feat: 툴바에 지금 보고 있는 워크트리와 브랜치를 표시한다 - #72
Conversation
뷰어는 repo를 URL 파라미터로만 받아서, 화면 어디에도 "지금 어느 워크트리의
무엇을 보고 있는가"가 없었다. 워크트리를 여럿 쓰는 리포에서 엉뚱한 워크트리를
열어도 알아챌 단서가 없고, 게다가 뒤처진 브랜치에서는 merge-base가 HEAD라
어떤 base를 골라도 결과가 같아 보여서 "피커를 바꿔도 화면이 안 변한다"를
고장으로 오인하게 된다. 정체성을 상시로 말해 두면 그 오진이 생기지 않는다.
서버 변경은 없다. /api/refs가 이미 워크트리별 {path, branch, head}를 주고
있어서 그대로 재사용한다.
- browser/repoLabel.ts (신규): DOM에 닿는 문자열 전량을 만드는 순수 모듈.
main.ts는 커버리지 게이트 밖이라 거기 조립을 두면 유닛·커버리지·타입체크가
전부 초록인 채로 버그가 산다(isLargeFile 사건과 같은 구조).
- findWorktree는 정확 일치가 아니라 세그먼트 경계 최장 접두 일치다. repo는
기동 시점의 process.cwd()라 리포 루트라는 보장이 없고, 중첩 워크트리에서
바깥이 이기면 라벨이 엉뚱한 워크트리를 말한다. 피커의 HEAD 태그 판정도
같은 함수로 옮겨 "내가 어느 워크트리에 있는가"의 답을 하나로 만든다.
- detached는 emptyState.ts와 같은 어휘(detached @ <short>)를 쓴다.
- 갱신은 load()(부트스트랩·focus·refresh·토글)와 피커 열림에서만 한다.
watch의 poll()은 fetchDiff만 부르고 load()를 거치지 않으므로 2초 폴링이
git 서브프로세스를 상시로 늘리지 않는다. 신선도는 피커와 같다(5초 TTL).
- CSS는 새 값을 만들지 않는다: max-width 260px은 #ref-picker-btn과 같은 값,
ellipsis 3속성은 #ref-picker-label과 같은 세트, 색은 --vd-fg/--vd-fg-muted
2톤. 상자(배경·테두리·radius)는 주지 않는다 — 조작이 아니라 정보다.
회귀망: repo-label.test.ts 20종(경로·중첩·detached·비ASCII 폴백을 분기마다
따로 찌른다 — 커버리지 게이트는 branch를 세지 않는다) + toolbar-repo.e2e.ts
5종(배선, 공백 텍스트 노드, 런타임 브랜치 전환, 긴 이름의 레이아웃 상한 —
happy-dom에는 레이아웃이 없어 마지막 둘은 유닛이 원리적으로 못 잡는다).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
cb9de56 to
a40eb1f
Compare
다차원 리뷰(5관점 + 지적별 적대적 검증)에서 확인된 것들.
**중대: watch 모드에서 라벨이 무한정 낡는다.** 갱신을 load()와 피커 열림에만
달아 뒀는데, watch는 창을 안 보고 있을 때 쓰는 기능이라 focus가 발화하지
않는다. 그래서 diff만 2초마다 새 브랜치 것으로 갈리고 툴바·탭 제목은 옛
브랜치에 굳었다. 게다가 같은 화면의 빈 상태 카드는 /api/summary(캐시 없음)로
살아 있는 브랜치를 말하므로 한 화면이 두 브랜치를 동시에 주장했다 — 이 기능이
없애려던 오진을 새로 만드는 셈이다. poll()에서도 갱신한다. 비용은 /api/refs의
5초 TTL이 흡수한다(폴이 2초여도 git 호출은 5초에 두 번이 상한). "내용이 바뀐
폴에서만" 같은 조건은 못 쓴다 — 워킹트리가 깨끗한 채로 브랜치만 갈아타면
지문이 그대로라 304로 흘러 한 번도 안 돈다(그게 고쳐야 할 경우다).
**회귀: 좁은 창에서 툴바가 화면을 벗어난다.** flex item의 기본
min-width:auto가 라벨 폭만큼 툴바의 하드 최소폭을 늘려, 라벨 없이는 460px에서도
멀쩡하던 툴바가 560px에서 .tb-right를 화면 밖으로 밀어냈다(실측). 게다가
#status가 두 줄로 접혀 툴바 높이가 700px에서 43 → 49px로 뛰었다. `.tb-left {
min-width: 0 }` + `.tb-left > * { flex: none }`으로 줄어드는 몫을 라벨이
전담하게 한다 — 조작과 개수는 정보 라벨보다 우선한다.
**사실이 틀린 주석 둘.** ① 모듈 헤더가 typecheck를 분리 근거로 들었는데
`apps/viewer/tsconfig.json`의 include에 `browser/**`가 없어 새 모듈도 typecheck
밖이다(분리 근거는 커버리지 게이트뿐이다). ② `SHORT_OID_LENGTH = 7`이
`rev-parse --short`의 기본과 같다고 적었는데 그쪽은 core.abbrev(auto)를 따라
길어진다(실측: 3.5k 객체 7자, 37k 객체 8자). 카드와 한 글자 갈릴 수 있다는
사실과, 그럼에도 카드를 자르지 않는 이유(auto는 모호하지 않은 길이다)를 적었다.
**무력했던 테스트 계약.** toHaveText는 공백을 정규화해서 `name · main`도
통과한다 — 공백 텍스트 노드 금지 계약은 textContent를 그대로 봐야 잡힌다.
회귀망 2종 추가 + 뮤테이션으로 판별력 확인: poll()의 갱신을 지우면 ⑤만,
min-width:0 또는 flex:none을 지우면 ⑥만 죽는다(나머지는 전부 통과).
반증된 지적도 있다 — findWorktree의 최장 일치는 기존 유닛이 이미 잡고(뮤테이션
확인), 좁은 창에서 ellipsis는 실제로 발동한다.
CLAUDE.md에 이 UI 계약을 기록한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
리뷰 라운드 결과 (fe22571)5개 관점(정확성·배선·CSS/레이아웃·테스트 판별력·레포 관례)으로 리뷰하고 지적마다 반증을 시도해 검증했습니다. 확인된 것만 고쳤고, 반증된 것은 고치지 않았습니다. 고친 것① 중대 — ② 회귀 — 좁은 창에서 툴바가 화면을 벗어난다
flex item의 기본 ③ 사실이 틀린 주석 둘
④ 무력했던 테스트 계약 반증한 것 (고치지 않음)
판별력 검증새 계약이 실제로 지켜지는지 뮤테이션으로 확인했습니다. 각 뮤테이션이 해당 스펙만 죽입니다:
검증
로컬 전체 e2e에서 CLAUDE.md에 이 UI 계약(갱신 시점 셋·CSS 짝 규칙·OID 길이 차이·findWorktree 규칙)을 기록했습니다. |
라벨이 워크트리 디렉토리 이름 하나를 "리포 이름"인 양 말하고 있었다. 그래서 중첩 워크트리에서는 `feat+PAY-412 · feat/PAY-412-idempotency`처럼 떠서 어느 리포인지 알 수 없었고, 반대로 리포 이름만 말했다면 어느 워크트리인지 알 수 없었을 것이다. 둘은 서로 다른 축이라 둘 다 말한다: 메인 워크트리 payments-api · main 링크된 워크트리 payments-api / feat+PAY-412 · feat/PAY-412-idempotency 색은 **구별되는 조각 하나만** 밝힌다(#repo-name). 리포 이름은 그 리포의 워크트리마다 같아서 화면을 가르지 못하기 때문이고, 같은 이유로 탭 제목에는 리포 접두를 넣지 않는다(탭은 좁고 오른쪽부터 잘린다). 자리는 개수(#status) 바로 왼쪽이다 — 왼쪽 그룹은 조작(피커·뷰 모드·새로고침)이 먼저 오고 그 결과를 말하는 정보가 뒤에 온다. **git 호출은 늘지 않는다.** parseRepoRoot가 `worktree list --porcelain` 원본의 첫 레코드를 읽는다. git은 메인 워크트리를 항상 먼저 내며 링크·중첩 워크트리에서 실행해도 그렇다(실측). parseWorktreeList의 결과를 대신 쓰면 안 된다 — 그쪽은 bare·prunable을 걸러내는데 bare 리포는 메인 항목이 바로 그 bare라 첫 항목이 링크된 워크트리로 밀리고, 그러면 라벨이 남의 워크트리를 리포 이름이라 말한다 (실측). bare 루트의 관례적 `.git` 접미는 벗긴다. **max-width: 260px를 제거한다.** 실측해 보니 좁은 창에서 상한 유무의 수치가 완전히 같았고(700/560/460px에서 각각 221/81/0px) 161자 브랜치에서도 툴바가 넘치지 않았다 — 폭을 붙드는 기제는 flex shrink 하나뿐이고 상한은 오버플로 방어에 아무것도 보태지 않으면서, 세 조각의 자연폭 350px을 공간이 남는데도 260px로 잘라 "내가 어디 있는지 말한다"는 이 라벨의 존재 이유를 깎고 있었다. e2e의 픽셀 상한 단언도 의미 있는 계약(잘림 + text-overflow 계산값)으로 바꿨다. 회귀망: repo-label.test.ts 26종 + diff-refs.test.ts의 parseRepoRoot 4종(bare가 핵심) + toolbar-repo.e2e.ts 7종(⑤ 링크된 워크트리 두 축과 배치 순서 추가). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
워크트리 워크플로에서는 작업이 브랜치에 **커밋**돼 있어서 기본 뷰(미커밋
변경)가 구조적으로 비어 있다. 즉 볼 게 가장 많은 순간에 빈 화면이 뜨고,
프리퍼런스 키가 리포 경로별이라 새 워크트리를 팔 때마다 카드의 버튼을 한
번씩 눌러 줘야 했다(실측: 미커밋 0건인데 main 대비 176 files인 워크트리).
판정을 새로 세우지 않고 **카드 자신의 액션을 읽는다** — 카드가 전환을 권할
상황이 곧 전환할 가치가 있는 상황이라 조건이 두 곳으로 갈라지지 않는다.
불변식 셋이 계약이다:
① 사용자가 고른 적 있으면 덮지 않는다(URL base= 또는 저장된 프리퍼런스).
resolveCompareBase가 null을 주는 것이 "고른 적 없음"의 정의다. 모듈
초기화 때 캐시하면 피커로 고른 뒤에도 옛 값이 남아 선택을 덮으므로
호출 시점에 다시 읽는다.
② 토글로 감춰졌을 뿐 이 뷰에도 볼 것이 있으면(untracked) 데려가지 않는다.
카드가 두 선택지를 나란히 보여주는 편이 낫다. 이 경계선이 없으면 기존
카드 스펙이 죽는다(실측으로 확인).
③ 저장하지 않는다 — 추론이지 사용자의 선택이 아니다. 저장하면 고른 적 없는
프리퍼런스가 생겨 불변식 ①이 이후 모든 자동 전환을 영구히 막는다.
그래서 applySelection(사용자 경로, 저장함)과 selectBase(next, {persist})가
갈라졌다. applySelection이 패널 닫기·포커스 복귀를 조기 반환보다 앞에 두는
순서는 그대로다 — 이미 고른 행을 눌러도 피커는 닫혀야 한다.
회귀망: shouldAutoViewBase 유닛 5종 + empty-state.e2e.ts 2종. 뮤테이션으로
판별력 확인 — untracked 경계선을 지우면 기존 카드 스펙만, persist:true로
바꾸면 새 스펙만 죽는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
빈 워킹트리에서의 자동 base 전환 (8bbe009)로컬 테스트 중 발견한 문제를 함께 고쳤습니다. 워크트리를 열었더니 기본 뷰가 "미커밋 변경"이라 정말로 비어 있습니다. 그런데 워크트리 워크플로에서는 작업이 브랜치에 커밋돼 있으니 이 상태가 구조적입니다 — 볼 게 가장 많은 순간에 빈 화면이 뜨고, 프리퍼런스 키가 리포 경로별이라 새 워크트리를 팔 때마다 카드의 버튼을 한 번씩 눌러야 했습니다. 이제 고른 적이 없으면 볼 것이 있는 쪽을 엽니다. 판정을 새로 세우지 않고 카드 자신의 액션을 읽습니다( 불변식 셋이 계약① 사용자의 선택은 절대 덮지 않는다. URL ② 토글로 감춰졌을 뿐 이 뷰에도 볼 것이 있으면(untracked) 데려가지 않습니다. 카드가 두 선택지를 나란히 보여주는 편이 낫고, 이 경계선이 없으면 기존 카드 스펙이 죽습니다. ③ 저장하지 않는다. 추론이지 사용자의 선택이 아닙니다. 저장하면 고른 적 없는 프리퍼런스가 생겨 불변식 ①이 이후 모든 자동 전환을 영구히 막습니다. 그래서 판별력 (뮤테이션)
검증
기존 3종이 그대로 통과하는 게 핵심입니다 — 불변식 ②가 없었으면 첫 번째 스펙이 깨졌을 겁니다. |
실측 리포의 브랜치가 679개라, 목록에서 "나는 어디 있나"를 확인하려면 훑거나 검색어를 쳐야 했다. 맨 위 자리를 그 답에 준다. 올리는 근거는 태그가 아니라 **위치**다. 자기 브랜치가 기본 브랜치이기도 하면 toRow의 태그는 default로 남지만(그 삼항의 우선순위는 그대로) 지금 있는 곳이라는 사실은 변하지 않으므로 여전히 올린다. 올림은 로컬 안에서만 한다 — 체크아웃된 브랜치는 언제나 로컬이고(원격 레코드 이름은 origin/main 꼴이라 짧은 현재 브랜치명과 애초에 안 맞는다) 로컬이 원격보다 앞이므로 그것으로 구역 맨 위가 된다. **순서는 유닛이 정하지만 배선은 못 잡는다.** main.ts가 현재 브랜치를 넘기지 않으면 유닛 전부가 통과한 채 화면만 조용히 옛 순서로 돌아간다. 그래서 e2e를 짝으로 뒀고, 뮤테이션으로 확인했다 — current를 null로 바꾸면 그 e2e 하나만 죽고 나머지 7종은 통과한다. 회귀망: ref-picker-model.test.ts 4종(올림·기본브랜치 겸용·로컬우선 유지· detached 무변화) + ref-picker.e2e.ts 1종. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
피커 목록 맨 위를 지금 체크아웃한 브랜치에 (1a16899)실측 리포의 브랜치가 679개라, 목록에서 "나는 어디 있나"를 확인하려면 훑거나 검색어를 쳐야 했습니다. 맨 위 자리를 그 답에 줍니다. 설계 결정 셋올리는 근거는 태그가 아니라 위치다. 자기 브랜치가 기본 브랜치이기도 하면 올림은 로컬 안에서만. 체크아웃된 브랜치는 언제나 로컬이고(원격 레코드 이름은 순서는 유닛이 정하지만 배선은 못 잡는다. 회귀망
기존 순서 의존 스펙 2곳은 그대로 통과합니다 — 필터 후 단일 행을 보거나( 검증
|
파일 수만으로는 규모를 못 읽는다 — 176 file(s)가 한 줄씩 고친 것인지 통째로 새로 쓴 것인지 열기 전에는 갈리지 않는다. **세는 값의 출처가 계약이다.** hunk의 **숫자** 필드이고, largeFile.ts의 countChangedLines와 같은 곳이다. FileDiffMetadata에도 동명 필드가 있지만 그건 string[]이고 파일 전량으로 파싱한 diff에서는 새/옛 파일의 전체 내용이라, 세면 변경량이 아니라 파일 길이가 나온다(그 착각이 실제로 대형 파일 자동 접힘을 오작동시킨 적이 있다). 합산은 renderPatch가 이미 전량 파싱해 둔 items에서 하므로 git 호출도 파싱도 늘지 않는다. **git과 수치가 같다.** 실측: 176파일 워크트리에서 `git diff --numstat <merge-base>` 합이 +17105 -435이고 화면도 같다(바이너리 8개는 양쪽 다 줄 수에서 빠진다). e2e가 그 대조를 매번 한다 — 기대값을 하드코딩하지 않고 스펙 안에서 git을 돌려 합산해 비교하므로, 클라이언트 재-diff가 git과 갈리면 바로 빨간불이 난다. 한쪽이 0이어도 둘 다 말한다(git·GitHub과 같다 — 자리가 사라지면 남은 숫자가 추가인지 삭제인지 색에만 의존하게 된다). 센 줄이 하나도 없으면(이미지만 바뀐 diff) 자리를 통째로 비운다. 갱신은 renderPatch 한 곳뿐이다 — 로딩·실패 중에는 손대지 않는데, 그때 화면에는 직전 diff가 그대로 남아 있으므로 숫자도 그것이 맞기 때문이다. 색은 앱에 이미 있는 add·del 쌍을 쓴다(--vd-success / #f85149). 엔진의 --diffs-addition-base는 shadow root 안에만 살아서 툴바에서는 해석되지 않는다. 회귀망: change-totals.test.ts 9종 + toolbar-totals.e2e.ts 3종. 뮤테이션으로 판별력 확인 — renderPatch의 합산 배선을 지우면 앞의 둘이 죽는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금까지 diff의 오른쪽(new)은 언제나 워킹트리였다. 피커가 head를 고르게 하려면 그 축이 열려야 한다. base와 **독립**인 축으로 더한다. wire는 `head=<ref>`이고 없거나 비면 워킹트리다. **base와 달리 head=HEAD는 정규화하지 않는다** — base=HEAD는 "커밋 안 한 것만"이라 워킹트리 뷰와 결과가 같지만, head=HEAD는 커밋된 HEAD를 보는 것이라 미커밋 변경이 빠진다. 둘을 합치면 그 구분이 사라진다. **갈림점은 head 기준이어야 한다.** merge-base(ref, HEAD)로 재면 브랜치를 head로 볼 때 지금 워크트리의 HEAD와 갈림점을 잡는데, 그 둘은 아무 관계도 없다 — 남의 브랜치를 보면서 내 위치를 기준 삼는 셈이다. 그러면 base 브랜치가 그 사이 만든 파일이 "head에서 삭제됨"으로 끼어든다. 뮤테이션으로 확인했다: HEAD 기준으로 되돌리면 diff-command의 새 테스트 2종이 죽는다. **커밋된 rev에는 untracked가 없다** — 토글이 켜져 있어도 건너뛴다. 디스크를 훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 사실이 아니다. **head도 base와 같은 보안 경계를 탄다.** verifyBaseRef가 첫 글자 `-`를 먼저 끊는 이유(옵션 주입)는 어느 축이든 유효하고, 이제 그 값이 git diff의 두 번째 인자로도 간다. 표식은 x-diff-error: unknown-head로 base와 가른다 — 클라이언트의 자가복구가 다르기 때문이다(base는 저장된 프리퍼런스를 지우면 되지만 head는 URL의 축을 되돌려야 한다). 캐시 키와 지문에도 head가 들어간다. 키는 "flight 클로저가 읽는 모든 입력의 전함수"라는 기존 계약이고, 빠지면 워킹트리 뷰와 브랜치 뷰가 같은 슬롯에 합류해 한쪽이 남의 diff를 받는다. 지문에는 head rev를 넣어 브랜치가 움직이면 캐시가 깨지게 한다. 이미지 blob도 같은 축을 탄다 — 예전에 라우트마다 기준이 갈려 이미지 카드가 텍스트와 다른 비교를 보여준 적이 있다. 회귀망: diff-selection.test.ts 7종 + diff-command.test.ts 4종(갈림점 기준이 핵심) + diff-server.test.ts 3종(HTTP 200 경로 · unknown-head 400 · 옵션 꼴 head가 git에 닿지 않음). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
브랜치를 head로 보고 있으면 카드가 워크트리를 설명하면 안 된다. **재지 않은 것을 0으로 적지 않는다.** workingFiles·untrackedFiles가 `number | null`이 되고, head가 커밋된 리비전이면 null이다. 0으로 적으면 "아무것도 없다"는 **주장**이 되어, 실제로는 미커밋 변경이 있는데도 카드가 "Nothing to show in any mode"라고 말한다. 이 파일이 이미 세운 "측정한 것만 주장한다" 원칙을 그대로 따른다. branch는 보고 있는 그 브랜치를 말한다 — 워크트리의 브랜치를 말하면 보고 있지도 않은 곳을 가리킨다. baseFiles·aheadCommits도 diff와 같은 축(head 기준 merge-base)으로 센다. /api/summary도 head를 받는다. 카드와 화면이 다른 비교를 설명하는 것은 parseSelection을 한 곳으로 모은 이유 그 자체다. 회귀망: summary.test.ts 3종(워킹트리 무주장 · 보고 있는 브랜치 이름 · head 기준 개수). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
목록에 뜬 브랜치 이름이 "그 브랜치를 보여줘"로 읽히는데 실제로는 반대 축을 건드리고 있었다. 메인 워크트리에서 feature 브랜치를 골라도 1 file만 나오던 것이 그 결과다 — 같은 브랜치를 워크트리로 열면 176이었다. **두 구역은 고르면 일어나는 일이 다르다.** 워크트리는 다른 리포 경로라 그 URL로 이동하고(repo가 바뀌므로 diff·트리·라벨·프리퍼런스 키가 전부 갈린다), 브랜치는 같은 워크트리 안의 축이라 head만 바꾼다. 옮기면서 head를 들고 가지 않는 이유는 그것이 워크트리에 매인 값이 아니어서다 — 그대로 두면 새 워크트리에서 남의 브랜치를 보게 된다. **head는 저장하지 않는다.** URL이 진실이라야 새로고침·링크가 재현되고, 저장하면 다음에 그 리포를 열 때 남의 브랜치 뷰에 갇힌 채 시작한다. 순서는 두 구역 모두 default → 지금 보고 있는 것 → 받은 순서다. 올리는 근거는 태그가 아니라 위치다. **워크트리 구역은 고를 것이 없으면 사라지되, head가 브랜치면 남긴다** — 제목만 남기고 비우면 "뭔가 있어야 하는데 없다"로 읽히지만, 워크트리가 하나뿐인 리포에서 무조건 숨기면 브랜치 뷰에 갇혀 돌아올 길이 없어진다. 행의 파일 개수는 뺐다. 그 수치는 base 축의 것이라 head를 고르는 지금은 행의 뜻과 맞지 않고, getRepoSummary가 의도적으로 single-flight 밖이라 목록이 그 속도에 묶여 있었다. 빈 상태 스펙 넷을 새 계약으로 옮겼다 — 피커 라벨은 이제 head를 말하므로 base의 증거가 아니고, 관찰 가능한 신호(카드 유무·개수·컨텍스트 줄)로 바꿨다. untracked 재계산 회귀 가드는 head를 바꾸기 **전에** 확인하도록 순서를 조정했다: 커밋된 rev를 보고 있으면 untracked는 재지 않은 값이라 그 안내가 원리적으로 없다. **e2e 함정 기록**: 픽스처에 `git remote add`를 쓰면 base 해석의 첫 단계인 `gh pr view`가 그것을 GitHub 리포로 풀어 보려 해서 스펙 하나가 수십 초씩 걸린다(실측: 이 파일 전체가 16초 → 15.5분). default 브랜치가 필요하면 update-ref + symbolic-ref로 symref만 직접 세운다. 회귀망: ref-picker-model.test.ts 19종 + ref-picker.e2e.ts 9종. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
브랜치를 head로 골랐을 때 라벨이 **워크트리의** 브랜치를 말하고 있었다. 화면에는 A의 diff가 떠 있는데 라벨과 탭 제목은 B라고 했다 — 오늘 내내 고쳐 온 "화면이 자기가 뭘 보고 있는지 잘못 말하는" 문제가 새 형태로 되살아난 것이다. 실측으로 확인했다: 라벨 : engagement-frontend / docs+MEC-230-… · docs/MEC-230-… ← 틀림 화면 : feature/FE-25807-common-comment 의 176 files 셋을 함께 고친다. ① **브랜치 뷰에서는 워크트리 이름을 뺀다.** 브랜치를 head로 보면 워크트리는 결과에 영향을 주지 않는다 — 어느 워크트리에서 보든 같은 diff다(실측: 워크트리 셋에서 전부 176). 이름을 두면 "이 워크트리의 무언가를 보고 있다"는 잘못된 인상을 준다. 주인공 자리는 보고 있는 브랜치가 갖는다. ② **base를 표시한다.** `· vs main`. 견줄 기준이 워킹트리(HEAD)면 말하지 않는다 — 그건 "커밋 안 한 변경"이지 무엇과 견준 결과가 아니다. ③ **브랜치를 고르면 base를 자동 해석으로 올린다.** 커밋된 rev에는 미커밋 변경이 없어 워킹트리 기준과의 조합은 언제나 빈 diff다. 자동 base 전환이 결국 구제하지만 그 전에 빈 화면이 한 번 깜박였다. URL에는 싣지 않는다 — 추론이지 사용자의 선택이 아니고, 새로고침하면 그 전환이 같은 상태를 만든다. 라벨은 `syncPickerLabel`에서 함께 다시 그린다. head·base가 바뀌는 지점이 곧 그 함수를 부르는 지점이라 둘이 갈라져 한쪽만 낡는 일이 생기지 않는다. 워크트리 뷰 : engagement-frontend / docs+MEC-230-… · docs/MEC-230-… · vs main 브랜치 뷰 : engagement-frontend · feature/FE-25807-common-comment · vs main 회귀망: repo-label.test.ts 7종 추가(33종) + toolbar-repo.e2e.ts 1종 추가(8종). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
피커가 base가 아니라 head를 고르게 된 순간, 트리거가 되풀이하던 선택
항목과 개수 왼쪽 라벨이 같은 말을 하게 됐다. 고르는 자리와 지금 무엇을
보는지 말하는 자리를 하나로 합쳐 둘이 어긋날 수 없게 하고, 남은 자리는
트리거가 결코 말하지 않는 축(견줄 기준)만 가진다.
- 트리거가 리포·워크트리·브랜치를 통째로 말한다
(#ref-picker-label = #picker-scope + #picker-name + #picker-branch)
- 개수 왼쪽은 #base-label — "vs main" 하나뿐이다
- 브랜치를 head로 보면 워크트리 조각이 빠지고 head가 주인공 자리를
가진다. 커밋된 rev의 diff는 어느 워크트리에서 보든 같으므로 워크트리
이름은 거짓 인상을 주고, 그 워크트리의 브랜치는 보고 있지도 않은 곳을
가리킨다.
폭을 붙드는 사슬에 `.tb-picker { display: flex }`가 새로 필요하다 —
블록이면 안쪽 inline-flex 버튼이 flex item이 아니라 줄어드는 몫을 못
받고 자연폭 그대로 넘쳐, 트리거가 아무리 좁아져도 라벨이 한 번도 잘리지
않는다(실측: 1280px 창에서 161자 브랜치인데 scrollWidth == clientWidth).
뮤테이션으로 확인했다 — 지우면 e2e ⑧의 넓은 창·좁은 창 clipped 단언이
둘 다 죽는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
왼쪽 그룹은 한 문장으로 읽혀야 한다 — 무엇을 보는가 · 무엇과 견주는가 · 몇 파일인가 · 몇 줄인가. 그런데 Unified/Split과 새로고침이 첫 마디와 둘째 마디 사이에 앉아 그 문장을 두 동강 냈다. 오른쪽 그룹은 이미 순수 조작이었으므로, 조작이 두 곳에 흩어지고 정보가 그 사이에 끼는 배치였다. 옮겨도 폭은 손해 보지 않는다(실측): 총 폭이 그대로라 트리거가 줄어들 몫도 그대로여서, 560px 창에서 트리거 160px · 넘침 54px로 옮기기 전과 완전히 같은 값이다. 오른쪽 순서 `찾기 · 새로고침 │ Unified/Split · 트리 │ ⋯`의 근거: - 찾기가 맨 앞인 것은 실측이다. find 바는 돋보기 자리에서 340px로 펼쳐지는데 이 그룹은 오른쪽 끝에 고정돼 있어 바가 자라면 그 왼쪽 이웃만 밀린다. 찾기 우선이면 이동량 0px, 세그먼트를 앞에 두면 −285px. - 가운데 셋은 순간 동작(찾기·새로고침) / 상태 토글(렌더 방식·트리)로 갈랐다. 118px 세그먼트가 두 무리 사이에 놓여 축의 경계와 시각적 경계가 겹친다. - 트리 토글이 ⋯ 옆인 것은 메뉴의 Hide file tree가 같은 토글이고, 메뉴 여섯 중 넷이 레이아웃이기 때문이다. 회귀망 toolbar-order.e2e.ts 3종. ②(바를 열어도 아무것도 안 움직인다)가 ①(순서)의 중복이 아님을 뮤테이션으로 확인했다 — margin-left:auto를 고정 마진으로 바꾸면 ①은 통과하고 ②만 죽는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
renderPatch의 빈 분기가 "No changes."를 동기로 그려 놓고, 요약이 도착하면 정보형 카드로 덮었다. 그 사이가 실측 60~80ms라 사용자에게는 없다고 한 번 말한 뒤 말을 바꾸는 것으로 보인다 — 첫 로드 673ms "No changes." → 753ms "No tracked changes …", head를 고르면 282ms → 340ms. 자동 base 전환이 걸리는 경우가 가장 나쁘다: 볼 것이 있는데도 없다고 말한 뒤 diff가 뜬다. 이제 자리만 잡고(LOADING_MARKUP — 첫 로드 표시와 같은 마크업이라 로딩에서 로딩으로 넘어가는 자리가 깜박이지 않는다) 문구는 enrichEmptyState가 한 번만 쓴다. "No changes."는 폴백이 되어 요약 fetch가 실패했을 때만 나오고, 그때 data-loading을 함께 걷어낸다. 회귀망 empty-state.e2e.ts 3종. 이 종류는 최종 상태로는 원리적으로 안 보이므로(끝나면 옳은 카드가 떠 있다) addInitScript로 첫 페인트 전에 MutationObserver를 걸어 #empty가 거쳐 간 문구를 전부 기록한다. 뮤테이션으로 판별력 확인 — 옛 동기 문구를 되돌리면 ⑥⑦만, 폴백 write를 지우면 ⑧만 죽는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**Critical — `git diff`에 `--`가 없었다.** 참조 이름이 트래킹된 경로와 겹치면(`docs`·`src`·`test`처럼 흔하다) git이 rev인지 path인지 못 정해 `ambiguous argument`로 죽는데, `2>/dev/null` + `.nothrow()`가 그 실패를 빈 문자열로 삼켜 **에러 없는 "변경 없음" 화면**이 된다(실측). 피커가 `%(refname:short)`를 그대로 넘기므로 목록에서 고르기만 해도 도달한다. head 축이 사용자 ref 이름을 `git diff`의 위치 인자로 처음 통과시키며 연 노출이다 — 예전엔 언제나 HEAD 아니면 merge-base OID였다. 다른 git 명령(merge-base·rev-list·rev-parse·show)은 rev만 받아 영향 없음을 실측 확인. **부트스트랩 TDZ.** applyRepoLabel([], null)이 compareBase·currentHead 선언보다 앞에서 돌았다. 살아 있던 이유는 번들러뿐이다 — bun build가 최상위 let을 var로 낮춰 TDZ가 아니라 undefined가 됐다(실측: 같은 코드를 ESM 그대로 평가하면 ReferenceError로 모듈이 죽는다). 게다가 undefined는 null이 아니라 head 분기를 통과해 #base-label에 "vs undefined"를 썼다. 뒤의 호출이 덮어써서 페인트만 안 됐다. 그 호출 하나로 합쳤다. **재지 않은 것에 대한 주장.** emptyState가 untrackedFiles를 `?? 0`으로 접어, head가 커밋된 rev라 워킹트리를 재지도 않은 화면이 "Working tree clean"이라고 그 워킹트리에 대해 단언했다 — null을 도입한 이유 그 자체다. **grab 참조가 head를 말하지 않았다.** 브랜치 head 뷰에서 잡은 줄은 그 브랜치의 커밋된 내용인데, 붙여넣기를 받은 에이전트는 자기 워킹트리의 같은 경로를 연다. `on <head>`를 싣는다. **CSS 계약 두 가지를 사실에 맞춘다.** width 사슬은 넷이 아니라 다섯이고 (#ref-picker-btn의 min-width:0이 display:flex와 한 짝), .tb-picker는 특이도가 아니라 소스 순서로만 이기고 있었다(주석은 "id 규칙이 이긴다"고 적었다) — #toolbar로 스코프해 순서 의존을 없앴다. 테스트 공백도 메웠다: 지문의 head rev, /api/blob·/api/summary의 unknown-head 400, blob의 head 리비전 읽기. 넷 다 라인 커버리지로는 초록인 채 한 번도 안 나가던 분기다(게이트는 branch를 안 센다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**vacuous 테스트 둘을 실제 가드로 바꿨다.**
- toolbar-totals ③: clean 리포로 띄우면 첫 렌더가 곧 빈 분기라 두 span이
마크업 상태 그대로여서, 빈 분기의 applyChangeTotals([]) 배선을 통째로
지워도 초록이었다. 숫자가 쓰인 뒤 사라지는 **전이**를 본다(뮤테이션으로
확인 — 클리어를 지우면 죽는다).
- ref-picker ⑦: 워킹트리 뷰에서 시작하니 URL에 head가 애초에 없어서,
워크트리 이동이 head를 실어 날라도 통과했다. 먼저 head를 세운 뒤 고른다
(뮤테이션 확인 — delete("head")를 지우면 라벨이 옛 브랜치를 말하며 죽는다).
- head를 저장하지 않는다는 계약에도 단언이 없었다 — localStorage 검사 추가.
**피커 첫 오픈이 거짓말을 했다.** head 모델에는 목록 없이 세울 수 있는 행이
하나도 없어(예전 base 모델의 "Working tree 행은 항상 있다"는 전제가 깨졌다)
첫 오픈이 정말로 빈 배열인데 "No match"를 썼다 — "이 리포엔 고를 게 없다"로
읽힌다. pickerLoaded로 가른다.
**피커 필터가 note를 안 봤다.** 워크트리 행의 브랜치 이름이 거기 사는데
label만 봐서, 브랜치명을 타이핑하면 그걸 물고 있는 워크트리가 안 잡혔다.
**listbox에 role 없는 자식이 섞였다.** 구역 제목·구분선·빈 문구가 직접
자식이라 보조기술이 옵션 수를 잘못 세거나 제목을 옵션처럼 읽는다.
**enrichEmptyState 스냅샷에 head가 빠졌다.** base가 이미 @auto면 head만
갈아타도 mode가 안 바뀌어, marker 가드 이전 창에서 옛 head의 카드가 그려질
수 있었다.
aria-label "View"는 동사로 읽혀 "What to view"로. CLAUDE.md의 회귀망 개수도
실제 값으로 맞췄다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM
**`?head=<branch>`가 그냥 빈 화면이었다.** base=HEAD(= mode=working, = base 미지정)는 "아직 커밋 안 한 것만"이라는 뜻인데 커밋된 rev에는 그런 게 없어서 `git diff <rev> <rev>`가 되어 에러 없이 0건이 나온다. 가장 자연스러운 수동· 공유 URL이 아무것도 안 보여줬고, 카드는 "No changes on main"이라 말하는데 실제로는 그 브랜치에 31파일이 있었다(실측). 자동 base 전환도 구제하지 못한다 — mode가 working이면 getRepoSummary가 base 개수를 아예 재지 않아 카드에 switch-mode 액션이 안 생기기 때문이다. 이 규칙은 원래 브라우저 한 줄(applyPick의 "HEAD" → "@auto")에만 살아서 URL을 손으로 만들면 우회됐다. parseSelection으로 올려 한 곳으로 만들었고, 화면도 같은 규칙을 써야 하므로 baseDisplay()가 head가 있을 때 HEAD를 auto처럼 표시한다 — 안 그러면 서버는 main과 견주는데 툴바는 아무 말도 안 한다. 사용자가 고른 진짜 base(base=develop)는 그대로 둔다. **사라진 head가 막다른 화면이었다.** 머지 후 삭제된 브랜치를 가리키는 링크는 "Failed to load diff."만 남기고 새로고침해도 같아서 스스로 못 빠져나온다. base처럼 조용히 되돌리지는 않는다 — base의 스테일 값은 사용자가 볼 수 없는 localStorage에 살지만 head는 링크가 요청한 것이라, 말없이 다른 화면을 보여주면 사용자가 속는다. 무엇이 없는지 말하고 나갈 길 하나를 준다. 회귀망: diff-selection 3종(세 URL 꼴·진짜 base 보존·워킹트리 뷰 불변) + ref-picker.e2e ⑩(카드 문구·URL 불변·버튼이 head를 떨군다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM

왜
뷰어는 repo를 URL 파라미터로만 받아서, 화면 어디에도 **"지금 어느 리포의 어느 워크트리에서 무엇을 보고 있는가"**가 없었다.
실제로 밟은 사고: 워크트리를 4개 쓰는 리포에서 엉뚱한 워크트리(
main, origin보다 1044 커밋 뒤처짐)를 열어 놓고 "changed files가 1개밖에 안 나온다"고 판단했다. 실제 작업 브랜치는 별도 워크트리에 있었고 거기선 176 files가 정상적으로 나왔다. 화면에 단서가 없으니 오진을 가려낼 수 없었다.원인을 파다 보니 툴바 피커 자체가 틀린 축을 건드리고 있었다. 목록의 브랜치 이름은 "그 브랜치를 보여줘"로 읽히는데 실제로는 *견줄 기준(base)*을 골랐다 — 그래서 메인 워크트리에서 남의 브랜치를 골라도 1 file만 나왔다(같은 브랜치를 워크트리로 열면 176). 게다가 뒤처진 브랜치에서는
merge-base가 HEAD라 어떤 base를 골라도 결과가 같아 보인다 — "피커를 바꿔도 화면이 안 변한다"가 고장으로 읽힌다.이 PR은 그 한 갈래를 끝까지 따라가 툴바를 다시 세운다: 표식을 만들고 → 피커를 head 축으로 돌리고 → 중복을 걷어내고 → 조작과 정보를 자리로 갈랐다.
Screenshots
Before (
origin/main) — 툴바가 어느 리포/워크트리/브랜치인지 말하지 않는다. 탭 제목도diffdeck뿐.After ① 메인 워크트리 — 리포 접두를 생략해
diffdeck · feat/toolbar-repo-branch. 개수 오른쪽에 전체 변경량.After ② 링크된 워크트리 —
diffdeck / feat+SHOT-1-toolbar · shot/linked-preview. 세 조각이 서로 다른 축이다.After ③ 브랜치를 head로 — 워크트리 조각이 통째로 빠지고 head가 주인공 자리를 가진다. 커밋된 rev의 diff는 어느 워크트리에서 보든 같으므로(실측) 워크트리 이름을 남기면 거짓 인상을 준다.
피커 — 워크트리 구역(각 행이 물고 있는 브랜치를 오른쪽에)과 브랜치 구역(default가 맨 위). 워크트리를 고르면 그 URL로 이동하고, 브랜치를 고르면
head만 바꾼다.마지막 커밋 — 조작을 오른쪽으로. 왼쪽 그룹은 한 문장으로 읽혀야 하는데(무엇을 · 무엇과 견줘 · 몇 파일 · 몇 줄) Unified/Split과 새로고침이 첫 마디와 둘째 마디 사이에 앉아 그 문장을 두 동강 냈다.
무엇을
1. 표식 — 트리거가 곧 표시다
세 조각이 서로 다른 축이다. 워크트리 이름만 보이면 어느 리포인지 모르고, 리포 이름만 보이면 어느 워크트리인지 모른다.
한때 피커 트리거와 개수 왼쪽 라벨이 따로였다. 피커가 base를 고르던 시절엔 서로 다른 축이라 중복이 아니었지만, 피커가 head를 고르게 된 순간 둘이 같은 말을 하게 됐다. 그래서 고르는 컨트롤이 곧 표시다 — 고르는 자리와 지금 무엇을 보는지 말하는 자리가 하나면 어긋날 수 없고, 남은 자리(
#base-label)는 트리거가 결코 말하지 않는 축(base)만 가진다.색은 구별되는 조각 하나만 밝힌다(
#picker-name=--vd-fg, 나머지 muted). 리포 이름은 그 리포의 워크트리마다 같아서 화면을 가르지 못하기 때문이고, 같은 이유로 탭 제목에는 리포 접두를 넣지 않는다(탭은 좁고 오른쪽부터 잘린다).git 호출은 늘지 않는다.
/api/refs가 이미 주는worktrees[]에 리포 루트 하나만 더했고,parseRepoRoot가worktree list --porcelain원본의 첫 레코드를 읽는다.browser/repoLabel.ts— DOM에 닿는 문자열 전량을 만드는 순수 모듈.main.ts는 커버리지 게이트 밖이라 거기 조립을 두면 게이트가 100% 초록인 채로 버그가 산다(isLargeFile사건과 같은 구조).findWorktree는 세그먼트 경계 최장 접두 일치.repo는 기동 시점의process.cwd()라 리포 루트라는 보장이 없고, 중첩 워크트리에서 바깥이 이기면 라벨이 엉뚱한 워크트리를 말한다. 피커의 HEAD 태그 판정도 같은 함수를 쓴다.load(), 피커 열림, watch의poll().poll()이 빠지면 watch 중에 diff만 갈리고 라벨은 옛 브랜치에 굳는다 — 같은 화면의 빈 상태 카드는 살아 있는 브랜치를 말하므로 한 화면이 두 브랜치를 주장하게 된다.2. 피커가 base가 아니라 head를 고른다
wire는
head=<ref>, 없으면 워킹트리.base=HEAD와 달리head=HEAD는 정규화하지 않는다 —base=HEAD는 "커밋 안 한 것만"이라 워킹트리 뷰와 결과가 같지만head=HEAD는 커밋된 HEAD를 보는 것이라 미커밋 변경이 빠진다.merge-base(ref, HEAD)로 재면 지금 워크트리의 HEAD와 갈림점을 잡는데 그 둘은 아무 관계도 없고, base 브랜치가 그 사이 만든 파일이 "head에서 삭제됨"으로 끼어든다(뮤테이션 확인).verifyBaseRef의 보안 경계를 탄다. 그 값이 이제git diff의 두 번째 인자로도 가므로-로 시작하는 참조가 닿으면--output=<path>가 된다. 표식은x-diff-error: unknown-head로 base와 가른다(자가복구가 다르다)./api/summary의workingFiles·untrackedFiles가number | null이 됐다 — 재지 않은 것을 0으로 적으면 주장이 되어, 실제로는 미커밋 변경이 있는데도 카드가 "Nothing to show in any mode"라고 말한다.selectionCacheKey는 "flight 클로저가 읽는 모든 입력의 전함수"라는 기존 계약이다.3. 전체 변경량 · 자동 base 전환
#change-totals— 파일 수만으로는 규모를 못 읽는다(176 file(s)가 한 줄씩인지 통째로 새로 쓴 것인지). 세는 값의 출처가 계약이다:hunk의 숫자 필드이지FileDiffMetadata의 동명string[]필드가 아니다(그 착각이 대형 파일 자동 접힘을 오작동시킨 적이 있다). e2e가 스펙 안에서git diff --numstat을 돌려 매번 대조한다.4. 조작과 정보를 자리로 가른다 (마지막 커밋)
옮겨도 폭은 손해 보지 않는다(실측): 총 폭이 그대로라 트리거가 줄어들 몫도 그대로여서, 560px 창에서 트리거 160px · 넘침 54px로 옮기기 전과 완전히 같은 값이다.
오른쪽 순서
찾기 · 새로고침 │ Unified/Split · 트리 │ ⋯의 근거:⋯옆 — 메뉴의Hide file tree가 같은 토글이고, 메뉴 여섯 중 넷이 레이아웃⋯가 마지막 — 메뉴는 툴바 아래로 떨어져 이웃을 가리지 않으므로 기계적 제약이 아니다CSS 사슬 한 마디가 새로 필요했다:
.tb-picker { display: flex }. 블록이면 안쪽inline-flex버튼이 flex item이 아니라 줄어드는 몫을 못 받고 자연폭 그대로 넘쳐, 트리거가 아무리 좁아져도 라벨이 한 번도 잘리지 않는다(실측: 1280px 창에서 161자 브랜치인데scrollWidth == clientWidth).max-width는 두지 않는다 — 좁은 창에서 두 안의 수치가 완전히 같고(700/560/460px에서 221/81/0px) 161자 브랜치에서도 넘치지 않았다. 즉 상한은 오버플로 방어에 아무것도 안 보태면서 공간이 남는 넓은 창에서 세 조각을 잘라 이 표식의 존재 이유를 깎는다.테스트 플랜
유닛 — 커버리지 게이트는 branch를 세지 않으므로 폴백 분기마다 따로 찔렀다.
repo-label.test.ts.git접미, head 뷰의 워크트리 제거ref-picker-model.test.tschange-totals.test.tsdiff-refs.test.ts(parseRepoRoot)diff-selection/diff-command/diff-server/summary/empty-stateunknown-head, 옵션 꼴 차단)·null 요약·shouldAutoViewBasee2e — 배선은 커버리지 밖이라 여기가 유일한 그물이다.
toolbar-repo.e2e.ts--watch무-focus 수렴, 브랜치 head가 워크트리 이름을 버림, 넓은 창·좁은 창 레이아웃ref-picker.e2e.tstoolbar-totals.e2e.ts--numstat합산)toolbar-order.e2e.tsempty-state.e2e.tsbase=는 안 덮음뮤테이션으로 판별력 확인한 것들
poll()의 라벨 갱신toolbar-repo⑥만.tb-picker { display: flex }toolbar-repo⑧의 넓은 창·좁은 창clipped둘 다 (rightEdge는 통과 — 상자만 줄고 버튼이 그 위로 넘칠 뿐).tb-left의min-width:0/flex:nonetoolbar-repo⑧의 좁은 창만.tb-right의margin-left: autotoolbar-order②만 (①은 통과 — 순서는 그대로다. 즉 ②는 ①의 중복이 아니라 그 순서의 이유를 지킨다)renderPatch의 합산 배선toolbar-totals①②diff-command2종persist: true로empty-state④만문서 의무 없음 — CLI 플래그를 추가하지 않으므로
docs-flags-parity.test.ts의 일곱 문서는 그대로다. CLAUDE.md에는 이 UI 계약들을 전부 기록했다.🤖 Generated with Claude Code
https://claude.ai/code/session_013LwoJdwzqo3Ln6y8g5ivMM