diff --git a/CLAUDE.md b/CLAUDE.md index fd5de1b..b02983a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ cc-statusline에 포함됐던 로컬 diff 뷰어를 독립 제품으로 분리 + **함정: Bun `$` 템플릿의 비ASCII 리터럴은 뭉개진다.** `` $`git … refs/remotes/origin/기능 …` `` 처럼 **리터럴**로 적으면 `uAE30uB2A5`라는 ASCII 텍스트가 된다(실측 코드포인트 `75 41 45 33 30 …`). `${보간}`으로 넘기면 온전하다(`ae30 b2a5`). 한글 브랜치명 테스트를 리터럴로 쓰면 한글이 아닌 브랜치를 만들어 놓고 **조용히 통과**한다 — 실제로 한 번 속았다. -"Grab" 기능은 diff의 특정 구간을 프롬프트와 함께 클립보드로 복사해 AI 에이전트에게 바로 붙여넣을 수 있게 한다. 진입 경로는 둘: ① 엔진 거터의 GitHub식 라인 선택 + 상주 "+" 버튼(CodeView의 `enableLineSelection`/`enableGutterUtility` 옵션으로 활성화), ② 코드 텍스트를 선택하면(드래그 릴리스 또는 더블/트리플클릭) 즉시 열리는 프롬프트 팝오버. 둘 다 `#grab-popover` 프롬프트 입력창을 열고, Enter로 "참조(파일 경로·상태·base)+스니펫+프롬프트"를 한 텍스트로 인코딩해 클립보드에 복사한다. `enableLineSelection`/`enableGutterUtility`는 전역 활성화라 모든 라인 호버에 "+"가 뜨고 find 매치 라인에도 상주하는데, 이는 의도된 UX 변화다. 텍스트 경로는 **제스처를 두 갈래로 인지한다**. ① 드래그: `pointerup`에서 pointerDown→pointerup 이동 거리가 `DRAG_THRESHOLD`(6px)를 넘었는지 본다(헤더 폴드 토글과 동일한 `movedBeyondThreshold` 관례). ② 멀티클릭: 더블/트리플클릭의 네이티브 단어·문단 선택은 **마우스가 전혀 움직이지 않아** ①을 원리적으로 통과하지 못하므로 `click` 이벤트에서 `event.detail >= 2`로 따로 받는다. 이 갈래가 `click`에 붙는 이유는 **Chrome의 `pointerdown`/`pointerup`은 `detail`이 언제나 0이라 클릭 횟수를 실어 나르지 않기 때문**이다(실측). 이 사실은 **포인터 이벤트에 한정된다** — `mousedown`/`mouseup`은 클릭 횟수를 그대로 싣는다(실측: 1, 2). 그래도 `click`이 맞는 지점인 건 down/up이 같은 타깃일 때만 발화하고 그 시점엔 네이티브 선택이 확정돼 있어서다. `dblclick` 이벤트로 짜면 안 된다: 트리플클릭엔 전용 이벤트가 없어(dblclick은 두 번째 클릭에서 끝난다) 세 번째 클릭의 문단 선택을 놓치고 단어 스니펫에 머문다. 트리플클릭에서는 두 번째 클릭이 팝오버를 한 번 열고, 세 번째 클릭의 pointerdown이 팝오버 바깥 dismiss에 걸려 그걸 닫은 뒤 다시 열린다 — 최종 상태는 옳지만 **한 번 깜박인다.** 바깥 dismiss에 디바운스나 "N ms 안엔 재오픈 금지" 가드를 넣으면 트리플클릭이 조용히 깨지니 주의. 문턱값 `>= 2`를 **자동으로 지켜주는 테스트는 없다**: `>= 1`로 밀려도 평범한 클릭은 선택 자체를 안 만들어(collapsed range) 팝오버가 안 열리므로 ⑧이 그대로 통과한다(실측). 문턱값을 실제로 가르는 유일한 제스처는 **shift+클릭 선택 확장**(detail 1인데 살아있는 선택을 만든다)인데, 이건 지금 팝오버를 열지 않는 **알려진 제외**다 — 드래그와 같은 상태를 만드는 표준 선택 관용구인데도 빠져 있으니, 나중에 지원하기로 하면 그때 ⑧의 짝으로 문턱값 가드를 함께 세울 것. 어느 갈래든 통과하면 스냅샷 의미론: 제스처 시점(한 틱 뒤)에 선택·파일·스니펫을 전부 고정해 이후 워커 하이라이트 DOM 교체나 recycle이 선택을 죽여도 안전하고, 팝오버는 스크롤로는 닫히지 않으며 Esc·팝오버 바깥 pointerdown·`renderPatch`(파일 목록 갱신) 발생 시, 그리고 복사 성공 후 1.2초 뒤 자동으로 닫힌다. 선택 소유권: 팝오버가 엔진 라인 선택(`codeView.selectedLines` — `data-selected-line`을 낳는 슬롯)을 "소유"한 경우에만 — 즉 거터 "+" 경로(`onGutterUtilityClick`)로 열렸을 때만 — 닫히거나 복사가 성공할 때 `codeView.clearSelectedLines()`로 그 선택을 해제한다(`onGutterUtilityClick`이 세우는 `grabOwnsLineSelection` 플래그를 `onCopied`/`onClosed`가 공통으로 가드). 텍스트 드래그 경로는 네이티브 브라우저 Selection만 읽을 뿐 이 슬롯을 건드리지 않으므로 플래그를 세우지 않는다 — 무조건 해제했다면 find 바가 `revealMatch`/`selectMatch`로 같은 슬롯에 세워 둔 매치 하이라이트가 텍스트 경로 팝오버의 Esc나 복사 성공만으로 지워지고, 팝오버를 연 적 없는 "파킹된"(드래그만 하고 아직 "+"를 안 누른) 거터 선택도 `renderPatch`(팝오버가 열렸든 아니든 진입부에서 항상 `close()`를 호출)마다 지워지는 회귀가 생긴다. 거터 경로에서 스테일 선택이 남으면 안 되는 이유: 엔진의 `InteractionManager.placeUtility()`는 활성 선택이 있으면 호버를 무시하고 "+"를 선택 하단 행에 고정하며(그 행이 더 이상 렌더 대상이 아니면 아예 숨김) 이후 다른 행 호버에서도 "+"가 뜨지 않게 된다. 텍스트 경로 팝오버가 열리면(`input.focus()`) 네이티브 드래그 선택은 붕괴한다 — 포커스가 문서 선택을 팝오버 input으로 **옮기는** 것이라(실측: `getComposedRanges`가 `#grab-popover`의 자식을 가리킨다) 페인팅만 멈추는 게 아니고, 강제로 되돌려도 첫 타이핑에 다시 붕괴한다. 그래서 잡은 라인은 **grab 하이라이트**가 대신 보여준다: `unsafeCSS`로 넣은 `::highlight(diffdeck-grab)`(파랑) + `CSS.highlights` 레지스트리로 칠하는 독립 채널이다. 앱이 shadow root에 직접 `