<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>코드 리뷰 on monkshark.dev</title><link>https://monkshark.github.io/tags/%EC%BD%94%EB%93%9C-%EB%A6%AC%EB%B7%B0/</link><description>Recent content in 코드 리뷰 on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Tue, 23 Jun 2026 12:00:00 +0900</lastBuildDate><atom:link href="https://monkshark.github.io/tags/%EC%BD%94%EB%93%9C-%EB%A6%AC%EB%B7%B0/index.xml" rel="self" type="application/rss+xml"/><item><title>#3 - 요약을 온디바이스로, 네 엔진을 한 입구로</title><link>https://monkshark.github.io/p/pr-lens-summary/</link><pubDate>Tue, 23 Jun 2026 12:00:00 +0900</pubDate><guid>https://monkshark.github.io/p/pr-lens-summary/</guid><description>&lt;p&gt;마지막으로 얹은 건 PR 요약이다. 어려운 건 요약 자체가 아니라, &amp;ldquo;공짜로, 키 없이, diff 를 밖으로 안 보내고&amp;quot;와 &amp;ldquo;필요하면 최고 품질로&amp;quot;를 한 버튼 뒤에 같이 두는 일이었다.&lt;/p&gt;
&lt;h2 id="기본은-온디바이스-클라우드는-옵트인"&gt;&lt;a href="#%ea%b8%b0%eb%b3%b8%ec%9d%80-%ec%98%a8%eb%94%94%eb%b0%94%ec%9d%b4%ec%8a%a4-%ed%81%b4%eb%9d%bc%ec%9a%b0%eb%93%9c%eb%8a%94-%ec%98%b5%ed%8a%b8%ec%9d%b8" class="header-anchor"&gt;&lt;/a&gt;기본은 온디바이스, 클라우드는 옵트인
&lt;/h2&gt;&lt;p&gt;PR 을 AI 로 요약하려면 보통 diff 를 외부 모델로 보낸다. 코드 리뷰 도구가 매번 변경 내용을 남의 서버로 흘리는 건 기본값으로 두기엔 부담스러웠다. 그래서 기본 엔진을 브라우저 안에서 도는 모델로 잡았다.&lt;/p&gt;
&lt;p&gt;엔진은 넷이다. Chrome 내장 모델(Gemini Nano)은 키도 비용도 없이 기기 안에서 돌고, WebLLM 은 WebGPU 로 브라우저 안에서 도는데 첫 사용 때 가중치를 한 번 받아 캐시한 뒤로는 오프라인으로도 된다. 더 안정적인 클라우드가 필요하면 무료 키의 Gemini, 최고 품질이 필요하면 자기 키의 Claude 를 옵트인으로 고른다. 기본은 아무것도 안 나가고, 무언가 나가는 선택은 사용자가 직접 켠다.&lt;/p&gt;
&lt;h2 id="네-엔진을-한-모양으로"&gt;&lt;a href="#%eb%84%a4-%ec%97%94%ec%a7%84%ec%9d%84-%ed%95%9c-%eb%aa%a8%ec%96%91%ec%9c%bc%eb%a1%9c" class="header-anchor"&gt;&lt;/a&gt;네 엔진을 한 모양으로
&lt;/h2&gt;&lt;p&gt;엔진마다 호출법이 딴판이다. 내장 모델은 세션을 만들어 스트림을 reader 로 읽고, WebLLM 은 OpenAI 풍 &lt;code&gt;chat.completions&lt;/code&gt; 스트림, Gemini 는 날 SSE 를 직접 파싱, Claude 는 공식 SDK 의 &lt;code&gt;messages.stream&lt;/code&gt; 을 쓴다. 이걸 호출부에 그대로 노출하면 UI 가 엔진 수만큼 갈라진다.&lt;/p&gt;
&lt;p&gt;그래서 전부 같은 모양으로 감쌌다. system·user 프롬프트를 받아, 토큰이 올 때마다 &lt;code&gt;onChunk&lt;/code&gt; 로 흘려보내는 함수. 안쪽이 SSE 든 SDK 든, 바깥에서 보면 똑같이 &amp;ldquo;조각이 스트리밍된다&amp;quot;가 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;promptStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;onChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="입력은-diff-출력은-한국어-고정"&gt;&lt;a href="#%ec%9e%85%eb%a0%a5%ec%9d%80-diff-%ec%b6%9c%eb%a0%a5%ec%9d%80-%ed%95%9c%ea%b5%ad%ec%96%b4-%ea%b3%a0%ec%a0%95" class="header-anchor"&gt;&lt;/a&gt;입력은 diff, 출력은 한국어 고정
&lt;/h2&gt;&lt;p&gt;요약할 재료는 GitHub REST API 에서 가져온다. PR 메타와 변경 파일을 페이지네이션으로 긁고(비공개 저장소나 rate limit 은 PAT 로 푼다), 너무 길면 정해둔 길이에서 자른 뒤 &amp;ldquo;이후 생략&amp;rdquo; 을 표시한다.&lt;/p&gt;
&lt;p&gt;프롬프트는 출력 형식을 못박는다. UI 언어가 영어든 한국어든 요약 본문은 한국어로 고정하고, &amp;ldquo;한 줄 요약 / 핵심 변경점 / 리뷰 포인트&amp;rdquo; 세 섹션의 마크다운으로만 답하게 한다. 작은 온디바이스 모델일수록 형식을 흔들기 쉬워서, 자유도를 줄이는 쪽이 결과가 안정적이었다. 한 번 생성한 요약은 PR 별로 저장해, 다시 들어오면 곧바로 보여준다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;3부의 핵심은 모델이 아니라 입구였다. 성능과 프라이버시가 다른 네 엔진을 같은 한 줄 인터페이스 뒤에 세우고, 그 입구의 기본값을 &amp;ldquo;기기 안, 무료, 아무것도 안 나감&amp;quot;으로 둔 것. 더 좋은 걸 원하면 키를 꽂아 옵트인하면 되고, 아무것도 안 하면 가장 안전한 쪽이 작동한다. 도구가 사용자 대신 내리는 기본 선택이 가장 정직해야 한다는 게, 이 프로젝트 내내 지킨 원칙이었다.&lt;/p&gt;</description></item><item><title>#2 - 남의 DOM 위에 세 들어 산다</title><link>https://monkshark.github.io/p/pr-lens-resilience/</link><pubDate>Tue, 23 Jun 2026 11:00:00 +0900</pubDate><guid>https://monkshark.github.io/p/pr-lens-resilience/</guid><description>&lt;p&gt;패널을 띄우는 건 쉬웠다. 어려운 건, 그 패널이 남의 페이지 위에서 — GitHub 가 마크업을 바꿔도, diff 를 천천히 그려도 — 깨지지 않고 버티게 하는 것이었다.&lt;/p&gt;
&lt;h2 id="셀렉터는-한-곳에-모으고-사다리로-쌓는다"&gt;&lt;a href="#%ec%85%80%eb%a0%89%ed%84%b0%eb%8a%94-%ed%95%9c-%ea%b3%b3%ec%97%90-%eb%aa%a8%ec%9c%bc%ea%b3%a0-%ec%82%ac%eb%8b%a4%eb%a6%ac%eb%a1%9c-%ec%8c%93%eb%8a%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;셀렉터는 한 곳에 모으고, 사다리로 쌓는다
&lt;/h2&gt;&lt;p&gt;확장은 GitHub DOM 에 기댄다. 그런데 GitHub 는 자기 마크업을 수시로 바꾼다. 클래스 이름이 갈리고, &lt;code&gt;data-testid&lt;/code&gt; 가 생겼다 없어지고, 같은 시기에 옛 마크업과 새 마크업이 섞여 나오기도 한다. 셀렉터를 코드 곳곳에 흩뿌리면 그 변화 한 번에 전부 무너진다.&lt;/p&gt;
&lt;p&gt;그래서 DOM 을 읽는 지점을 셀렉터 모듈 하나로 모았다. 파일 컨테이너는 후보를 여러 개 묶어, 신·구 마크업을 한꺼번에 받는다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;fileContainer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;div.file.js-file&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;div.file[data-tagsearch-path]&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;[data-testid=&amp;#34;file-diff&amp;#34;]&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;[class*=&amp;#34;diffTargetable&amp;#34;]&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;,&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;파일 경로를 뽑을 때도 한 방법에 걸지 않았다. &lt;code&gt;data-tagsearch-path&lt;/code&gt; 속성 → 앵커 텍스트 → &lt;code&gt;Diff for:&lt;/code&gt; 표 라벨 → 내부 속성 → 파일 헤더 링크 title 순으로 내려가며, 하나가 실패하면 다음을 시도한다. 수치(+/-)도 같은 식으로 &lt;code&gt;aria-label&lt;/code&gt; → 색상 클래스 → 옛 diffstat 까지 폴백을 깔았다.&lt;/p&gt;
&lt;h2 id="못-찾으면-조용히-비운다"&gt;&lt;a href="#%eb%aa%bb-%ec%b0%be%ec%9c%bc%eb%a9%b4-%ec%a1%b0%ec%9a%a9%ed%9e%88-%eb%b9%84%ec%9a%b4%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;못 찾으면, 조용히 비운다
&lt;/h2&gt;&lt;p&gt;폴백을 다 내려가도 아무것도 못 찾는 날은 온다. 그럴 때 절대 하지 말아야 할 건, 에러를 던져 GitHub 페이지 자체를 망가뜨리는 것이다. 그래서 파일을 하나도 못 찾으면 패널은 깨지는 대신 &amp;ldquo;바뀐 파일 없음&amp;rdquo; 안내를 띄운다. 내가 못 읽는 게 사용자 페이지를 부수는 일이 되면 안 된다.&lt;/p&gt;
&lt;h2 id="내가-일으킨-변화에-내가-반응하지-않기"&gt;&lt;a href="#%eb%82%b4%ea%b0%80-%ec%9d%bc%ec%9c%bc%ed%82%a8-%eb%b3%80%ed%99%94%ec%97%90-%eb%82%b4%ea%b0%80-%eb%b0%98%ec%9d%91%ed%95%98%ec%a7%80-%ec%95%8a%ea%b8%b0" class="header-anchor"&gt;&lt;/a&gt;내가 일으킨 변화에 내가 반응하지 않기
&lt;/h2&gt;&lt;p&gt;GitHub 는 diff 를 한 번에 안 그린다. 스크롤하면 파일이 점점 붙는다. 그래서 DOM 변화를 &lt;code&gt;MutationObserver&lt;/code&gt; 로 지켜보다 다시 스캔해야 한다. 그런데 함정이 있다. 패널과 체크박스를 주입하는 것도 DOM 변화라, 내 주입이 옵저버를 깨우고, 그게 또 재스캔과 재주입을 부르는 무한 루프가 된다.&lt;/p&gt;
&lt;p&gt;두 겹으로 막았다. 변화가 전부 내 패널 안에서 난 거면 무시하고,&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;isOwnMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closest&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;#gh-prh-panel&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closest&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;.gh-prh-seen&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;재스캔하는 동안에는 옵저버를 잠시 끊었다가, 끝나고 다시 잇는다. 내가 만든 변화가 나를 다시 깨우지 못하게.&lt;/p&gt;
&lt;p&gt;또 매번 목록을 통째로 다시 그리면 깜빡이고 느리다. 그래서 파일 경로와 수치로 시그니처를 만들어, 지난번과 같으면 &amp;ldquo;봤음&amp;rdquo; 표시만 토글하고 DOM 은 건드리지 않는다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;2부는 기능이 아니라 규율에 대한 얘기였다. 남의 DOM 위에 세 들어 사는 코드는, 집주인이 벽을 옮겨도 버티고(폴백 사다리), 못 버틸 땐 조용히 비키고(graceful degrade), 자기가 일으킨 먼지에 자기가 기침하지 않아야(자기 변경 무시) 한다. 화면에 안 보이는 이 세 가지가 패널을 오래 살아남게 한 진짜 이유였다.&lt;/p&gt;</description></item><item><title>#1 - 큰 PR 은 어디까지 봤는지부터 잃는다</title><link>https://monkshark.github.io/p/pr-lens-tracker/</link><pubDate>Tue, 23 Jun 2026 10:00:00 +0900</pubDate><guid>https://monkshark.github.io/p/pr-lens-tracker/</guid><description>&lt;p&gt;PR Lens 는 GitHub 의 큰 Pull Request 를 리뷰할 때 &amp;ldquo;어디까지 봤는지&amp;quot;를 잃지 않게 돕는 Chrome 확장이다. Files changed 페이지에 바뀐 파일 트리를 띄우고, 본 파일을 PR 별로 기억하고, 남은 진도를 항상 보여준다. 한 줄로 줄이면, 수십 개 파일 diff 사이에서 길을 잃지 않게 하는 도구다.&lt;/p&gt;
&lt;h2 id="큰-pr-은-기능보다-진도를-먼저-잃는다"&gt;&lt;a href="#%ed%81%b0-pr-%ec%9d%80-%ea%b8%b0%eb%8a%a5%eb%b3%b4%eb%8b%a4-%ec%a7%84%eb%8f%84%eb%a5%bc-%eb%a8%bc%ec%a0%80-%ec%9e%83%eb%8a%94%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;큰 PR 은 기능보다 진도를 먼저 잃는다
&lt;/h2&gt;&lt;p&gt;파일이 마흔 개쯤 되는 PR 을 열면, 스크롤을 내리다 방금 본 파일이 어디였는지, 무엇이 남았는지 금세 흐려진다. 새로고침하거나 다른 PR 을 들렀다 오면 그 감각은 통째로 리셋된다. 그래서 첫 목표는 화려한 게 아니라 단순했다. 본 파일을 기억하고, 남은 게 몇 개인지 늘 보이게.&lt;/p&gt;
&lt;h2 id="패널은-페이지에-얹고-상태는-pr-에-붙인다"&gt;&lt;a href="#%ed%8c%a8%eb%84%90%ec%9d%80-%ed%8e%98%ec%9d%b4%ec%a7%80%ec%97%90-%ec%96%b9%ea%b3%a0-%ec%83%81%ed%83%9c%eb%8a%94-pr-%ec%97%90-%eb%b6%99%ec%9d%b8%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;패널은 페이지에 얹고, 상태는 PR 에 붙인다
&lt;/h2&gt;&lt;p&gt;Files changed 페이지에 떠 있는 패널을 하나 주입한다. 바뀐 파일을 전부 나열하고, 각 줄에 +/- 수치와 &amp;ldquo;본 파일&amp;rdquo; 체크박스, 위에는 &amp;ldquo;Seen N/M today&amp;rdquo; 진행 바를 둔다. 줄을 클릭하면 그 파일 diff 로 곧장 스크롤한다.&lt;/p&gt;
&lt;p&gt;기억은 PR 단위로 묶어야 의미가 있다. URL 에서 PR 을 식별해 키로 삼고, 그 키 밑에 본 파일을 저장한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;parsePrUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;prKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;#&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;상태는 &lt;code&gt;chrome.storage.local&lt;/code&gt; 에 &lt;code&gt;pr:owner/repo#번호&lt;/code&gt; 키로 들어간다. 체크 한 번이 &lt;code&gt;{ seen: true, at }&lt;/code&gt; 로 박히고, 새로고침하든 며칠 뒤 다시 오든 그대로 복원된다.&lt;/p&gt;
&lt;h2 id="github-는-페이지를-새로-안-띄운다"&gt;&lt;a href="#github-%eb%8a%94-%ed%8e%98%ec%9d%b4%ec%a7%80%eb%a5%bc-%ec%83%88%eb%a1%9c-%ec%95%88-%eb%9d%84%ec%9a%b4%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;GitHub 는 페이지를 새로 안 띄운다
&lt;/h2&gt;&lt;p&gt;여기서 막혔다. content script 는 페이지가 로드될 때 한 번 돈다. 그런데 GitHub 는 SPA 라, PR 목록에서 Files changed 로 넘어가도 페이지가 새로 뜨지 않는다. &lt;code&gt;pushState&lt;/code&gt; 로 URL 만 갈아끼운다. 그러면 스크립트는 처음 들어온 그 순간에 멈춰 있고, 패널은 영영 안 뜬다.&lt;/p&gt;
&lt;p&gt;그래서 history 를 후킹했다. &lt;code&gt;pushState&lt;/code&gt;·&lt;code&gt;replaceState&lt;/code&gt; 를 감싸 호출될 때마다 자체 이벤트를 쏘고, &lt;code&gt;popstate&lt;/code&gt; 까지 묶어 네비게이션을 직접 감지한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pushState&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;replaceState&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kr"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;gh-prh-loc&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이벤트가 오면 300ms 디바운스 뒤 현재 경로가 Files changed 인지 확인하고, 맞으면 패널을 띄운다. 같은 PR 안에서의 이동이면 다시 그리지 않고 스캔만 갱신하고, 다른 PR 로 갔으면 헐고 새로 짓는다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;1부에서 만든 건 결국 두 가지다. 본 파일을 PR 에 묶어 기억하는 것, 그리고 페이지가 새로 뜨지 않는 SPA 위에서도 그 기억을 제때 띄우는 것. 리뷰 보조라는 말은 거창하지만, 실제로 사람을 편하게 한 건 &amp;ldquo;어디까지 봤더라&amp;quot;를 대신 들고 있어 주는 이 단순한 영속성이었다.&lt;/p&gt;</description></item></channel></rss>