diff --git a/CLAUDE.md b/CLAUDE.md index b02983a..2963777 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,17 @@ cc-statusline에 포함됐던 로컬 diff 뷰어를 독립 제품으로 분리 + 뷰어 토글(untracked 포함·watch 자동갱신·flatten·파일트리 좌우·unified/split·파일트리 숨김·트리 접기 동기화)은 `--untracked`/`--watch`/`--no-flatten`/`--tree-right`/`--split`/`--hide-tree`/`--fold-with-tree` CLI 플래그로 구동 시점에 미리 설정할 수 있다(session-only — 저장된 localStorage 프리퍼런스는 건드리지 않음). 인앱 토글의 초기 표시 상태는 항상 실제 launch 값과 일치하도록 sync되며, 우선순위 계산(URL 파라미터 → localStorage → 기본값)은 `apps/viewer/browser/prefs.ts`의 순수 resolver 함수(`resolveUntracked`/`resolveWatch`/`resolveFlatten`/`resolveTreeSide`/`resolveDiffStyle`/`resolveTreeHidden`/`resolveFoldWithTree`)로 분리해 단위 테스트한다. unified↔split 전환은 읽던 스크롤 위치를 그대로 유지한다(엔진 앵커링 — 아래 "CodeView 인스턴스 수명" 항목 참고). 파일트리 숨김은 `localStorage` 폴백이 없는 session-only 토글(`resolveUntracked`와 동일 패턴)로, 툴바 아이콘 버튼(`#tree-toggle-btn`)과 오버플로 메뉴 체크박스(`#toggle-tree-hidden`) 둘 다에서 조작 가능하며 항상 서로 동기화된다. 사이드바에서 디렉토리를 접으면 diff 화면의 해당 파일들도 자동으로 접히는 "Fold with tree" 토글은 `flatten`/`treeSide`와 동일하게 `localStorage`(`cc-statusline:fold-with-tree`)에 영속화되며, 오버플로 메뉴 체크박스(`#toggle-fold-with-tree`)로만 조작한다(전용 툴바 버튼 없음). 트리에서 접힌 디렉토리 아래 파일을 diff 헤더 클릭으로 개별 펼치면 그 파일은 사용자가 다시 접기 전까지(토글 on/off와 무관하게) 계속 펼쳐진 채 유지된다. 파일 트리 사이드바 폭은 `#tree-resizer`를 드래그하거나(포커스 후 방향키로도 10px 단위 조정 가능) 180~600px 범위에서 조정할 수 있으며, 조정한 폭은 `localStorage`(session-only 토글들과 달리 지속)에 저장된다. -**견줄 기준 피커** — 툴바의 옛 `를 검색 가능한 목록으로 바꾼 것. +// 툴바 피커: **무엇을 볼 것인가(head)**를 고른다. +// +// 예전에는 "무엇과 견줄까(base)"를 골랐는데, 목록의 브랜치 이름이 "그 브랜치를 +// 보여줘"로 읽히면서 실제로는 반대 축을 건드리는 어긋남이 있었다 — 메인 +// 워크트리에서 남의 브랜치를 골라도 1 file만 나오던 것이 그 결과다. // // 여기서만 잡히는 계약들이다 — happy-dom에는 레이아웃도 CSS 캐스케이드도 // 없어서 "[hidden]이 실제로 숨기는가", "패널이 정말 페인트되는가", // "닫을 때 포커스가 트리거로 돌아오는가"를 유닛이 원리적으로 볼 수 없다. +// 그리고 배선(고른 행이 실제로 무엇을 하는가)은 main.ts가 커버리지 게이트 +// 밖이라 여기가 유일한 그물이다. import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { realpathSync } from "node:fs"; +import { basename, join } from "node:path"; import { expect, launchViewer, test } from "./fixtures/app.ts"; const OPTS = { featureBranchCommit: true, branches: ["develop"] }; +/** + * 픽스처에는 원격이 없어 원격 HEAD symref가 비고, 그러면 서버가 default + * 브랜치를 해석하지 못한다(`/api/refs`의 defaultBranch가 null). 자기 자신을 + * origin으로 걸어 그 symref를 세운다 — default 정렬을 보려면 이것이 있어야 한다. + */ +const giveItADefaultBranch = (dir: string): void => { + // **`git remote add`를 쓰지 마라.** 원격 URL이 생기면 base 해석의 첫 단계인 + // `gh pr view`가 그것을 GitHub 리포로 풀어 보려 하고, 그때부터 스펙 하나가 + // 수십 초씩 걸린다(실측: 이 파일 전체가 16초에서 15분으로 늘었다). + // 필요한 것은 symref 하나뿐이므로 참조만 직접 세운다. + const sha = spawnSync("git", ["-C", dir, "rev-parse", "main"], { + encoding: "utf8", + }).stdout.trim(); + run(dir, ["update-ref", "refs/remotes/origin/main", sha]); + run(dir, [ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); +}; + const run = (dir: string, args: string[]): void => { const r = spawnSync("git", ["-C", dir, ...args], { stdio: "pipe" }); if (r.status !== 0) { @@ -17,7 +44,7 @@ const run = (dir: string, args: string[]): void => { } }; -test.describe("compare base picker", () => { +test.describe("head picker", () => { test("opens to a searchable list and closes on Escape", async ({ page }) => { const { url, stop } = await launchViewer([], OPTS); try { @@ -33,12 +60,10 @@ test.describe("compare base picker", () => { "aria-expanded", "true", ); - const rows = page.locator("#ref-picker .ref-row"); - await expect(rows.filter({ hasText: "Working tree" })).toHaveCount(1); - await expect(rows.filter({ hasText: "develop" })).toHaveCount(1); await page.keyboard.press("Escape"); await expect(panel).toBeHidden(); + await expect(page.locator("#ref-picker-btn")).toBeFocused(); } finally { await stop(); } @@ -50,10 +75,9 @@ test.describe("compare base picker", () => { await page.goto(url); await page.locator("#ref-picker-btn").click(); const rows = page.locator("#ref-picker .ref-row"); - // 목록은 /api/refs가 도착한 뒤에 채워진다. count()는 재시도하지 - // 않는 일회성 읽기라, 도착을 기다리는 단언을 먼저 둔다. await expect(rows.filter({ hasText: "develop" })).toHaveCount(1); expect(await rows.count()).toBeGreaterThan(1); + await page.locator("#ref-picker-search").fill("develop"); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveText(/develop/); @@ -62,49 +86,134 @@ test.describe("compare base picker", () => { } }); - // 기본값은 base=HEAD(미커밋만)이므로 브랜치에 커밋한 파일은 안 보인다. - // main을 기준으로 고르면 merge-base가 갈림점으로 내려가 그 커밋까지 들어온다. - test("choosing a branch widens the diff to the committed work", async ({ + // 워크트리가 하나뿐이면 고를 것이 없다. 제목만 남기고 목록을 비우면 + // "뭔가 있어야 하는데 없다"로 읽히므로 구역 자체가 사라진다. + test("hides the worktree section when there is nothing to choose", async ({ page, }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + await page.locator("#ref-picker-btn").click(); + await expect(page.locator("#ref-picker .ref-section")).toHaveText([ + "BRANCHES", + ]); + } finally { + await stop(); + } + }); + + // 워크트리가 둘 이상이면 구역이 서고, 각 행이 **물고 있는 브랜치**를 말한다. + test("lists worktrees with the branch each one holds", async ({ page }) => { const { url, repoDir, stop } = await launchViewer([], OPTS); try { + const nested = join(repoDir, ".claude", "worktrees", "side"); + run(repoDir, ["worktree", "add", "-q", "-b", "side/work", nested]); + await page.goto(url); - await expect(page.locator("#ref-picker-label")).toHaveText( - "Working tree", - ); - // 기본 픽스처의 워킹트리 편집은 정확히 셋이다(src/hello.ts, - // README.md, assets/logo.png). + await page.locator("#ref-picker-btn").click(); + await expect(page.locator("#ref-picker .ref-section")).toHaveText([ + "WORKTREES", + "BRANCHES", + ]); + + const rows = page.locator("#ref-picker .ref-row"); + // default(main)를 물고 있는 워크트리가 없으므로 지금 보고 있는 것이 + // 맨 위다. 그 행이 자기 브랜치를 오른쪽에 단다. + await expect(rows.nth(0)).toHaveText(new RegExp(basename(repoDir))); + await expect(rows.nth(0).locator(".ref-row-tag")).toHaveText("feature"); + await expect(rows.nth(1)).toHaveText(/side/); + await expect(rows.nth(1).locator(".ref-row-tag")).toHaveText("side/work"); + } finally { + await stop(); + } + }); + + // 브랜치 구역은 default가 언제나 맨 위 — 사용자가 지정한 규칙이다. + test("puts the default branch at the top of the branches", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer([], OPTS); + try { + giveItADefaultBranch(repoDir); + await page.goto(url); + await page.locator("#ref-picker-btn").click(); + const rows = page.locator("#ref-picker .ref-row"); + await expect(rows.nth(0)).toHaveText(/main/); + await expect(rows.nth(0).locator(".ref-row-tag")).toHaveText("default"); + } finally { + await stop(); + } + }); + + // **이 피커의 존재 이유.** 브랜치를 고르면 그 브랜치의 커밋된 작업을 본다 — + // 워킹트리의 미커밋 변경은 빠진다. 예전 base 피커는 반대 축을 건드려 + // 남의 브랜치를 골라도 내 워킹트리만 보여줬다. + test("choosing a branch views its committed work, not the working tree", async ({ + page, + }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + await page.goto(url); + // 워크트리 뷰: 커밋된 것 + 미커밋 셋. await expect(page.locator("#status")).toHaveText("3 file(s)"); - // 브랜치에만 있는 파일을 하나 만든다. 커밋된 뒤 워킹트리에서는 - // 깨끗하므로 HEAD 기준에는 안 보이고, main 기준(갈림점)에서만 - // 보인다 — 그래서 "기준을 바꾸면 비교 범위가 넓어진다"를 개수로 - // 확실히 가른다. - // - // featureBranchCommit이 커밋하는 src/hello.ts를 그대로 쓰면 안 된다: - // 그 파일은 이미 워킹트리에서도 편집돼 있어 기준을 바꿔도 파일 - // 개수가 그대로다. 예전 판이 그걸 모르고 "개수가 달라진다"를 - // 단언했다가, 로컬에서는 status가 아직 "Loading…"일 때 이전 값을 - // 캡처하는 레이스 덕에 통과하고 CI에서만 깨졌다. - writeFileSync( - join(repoDir, "src", "branch-only.ts"), - "export const x = 1;\n", - ); - run(repoDir, ["add", "src/branch-only.ts"]); - run(repoDir, ["commit", "-qm", "branch only file"]); + await page.locator("#ref-picker-btn").click(); + await page + .locator("#ref-picker .ref-row") + .filter({ hasText: "feature" }) + .first() + .click(); + + // feature가 main에서 갈라진 뒤 커밋한 것 하나뿐이다. + await expect(page.locator("#status")).toHaveText("1 file(s)"); + await expect(page.locator("#picker-name")).toHaveText("feature"); + // URL이 진실이라야 새로고침·링크 공유가 그대로 재현된다. + expect(new URL(page.url()).searchParams.get("head")).toBe("feature"); + // **저장하지 않는다** — 저장하면 다음에 이 리포를 열 때 남의 브랜치 + // 뷰에 갇힌 채 시작한다. base 쪽(empty-state ④)과 짝이 되는 단언이다. + expect( + await page.evaluate(() => + Object.keys(localStorage).filter((k) => k.includes("head")), + ), + ).toEqual([]); + } finally { + await stop(); + } + }); + + // 워크트리는 다른 리포 경로다 — 고르면 그 URL로 이동한다. + test("choosing a worktree navigates to it", async ({ page }) => { + const { url, repoDir, stop } = await launchViewer([], OPTS); + try { + const nested = join(repoDir, ".claude", "worktrees", "side"); + run(repoDir, ["worktree", "add", "-q", "-b", "side/work", nested]); + + await page.goto(url); + // **먼저 head를 세운다.** 워킹트리 뷰에서 시작하면 URL에 `head`가 + // 애초에 없어서, 구현이 그것을 그대로 실어 날라도 아래 단언이 + // 통과한다 — 문서에 명시된 실패 모드를 못 잡는 vacuous 스펙이 된다. + await page.locator("#ref-picker-btn").click(); + // `data-value`로 고른다 — 워크트리 행도 자기가 물고 있는 브랜치를 + // 태그로 달고 있어서 hasText로는 그쪽이 먼저 잡힌다(워크트리 구역이 + // 위에 있다). 값은 워크트리면 경로, 브랜치면 ref 이름이다. + await page.locator('#ref-picker .ref-row[data-value="feature"]').click(); + expect(new URL(page.url()).searchParams.get("head")).toBe("feature"); await page.locator("#ref-picker-btn").click(); await page .locator("#ref-picker .ref-row") - .filter({ hasText: /^main/ }) + .filter({ hasText: "side" }) .first() .click(); - await expect(page.locator("#ref-picker-label")).toHaveText("vs main"); - await expect(page.locator("#status")).toHaveText("4 file(s)"); - // 닫힌 뒤 포커스가 트리거로 돌아와야 키보드 사용자가 길을 잃지 않는다. - await expect(page.locator("#ref-picker-btn")).toBeFocused(); + await expect(page.locator("#picker-name")).toHaveText("side"); + expect(new URL(page.url()).searchParams.get("repo")).toBe( + realpathSync(nested), + ); + // head는 워크트리에 매인 값이 아니다 — 들고 가면 새 워크트리에서 + // 남의 브랜치를 보게 된다. + expect(new URL(page.url()).searchParams.get("head")).toBeNull(); } finally { await stop(); } @@ -116,6 +225,7 @@ test.describe("compare base picker", () => { await page.goto(url); await page.locator("#overflow-btn").click(); await expect(page.locator("#overflow-menu")).toBeVisible(); + await page.locator("#ref-picker-btn").click(); await expect(page.locator("#ref-picker")).toBeVisible(); await expect(page.locator("#overflow-menu")).toBeHidden(); @@ -124,16 +234,19 @@ test.describe("compare base picker", () => { } }); - // 네이티브 가 공짜로 주던 키보드 조작 — 클릭 전용으로 두면 + // 이 컨트롤만 마우스를 요구하게 된다. test("moves with the arrow keys and applies with Enter", async ({ page }) => { - const { url, stop } = await launchViewer([], OPTS); + const { url, repoDir, stop } = await launchViewer([], OPTS); try { + // default가 맨 위로 올라가야 develop이 0번이 아니게 된다. + giveItADefaultBranch(repoDir); await page.goto(url); await page.locator("#ref-picker-btn").click(); const rows = page.locator("#ref-picker .ref-row"); await expect(rows.filter({ hasText: "develop" })).toHaveCount(1); + // 하드코딩하지 않고 보이는 목록에서 위치를 찾아 그만큼 내려간다. const labels = await rows.allTextContents(); const target = labels.findIndex((l) => l.includes("develop")); expect(target).toBeGreaterThan(0); @@ -143,82 +256,37 @@ test.describe("compare base picker", () => { await expect(rows.nth(target)).toHaveAttribute("data-active", "true"); await page.keyboard.press("Enter"); - await expect(page.locator("#ref-picker-label")).toHaveText("vs develop"); + await expect(page.locator("#picker-name")).toHaveText("develop"); } finally { await stop(); } }); - // 고른 브랜치가 나중에 사라지면(PR 머지 후 원격 브랜치 삭제 + prune) 저장된 - // 기준이 400을 부르고, 400은 재시도 없는 terminal이라 손대지 않으면 이후 - // 모든 실행이 실패 카드로 시작한다. - test("recovers when the remembered branch no longer exists", async ({ + // 머지 후 삭제된 브랜치를 가리키는 링크. 예전엔 "Failed to load diff."만 + // 남고 새로고침해도 같아서 스스로 못 빠져나왔다 — head는 URL에 살아서 + // base처럼 저장된 값을 지우는 자가복구를 쓸 수 없기 때문이다. + test("a head that no longer exists says so and offers a way out", async ({ page, }) => { - const { url, repoDir, stop } = await launchViewer([], OPTS); + const { url, stop } = await launchViewer([], OPTS); try { - await page.goto(url); - await page.locator("#ref-picker-btn").click(); - await page - .locator("#ref-picker .ref-row") - .filter({ hasText: "develop" }) - .first() - .click(); - await expect(page.locator("#ref-picker-label")).toHaveText("vs develop"); + await page.goto(`${url}&head=gone-branch`); - spawnSync("git", ["-C", repoDir, "branch", "-D", "develop"], { - stdio: "pipe", - }); - await page.reload(); - - await expect(page.locator("#ref-picker-label")).toHaveText( - "Working tree", + const card = page.locator("#empty.empty-card"); + await expect(card.locator(".empty-headline")).toHaveText( + "That branch is gone", ); - await expect(page.locator("diffs-container").first()).toBeVisible(); - await expect(page.locator("#diff")).not.toContainText( - "Failed to load diff", + await expect(card.locator(".empty-context")).toHaveText( + "No ref named gone-branch in this repo", ); - } finally { - await stop(); - } - }); - // 두 종류를 화면에서 가르는 것이 이 목록의 요점이다 — Working tree는 - // 미커밋만, 브랜치는 갈라진 뒤 전부라 같은 줄에 같은 모양으로 두면 - // 구분이 안 된다. - test("separates the working tree from the branches, and says how full it is", async ({ - page, - }) => { - const { url, stop } = await launchViewer([], OPTS); - try { - await page.goto(url); - await expect(page.locator("#status")).toHaveText("3 file(s)"); - await page.locator("#ref-picker-btn").click(); - - const sections = page.locator("#ref-picker .ref-section"); - await expect(sections).toHaveText([ - "UNCOMMITTED", - "COMPARE WITH A BRANCH", - ]); + // **자동으로 되돌리지 않는다** — 링크가 요청한 것을 말없이 바꾸면 + // 사용자가 속는다. URL은 그대로여야 한다. + expect(new URL(page.url()).searchParams.get("head")).toBe("gone-branch"); - // 고르기 전에 얼마나 들어 있는지 보여야 "골랐더니 비어 있더라"가 - // 안 생긴다. - const working = page - .locator("#ref-picker .ref-row") - .filter({ hasText: "Working tree" }); - await expect(working.locator(".ref-row-tag")).toHaveText("3 file(s)"); - - // 개수가 **어느 행의 것인지**도 지킨다. 서버가 잰 참조로 맞추지 - // 않고 표시명으로 맞추면(base는 origin/ 접두가 벗겨진다) 남의 - // 숫자가 로컬 동명 브랜치에 붙는다 — 화면의 숫자와 눌렀을 때 - // 나오는 개수가 달라진다. - const mainRow = page - .locator("#ref-picker .ref-row") - .filter({ hasText: /^main/ }) - .first(); - // 이 픽스처엔 origin/HEAD가 없어 default 태그는 안 붙는다. - // 귀속이 깨지면 이 행에는 아무 텍스트도 없으므로 여전히 가른다. - await expect(mainRow.locator(".ref-row-tag")).toHaveText(/\d+ file\(s\)/); + await card.locator("button.empty-action").click(); + await expect(page.locator("#status")).toHaveText(/file\(s\)/); + expect(new URL(page.url()).searchParams.get("head")).toBeNull(); } finally { await stop(); } diff --git a/apps/viewer/e2e/toolbar-order.e2e.ts b/apps/viewer/e2e/toolbar-order.e2e.ts new file mode 100644 index 0000000..f12969d --- /dev/null +++ b/apps/viewer/e2e/toolbar-order.e2e.ts @@ -0,0 +1,143 @@ +// 툴바의 두 축과 오른쪽 그룹의 순서. +// +// 왼쪽은 "지금 무엇을 보고 있는가"를 한 문장으로 말하고(트리거 · 견줄 기준 · +// 개수 · 변경량), 오른쪽은 화면을 다룬다. 예전엔 뷰 모드와 새로고침이 왼쪽 +// 한복판에 앉아 그 문장을 두 동강 냈다. +// +// **여기서만 잡히는 계약이 하나 있다**: find 바가 펼쳐질 때 다른 컨트롤이 +// 움직이지 않는 것. 그건 레이아웃과 CSS 전이가 있어야 보이므로 happy-dom이 +// 원리적으로 못 본다. 마크업 순서는 유닛으로도 볼 수 있지만, 그 순서의 +// **이유**가 이 이동량이라 같은 파일에서 나란히 지킨다. +import { expect, launchViewer, test } from "./fixtures/app.ts"; + +/** `.tb-right` 안에서 실제로 자리를 차지하는 컨트롤들 (find 바는 숨김 상태). */ +const RIGHT_ORDER = [ + "find-open", + "refresh", + "diff-style-group", + "tree-toggle-btn", + "overflow-btn", +]; + +test.describe("toolbar groups", () => { + test("the left group is a sentence and the right group is the controls", async ({ + page, + }) => { + const { url, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#picker-name")).not.toBeEmpty(); + + const groups = await page.evaluate(() => { + const idsOf = (sel: string) => + [...(document.querySelector(sel)?.children ?? [])] + .map((el) => el.id || el.className) + .filter((s) => s !== ""); + return { left: idsOf(".tb-left"), right: idsOf(".tb-right") }; + }); + + // 왼쪽은 정보 넷뿐이다 — 조작은 트리거 하나이고 그건 곧 표식이다. + expect(groups.left).toEqual([ + "tb-picker", + "base-label", + "status", + "change-totals", + ]); + // 오른쪽 순서: 순간 동작 → 상태 토글 → 나머지. `find-bar`가 돋보기 + // **바로 뒤**인 것도 계약이다 — 바는 그 자리에서 펼쳐지므로, 떼어 + // 놓으면 열 때 사이에 낀 컨트롤이 340px 밀린다. + expect(groups.right).toEqual([ + "find-open", + "find-bar", + "refresh", + "diff-style-group", + "tree-toggle-btn", + "tb-overflow", + ]); + } finally { + await stop(); + } + }); + + // **찾기가 맨 앞인 이유.** find 바는 돋보기 자리에서 340px로 펼쳐지는데, + // 오른쪽 그룹은 오른쪽 끝에 고정돼 있어 바가 자라면 그 **왼쪽** 이웃만 + // 밀린다. 찾기가 첫 자리면 밀릴 이웃이 없다 — 실측: 세그먼트를 찾기 앞에 + // 두면 그 하나가 285px 왼쪽으로 점프한다. + test("opening the find bar moves no other control", async ({ page }) => { + const { url, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#picker-name")).not.toBeEmpty(); + + const lefts = () => + page.evaluate( + (ids) => + Object.fromEntries( + ids.map((id) => [ + id, + Math.round( + document.getElementById(id)?.getBoundingClientRect().left ?? + Number.NaN, + ), + ]), + ), + RIGHT_ORDER, + ); + + const before = await lefts(); + // 돋보기 자신은 바가 열리면 사라지므로 비교 대상에서 뺀다. + delete before["find-open"]; + + await page.locator("#find-open").click(); + await expect(page.locator("#find-bar")).toBeVisible(); + // 340px 확장 전이(0.28s)가 끝날 때까지 기다린다 — 중간 프레임을 재면 + // 이 단언이 전이 속도에 묶인다. + await page.waitForTimeout(500); + + const after = await lefts(); + delete after["find-open"]; + expect(after).toEqual(before); + } finally { + await stop(); + } + }); + + // 오른쪽 그룹이 무거워졌으므로(세그먼트 118px가 늘었다) 좁은 창에서 눌리지 + // 않는지 확인한다. 줄어드는 몫은 여전히 트리거 혼자 진다. + test("the right group keeps its size when the window is narrow", async ({ + page, + }) => { + const { url, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#picker-name")).not.toBeEmpty(); + const wide = await page + .locator(".tb-right") + .evaluate((el) => Math.round(el.getBoundingClientRect().width)); + + await page.setViewportSize({ width: 560, height: 720 }); + await page.waitForTimeout(200); + const narrow = await page.evaluate(() => { + const right = document.querySelector(".tb-right"); + const toolbar = document.getElementById("toolbar"); + const label = document.getElementById("ref-picker-label"); + if (!right || !toolbar || !label) + throw new Error("toolbar nodes missing"); + return { + width: Math.round(right.getBoundingClientRect().width), + rightEdge: Math.round(right.getBoundingClientRect().right), + height: Math.round(toolbar.getBoundingClientRect().height), + // 줄어드는 몫은 트리거가 전담한다는 기존 계약. + clipped: label.scrollWidth > label.clientWidth, + }; + }); + + expect(narrow.width).toBe(wide); + expect(narrow.rightEdge).toBeLessThanOrEqual(560); + expect(narrow.height).toBeLessThan(48); + expect(narrow.clipped).toBe(true); + } finally { + await stop(); + } + }); +}); diff --git a/apps/viewer/e2e/toolbar-repo.e2e.ts b/apps/viewer/e2e/toolbar-repo.e2e.ts new file mode 100644 index 0000000..f2718eb --- /dev/null +++ b/apps/viewer/e2e/toolbar-repo.e2e.ts @@ -0,0 +1,318 @@ +// 툴바의 리포·브랜치 표식 — "지금 어느 워크트리의 무엇을 보고 있는가". +// +// 이 스펙이 지키는 것은 **배선**이다. 문자열 조립은 `browser/repoLabel.ts`가 +// 하고 유닛(`repo-label.test.ts`)이 덮지만, `browser/main.ts`는 커버리지 게이트 +// 밖이고 루트 typecheck의 include에도 없어서(bunfig.toml / tsconfig) 배선이 +// 통째로 빠져도 유닛·커버리지·타입체크가 전부 초록으로 남는다 — `isLargeFile` +// 사건과 같은 구조다(CLAUDE.md). 여기가 그 구멍을 막는 유일한 지점이다. +// +// 아울러 happy-dom이 원리적으로 못 보는 계약도 함께 잡는다: 라벨이 실제로 +// 페인트되는가, 조각 사이에 공백 텍스트 노드가 끼지 않았는가(포매터가 +// 되돌릴 수 있다), 긴 브랜치명이 툴바 오른쪽을 화면 밖으로 밀지 않는가. +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { basename, join } from "node:path"; +import { expect, launchViewer, test } from "./fixtures/app.ts"; + +const run = (dir: string, args: string[]): void => { + const r = spawnSync("git", ["-C", dir, ...args], { stdio: "pipe" }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${r.stderr?.toString()}`); + } +}; + +const capture = (dir: string, args: string[]): string => { + const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); + } + return r.stdout.trim(); +}; + +test.describe("toolbar repo label", () => { + test("says the worktree name and its branch", async ({ page }) => { + const { url, repoDir, stop } = await launchViewer([]); + try { + await page.goto(url); + const name = basename(repoDir); + + await expect(page.locator("#picker-name")).toHaveText(name); + await expect(page.locator("#picker-branch")).toHaveText("· main"); + // 메인 워크트리라 리포 접두가 없다 — 붙으면 `dd-e2e-repo-x / dd-e2e-repo-x`. + await expect(page.locator("#picker-scope")).toBeEmpty(); + + // 개수 바로 왼쪽 자리는 이제 **견줄 기준**의 몫이다 — 트리거가 + // "무엇을 보는가"를 말하므로 그 자리에 같은 말을 두면 중복이다. + const beforeStatus = await page.evaluate( + () => + document.getElementById("base-label")?.nextElementSibling?.id ?? null, + ); + expect(beforeStatus).toBe("status"); + + // 조각 사이에 공백 텍스트 노드가 끼면 `name · main`이 된다. + // #picker-branch가 구분자를 품고 오므로 마크업은 붙여 써야 하는데, + // 그건 포매터가 되돌릴 수 있는 종류의 계약이라 여기서 못박는다. + // **toHaveText로는 못 잡는다** — Playwright가 공백을 정규화해서 + // 이중 공백도 통과시킨다. textContent를 그대로 봐야 한다. + expect( + await page + .locator("#ref-picker-label") + .evaluate((el) => el.textContent), + ).toBe(`${name} · main`); + + // 말줄임을 hover로 편다 — 트리거가 세 조각을 담아 길어질 수 있다. + await expect(page.locator("#ref-picker-btn")).toHaveAttribute( + "title", + `${realpathSync(repoDir)} · main`, + ); + + // 탭 제목 — 워크트리를 여럿 열어 두면 탭만으로 구별돼야 한다. + await expect(page).toHaveTitle(`${name} · main — diffdeck`); + } finally { + await stop(); + } + }); + + test("says the checked-out branch, not the default one", async ({ page }) => { + const { url, repoDir, stop } = await launchViewer([], { + clean: true, + featureBranchCommit: true, + }); + try { + await page.goto(url); + await expect(page.locator("#picker-name")).toHaveText(basename(repoDir)); + await expect(page.locator("#picker-branch")).toHaveText("· feature"); + } finally { + await stop(); + } + }); + + // 브랜치가 없는 상태에서 `name · ` 처럼 끝나면 안 된다. detached 표기는 + // emptyState.ts와 **같은 어휘**를 쓴다 — 같은 사실을 화면 두 곳이 다르게 + // 말하면 안 되기 때문. /api/refs의 head는 full OID라 앞 7자로 잘린다. + test("falls back to a short OID on detached HEAD", async ({ page }) => { + const { url, repoDir, stop } = await launchViewer([], { clean: true }); + try { + const sha = capture(repoDir, ["rev-parse", "HEAD"]); + run(repoDir, ["checkout", "-q", sha]); + + await page.goto(url); + await expect(page.locator("#picker-branch")).toHaveText( + `· detached @ ${sha.slice(0, 7)}`, + ); + } finally { + await stop(); + } + }); + + // 부팅 시 한 번만 읽는 구현을 떨어뜨린다. 브랜치를 갈아타고 창으로 + // 돌아오는 것(focus → load)이 뷰어를 켜 둔 채 일할 때의 실제 흐름이다. + test("follows a branch switch made while the viewer is open", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#picker-branch")).toHaveText("· main"); + + run(repoDir, ["checkout", "-qb", "other"]); + + // focus를 한 번이 아니라 **수렴할 때까지** 보낸다. /api/refs는 5초 + // TTL 캐시라(server.ts의 REFS_TTL_MS) 전환 직후의 첫 갱신은 캐시된 + // 값을 읽을 수 있다 — 라벨은 피커와 같은 데이터를 같은 신선도로 + // 쓰므로 이건 결함이 아니라 설계된 수렴 지연이다. 고정 sleep 대신 + // 폴링으로 적는 이유는 이 스펙이 지키려는 것이 "몇 초 안에"가 아니라 + // "부팅 때 한 번만 읽지 않는다"이기 때문이다. + await expect + .poll( + async () => { + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + return page.locator("#picker-branch").textContent(); + }, + { timeout: 15_000, intervals: [500] }, + ) + .toBe(" · other"); + + await expect(page).toHaveTitle(`${basename(repoDir)} · other — diffdeck`); + } finally { + await stop(); + } + }); + + // 사용자가 실제로 겪은 형태: 한 리포의 워크트리를 여럿 열어 두고 일한다. + // 워크트리 이름만 보이면 어느 리포인지 알 수 없고, 리포 이름만 보이면 어느 + // 워크트리인지 알 수 없다 — 라벨이 둘 다 말해야 탭을 잘못 고르지 않는다. + test("names the repo and the worktree when inside a linked worktree", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer([]); + try { + // 사용자 리포와 같은 중첩 배치(`/.claude/worktrees/*`). + const nested = join(repoDir, ".claude", "worktrees", "feat+ABC-1"); + run(repoDir, ["worktree", "add", "-q", "-b", "feat/ABC-1", nested]); + + // repo는 URL 파라미터라 워크트리를 가리키게 바꾸면 그대로 열린다. + // git이 보고하는 경로와 맞추려고 realpath로 정규화한다(macOS의 + // /var → /private/var 심링크). + const target = new URL(url); + target.searchParams.set("repo", realpathSync(nested)); + await page.goto(target.toString()); + + await expect(page.locator("#picker-scope")).toHaveText( + `${basename(repoDir)} /`, + ); + await expect(page.locator("#picker-name")).toHaveText("feat+ABC-1"); + await expect(page.locator("#picker-branch")).toHaveText("· feat/ABC-1"); + + // 탭 제목에는 리포 접두가 없다 — 탭은 오른쪽부터 잘리는데 리포 + // 이름은 워크트리마다 같아서 탭을 가르지 못한다. + await expect(page).toHaveTitle("feat+ABC-1 · feat/ABC-1 — diffdeck"); + } finally { + await stop(); + } + }); + + // watch는 창을 **안 보고 있을 때** 쓰는 기능이라 focus가 발화하지 않는다. + // 갱신이 load()에만 걸려 있으면 diff는 2초마다 새 브랜치 것으로 갈리는데 + // 툴바·탭 제목만 옛 브랜치에 무기한 굳고, 같은 화면의 빈 상태 카드는 + // /api/summary로 살아 있는 브랜치를 말해 한 화면이 두 브랜치를 동시에 + // 주장하게 된다. 그래서 이 스펙은 focus를 **한 번도 보내지 않는다** — + // 그게 위 ④와 갈라지는 지점이고, poll()의 갱신을 지우면 여기만 빨개진다. + test("keeps the label live under --watch without any focus event", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer(["--watch"]); + try { + await page.goto(url); + await expect(page.locator("#picker-branch")).toHaveText("· main"); + + run(repoDir, ["checkout", "-qb", "watched"]); + + // 폴 주기 + /api/refs의 5초 TTL만큼 수렴을 기다린다. + await expect(page.locator("#picker-branch")).toHaveText("· watched", { + timeout: 20_000, + }); + await expect(page).toHaveTitle( + `${basename(repoDir)} · watched — diffdeck`, + ); + } finally { + await stop(); + } + }); + + // 브랜치를 head로 보면 **워크트리는 결과에 영향을 주지 않는다** — 어느 + // 워크트리에서 보든 같은 diff다. 그래서 라벨에서 워크트리 이름을 빼고 + // 보고 있는 브랜치를 주인공으로 세운다. 예전에는 워크트리의 브랜치를 + // 말해서, 화면에는 A의 diff가 떠 있는데 라벨은 B라고 하는 상태가 됐다. + test("a branch head drops the worktree name and says what it is viewing", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer([], { + branches: ["develop"], + featureBranchCommit: true, + }); + try { + await page.goto(url); + // 워크트리 뷰에서는 그 워크트리의 브랜치를 말한다. + await expect(page.locator("#picker-branch")).toHaveText("· feature"); + + await page.locator("#ref-picker-btn").click(); + await page + .locator("#ref-picker .ref-row") + .filter({ hasText: /^develop/ }) + .first() + .click(); + + await expect(page.locator("#picker-name")).toHaveText("develop"); + // 브랜치 뷰에서는 head 자체가 브랜치라 트리거가 따로 말할 것이 없다. + await expect(page.locator("#picker-branch")).toBeEmpty(); + await expect(page.locator("#picker-scope")).toHaveText( + `${basename(repoDir)} ·`, + ); + // 견줄 기준이 자동 해석으로 올라간다 — 커밋된 rev에는 미커밋 + // 변경이 없어 워킹트리 기준은 뜻이 없다. 다른 축이므로 자기 자리다. + await expect(page.locator("#base-label")).toHaveText("vs main"); + // 워크트리의 브랜치를 말하면 보고 있지도 않은 곳을 가리킨다. + expect( + await page + .locator("#ref-picker-label") + .evaluate((el) => el.textContent), + ).not.toContain("feature"); + await expect(page).toHaveTitle("develop — diffdeck"); + } finally { + await stop(); + } + }); + + // 유닛이 원리적으로 못 보는 계약: happy-dom에는 레이아웃이 없다. + // 툴바에는 flex-wrap도 @media도 없어서, max-width + ellipsis가 빠지면 + // 긴 이름 하나로 .tb-right(찾기·트리토글·⋯)가 화면 밖으로 나간다. + test("a very long branch name never pushes the toolbar off screen", async ({ + page, + }) => { + const { url, repoDir, stop } = await launchViewer([]); + try { + const long = `feature/${"very-long-branch-segment-".repeat(6)}end`; + run(repoDir, ["checkout", "-qb", long]); + + await page.goto(url); + await expect(page.locator("#picker-branch")).toHaveText(`· ${long}`); + + // 담기지 않으면 잘린다. 픽셀 상한을 단언하지 않는 이유는 폭을 붙드는 + // 기제가 max-width가 아니라 flex shrink이기 때문이다 — 상한을 두면 + // 공간이 남는 넓은 창에서까지 자르게 된다(실측 근거는 CSS 주석에). + // 이 단언이 `.tb-picker { display: flex }`의 회귀망이다: 블록이면 + // 안쪽 inline-flex 버튼이 flex item이 아니라 줄어드는 몫을 못 받아 + // 자연폭 그대로 넘치고, 라벨은 한 번도 잘리지 않는다. + const label = await page.locator("#ref-picker-label").evaluate((el) => ({ + clipped: el.scrollWidth > el.clientWidth, + // text-overflow를 지우면 잘린 자리에 말줄임표가 사라진다. + // scrollWidth로는 그 삭제를 못 보므로 계산된 값을 직접 본다. + textOverflow: getComputedStyle(el).textOverflow, + })); + expect(label.clipped).toBe(true); + expect(label.textOverflow).toBe("ellipsis"); + + // 오른쪽 그룹이 뷰포트 안에 그대로 있다. + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size unavailable"); + const right = await page + .locator(".tb-right") + .evaluate((el) => el.getBoundingClientRect().right); + expect(right).toBeLessThanOrEqual(viewport.width); + + // 툴바가 한 줄로 남는다 (줄바꿈되면 높이가 배로 뛴다). + const toolbarHeight = await page + .locator("#toolbar") + .evaluate((el) => el.getBoundingClientRect().height); + expect(toolbarHeight).toBeLessThan(60); + + // 좁은 창 — 트리거가 줄어드는 몫을 **전담**해야 한다. 실측 근거: + // `.tb-left { min-width: 0 }`이 없으면 여기서 .tb-right가 화면 밖으로 + // 나가고(표식이 없던 시절엔 460px에서도 멀쩡했다), `.tb-left > *`의 + // flex:none이 없으면 #status가 두 줄로 접혀 툴바가 43 → 49px로 뛴다. + // 트리거는 `.tb-picker`의 flex:0 1 auto로 그 예외를 되돌려 받는다. + await page.setViewportSize({ width: 560, height: 720 }); + await page.waitForTimeout(200); + const narrow = await page.evaluate(() => { + const trigger = document.getElementById("ref-picker-label"); + const rightGroup = document.querySelector(".tb-right"); + const toolbar = document.getElementById("toolbar"); + if (!trigger || !rightGroup || !toolbar) { + throw new Error("toolbar nodes missing"); + } + return { + rightEdge: rightGroup.getBoundingClientRect().right, + height: toolbar.getBoundingClientRect().height, + // overflow:hidden / text-overflow:ellipsis를 지우면 사라진다. + clipped: trigger.scrollWidth > trigger.clientWidth, + }; + }); + expect(narrow.rightEdge).toBeLessThanOrEqual(560); + expect(narrow.height).toBeLessThan(48); + expect(narrow.clipped).toBe(true); + } finally { + await stop(); + } + }); +}); diff --git a/apps/viewer/e2e/toolbar-totals.e2e.ts b/apps/viewer/e2e/toolbar-totals.e2e.ts new file mode 100644 index 0000000..822f564 --- /dev/null +++ b/apps/viewer/e2e/toolbar-totals.e2e.ts @@ -0,0 +1,112 @@ +// 툴바의 전체 변경량 — 개수(`n file(s)`) 오른쪽에서 "통틀어 몇 줄인가"를 말한다. +// +// 합산은 `browser/changeTotals.ts`(유닛 100%)가 하지만, `main.ts`는 커버리지 +// 게이트 밖이라 배선이 통째로 빠져도 유닛은 전부 초록이다. 여기가 그 구멍을 +// 막는다. 아울러 **숫자가 git과 같은가**는 실제 git 없이는 확인할 수 없으므로 +// 기대값을 스펙 안에서 git으로 직접 계산해 대조한다. +import { spawnSync } from "node:child_process"; +import { expect, launchViewer, test } from "./fixtures/app.ts"; + +const capture = (dir: string, args: string[]): string => { + const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8" }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); + } + return r.stdout; +}; + +/** `git diff --numstat`의 합. 바이너리는 줄 수가 `-`로 나오므로 뺀다. */ +const gitTotals = (dir: string, rev: string): { add: number; del: number } => { + let add = 0; + let del = 0; + for (const line of capture(dir, ["diff", "--numstat", rev]).split("\n")) { + const [a, d] = line.split("\t"); + if (a === undefined || a === "" || a === "-") continue; + add += Number(a); + del += Number(d); + } + return { add, del }; +}; + +test.describe("toolbar change totals", () => { + test("sums the whole diff and agrees with git", async ({ page }) => { + const { url, repoDir, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#status")).toHaveText("3 file(s)"); + + // 기본 뷰는 워킹트리(HEAD 대비)다. + const { add, del } = gitTotals(repoDir, "HEAD"); + expect(add).toBeGreaterThan(0); + + await expect(page.locator("#change-add")).toHaveText(`+${add}`); + await expect(page.locator("#change-del")).toHaveText(`-${del}`); + + // 사용자가 지정한 자리: 개수 **오른쪽**. + const afterStatus = await page.evaluate( + () => document.getElementById("status")?.nextElementSibling?.id ?? null, + ); + expect(afterStatus).toBe("change-totals"); + + // 조각 사이에 공백 텍스트 노드가 끼면 `+7 -1`이 된다. + // toHaveText는 공백을 정규화하므로 textContent를 그대로 본다. + expect( + await page.locator("#change-totals").evaluate((el) => el.textContent), + ).toBe(`+${add} -${del}`); + } finally { + await stop(); + } + }); + + // 색이 add/del을 가른다 — 지우면 두 숫자가 같은 색이 되어 어느 쪽이 + // 추가인지 부호에만 의존하게 된다. happy-dom에는 캐스케이드가 없어 + // 유닛이 원리적으로 못 보는 계약이다. + test("colors additions and deletions apart", async ({ page }) => { + const { url, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#change-add")).not.toBeEmpty(); + + const colors = await page.evaluate(() => { + const add = document.getElementById("change-add"); + const del = document.getElementById("change-del"); + if (!add || !del) throw new Error("totals nodes missing"); + return { + add: getComputedStyle(add).color, + del: getComputedStyle(del).color, + }; + }); + // --vd-success(#3fb950) / #f85149 — 앱에 이미 있는 add·del 쌍. + expect(colors.add).toBe("rgb(63, 185, 80)"); + expect(colors.del).toBe("rgb(248, 81, 73)"); + } finally { + await stop(); + } + }); + + // 변경이 없으면 개수와 함께 자리를 통째로 비운다 — `+0 -0`이 남으면 + // 아무 말도 아닌 숫자가 툴바를 차지한다. + test("clears the totals when a render lands with nothing to count", async ({ + page, + }) => { + // **전이를 봐야 한다.** clean 리포로 띄우면 첫 렌더가 곧 빈 분기라 + // #change-add/#change-del이 마크업 상태(빈 문자열) 그대로이고, 그러면 + // `applyChangeTotals([])` 배선을 통째로 지워도 이 단언이 초록이다 — + // CLAUDE.md가 경고하는 lockfile 픽스처와 같은 vacuity 구조다. 그래서 + // 숫자가 실제로 **쓰인 뒤** 사라지는지를 본다. + const { url, repoDir, stop } = await launchViewer([]); + try { + await page.goto(url); + await expect(page.locator("#change-add")).not.toBeEmpty(); + + // 워킹트리를 되돌리면 볼 것이 없어진다. + capture(repoDir, ["checkout", "--", "."]); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + + await expect(page.locator("#status")).toBeEmpty(); + await expect(page.locator("#change-totals")).toBeEmpty(); + } finally { + await stop(); + } + }); +}); diff --git a/apps/viewer/index.html b/apps/viewer/index.html index 1acfb65..cc0d785 100644 --- a/apps/viewer/index.html +++ b/apps/viewer/index.html @@ -49,9 +49,68 @@ align-items: center; gap: 8px; } + /* 오른쪽은 **조작만** 산다. 툴바가 두 축으로 갈리기 때문이다: 왼쪽은 + "지금 무엇을 보고 있는가"를 한 문장으로 말하고(트리거 · 견줄 기준 · + 개수 · 변경량), 오른쪽은 화면을 다룬다. 예전엔 뷰 모드와 + 새로고침이 왼쪽 한복판에 앉아 그 문장을 두 동강 냈다. + + **순서 자체가 계약이다** — `찾기 · 새로고침 │ Unified/Split · 트리 │ ⋯`: + ① **찾기가 맨 앞이어야 한다(실측).** find 바는 돋보기 자리에서 + 340px로 펼쳐지는데, 이 그룹은 오른쪽 끝에 고정돼 있어 바가 + 자라면 **그 왼쪽 이웃만** 밀린다. 찾기가 첫 자리면 밀릴 이웃이 + 없다 — 실측: 찾기 우선이면 바를 열어도 모든 컨트롤 이동량 0px, + 세그먼트를 앞에 두면 −285px 점프. + ② 가운데 셋은 **순간 동작 / 상태 토글**로 갈랐다. 찾기·새로고침은 + 눌러도 흔적이 없고, Unified/Split·트리 토글은 지금 어떤지를 + 상시로 말한다. 118px 세그먼트가 두 무리 사이에 놓여 축의 경계와 + 시각적 경계가 겹친다. + ③ 트리 토글이 ⋯ 바로 옆인 것은 메뉴의 `Hide file tree`가 **같은 + 토글**이기 때문이고, 레이아웃 짝이 ⋯ 에 붙는 것은 메뉴 여섯 중 + 넷이 레이아웃이기 때문이다(Flatten · Tree on right · + Hide file tree · Fold with tree). ⋯ 가 마지막인 건 관례다 — + 메뉴는 툴바 아래로 떨어져 이웃을 가리지 않으므로 기계적 제약은 + 아니지만, "자리를 못 얻은 나머지 전부"는 줄을 닫는 쪽이 맞다. */ .tb-right { margin-left: auto; } + /* 좁은 창에서 왼쪽 그룹이 오그라들 수 있어야 한다. flex item의 기본 + min-width:auto가 min-content 아래로 줄어드는 것을 막는데, 라벨이 + 붙으면 그 min-content가 최대 260px 늘어 .tb-right(찾기·트리토글·⋯)를 + 화면 밖으로 밀어낸다 — 실측: 라벨 없이는 460px에서도 멀쩡하던 툴바가 + 560px에서 이탈했다. */ + .tb-left { + min-width: 0; + } + /* 줄어드는 몫은 전부 트리거가 진다. 뒤따르는 세 마디(견줄 기준 · 개수 · + 변경량)는 짧고 고정폭에 가까운데 트리거만 브랜치명만큼 길어지기 + 때문이고, 이 규칙이 없으면 #status가 두 줄로 접혀 툴바 높이가 + 43 → 49px로 뛴다(실측). + + 예외는 아래 `#toolbar .tb-picker`가 되돌려 받는다. **특이도로** + 이긴다(0,1,1 대 0,1,0) — 예전엔 `.tb-picker`라 둘 다 (0,1,0)이고 + 소스 순서로만 이겼는데, 그러면 피커 규칙을 한곳으로 모으는 + 평범한 정리가 트리거를 조용히 `flex: none`으로 되돌린다. */ + .tb-left > * { + flex: none; + } + /* 견줄 기준 표식 — 조작이 아니라 정보다. #status와 같은 종(種)이라 + 상자가 없고(배경·테두리·radius 없음), 툴바의 13px·시스템 스택을 + 그대로 상속하므로 font 선언 자체를 두지 않는다. */ + #base-label { + color: var(--vd-fg-muted); + } + /* 전체 변경량 — 파일 헤더 배지와 같은 add·del 어휘를 쓴다. 앱에 이미 + 있는 두 색이라 새 값을 만들지 않는다: --vd-success는 grab 보내기의 + ok 상태가, #f85149는 fail 상태가 쓰는 바로 그 값이고, 둘이 diff의 + add·del 쌍이라는 것은 .grab-l-side 주석이 이미 적고 있다. 엔진의 + --diffs-addition-base를 쓸 수는 없다 — 그 토큰은 shadow root + 안에만 살아서 툴바에서는 해석되지 않는다. */ + #change-add { + color: var(--vd-success); + } + #change-del { + color: #f85149; + } /* One control height for every toolbar widget — without it, line-height: normal + differing paddings yield 25/26/26.5/27px mismatches. */ #toolbar button { @@ -345,14 +404,45 @@ .tb-picker { position: relative; } + /* 트리거는 조작이자 **정보 표출**이다 — 지금 보고 있는 리포·워크트리· + 브랜치를 통째로 말하므로 상한을 두지 않는다. 폭을 붙드는 기제는 + flex shrink 하나뿐이고, 그건 옛 라벨에서 이미 실측으로 확인했다 + (161자 브랜치에서도 툴바가 넘치지 않았다). + + **display:flex가 여기서 계약이다.** 블록이면 안쪽 inline-flex + 버튼이 flex item이 아니라서 줄어드는 몫을 물려받지 못하고 제 + 자연폭 그대로 상자를 넘쳐, 트리거가 아무리 좁아져도 라벨은 + 한 번도 잘리지 않는다(실측: scrollWidth == clientWidth). */ + #toolbar .tb-picker { + display: flex; + flex: 0 1 auto; + min-width: 0; + } + /* **`min-width: 0`은 위 `display: flex`와 한 짝이다.** `.tb-picker`가 + flex 컨테이너가 된 순간 이 버튼은 flex item이고, 기본 + `min-width: auto`가 콘텐츠 기반 최소 크기 — 즉 `nowrap` 라벨의 + 텍스트 전체 폭 — 으로 풀린다. 라벨 자신의 `overflow: hidden`은 + 라벨의 자동 최소값만 0으로 만들 뿐 버튼의 min-content 기여를 + 줄이지 못하므로, 이 줄이 빠지면 `display: flex`를 지웠을 때와 + **똑같이** 라벨이 한 번도 안 잘린다. 이 줄은 옛 `max-width: 260px`을 + 대체하며 들어왔으니 "상한을 없앴으니 잉여"로 읽고 지우지 말 것. */ #toolbar #ref-picker-btn { gap: 8px; - max-width: 260px; + min-width: 0; + } + /* 줄어드는 몫은 라벨만 진다 — 셰브론이 눌리면 컨트롤로 안 읽힌다. */ + #toolbar #ref-picker-btn > svg { + flex: none; } #ref-picker-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + /* 구별되는 조각 하나만 밝힌다 — 나머지는 맥락이다. */ + color: var(--vd-fg-muted); + } + #picker-name { + color: var(--vd-fg); } #ref-picker { position: absolute; @@ -747,7 +837,10 @@ aria-expanded="false" aria-controls="ref-picker" > - Working tree + -
- - -
- + +
+ +
+
+ + +