diff --git a/CLAUDE.md b/CLAUDE.md index 59025f6..fd5de1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ 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 토글들과 달리 지속)에 저장된다. +**견줄 기준 피커** — 툴바의 옛 `가 공짜로 주던 키보드 조작을 되돌려 놓는다. 클릭 전용으로 +// 두면 이 컨트롤만 마우스를 요구하게 된다. +pickerSearch?.addEventListener("keydown", (event) => { + // 조합 중의 Enter는 한글 확정이지 선택이 아니다 (grab 팝오버와 같은 가드). + if (event.isComposing || event.keyCode === 229) return; + const last = pickerVisible.length - 1; + if (last < 0) return; + const move = (next: number): void => { + event.preventDefault(); + pickerActive = next; + renderPickerRows(); + pickerList + ?.querySelector('[data-active="true"]') + ?.scrollIntoView({ block: "nearest" }); + }; + if (event.key === "ArrowDown") + return move(pickerActive >= last ? 0 : pickerActive + 1); + if (event.key === "ArrowUp") + return move(pickerActive <= 0 ? last : pickerActive - 1); + if (event.key === "Home") return move(0); + if (event.key === "End") return move(last); + if (event.key === "Enter") { + event.preventDefault(); + const row = pickerVisible[pickerActive]; + if (row) void applySelection(row.value); + } }); -// URL mode (from the statusline link) wins over the persisted preference so a -// "vs base" edit link opens directly in base mode; otherwise restore localStorage. +// 피커는 자기 dismiss를 갖는다 — 오버플로 메뉴의 리스너에 얹으면 한쪽을 +// 고치다 다른 쪽이 조용히 깨진다. +document.addEventListener("mousedown", (event) => { + if (!pickerPanel || pickerPanel.hidden) return; + const target = event.target as Node; + if (pickerPanel.contains(target) || pickerBtn?.contains(target)) return; + setPickerOpen(false); +}); + +document.addEventListener("keydown", (event) => { + if (!pickerPanel || pickerPanel.hidden) return; + // 한글 등 조합 입력 중의 Escape는 조합 취소이지 팝오버 닫기가 아니다 + // (grab 팝오버와 같은 가드). + if (event.isComposing || event.keyCode === 229) return; + if (event.key !== "Escape") return; + setPickerOpen(false); + pickerBtn?.focus(); +}); + +// URL이 저장된 선택을 이긴다. 레거시 `mode`는 **URL 레이어에서** base 값으로 +// 승격시켜야 그 계약이 유지된다 — 저장된 선택 뒤로 내리면 한 번이라도 피커를 +// 쓴 사용자에게는 statusline의 `?mode=base` 링크가 조용히 무시된다 +// (link.ts는 지금도 mode를 발행한다). const urlMode = params.get("mode"); -if (urlMode === "base" || urlMode === "working") { - diffMode = urlMode; - localStorage.setItem("cc-statusline:diff-mode", urlMode); - if (modeSelect) modeSelect.value = urlMode; -} else if (localStorage.getItem("cc-statusline:diff-mode") === "base") { - diffMode = "base"; - modeSelect.value = "base"; -} +const urlBase = + params.get("base") ?? + (urlMode === "base" ? "@auto" : urlMode === "working" ? "HEAD" : null); +const storedLegacyMode = localStorage.getItem("cc-statusline:diff-mode"); +// `?base=`(빈 값)는 resolveCompareBase가 "없음"으로 치므로 여기서도 같은 +// 규칙을 써야 판정이 어긋나지 않는다 — URL이 이겼는지를 실제로 이긴 값으로 +// 정한다. 저장된 값에서 왔을 때만 자가복구가 돈다. +const urlChoice = urlBase !== null && urlBase !== "" ? urlBase : null; +const compareBaseFromStorage = urlChoice === null; +compareBase = + resolveCompareBase(urlChoice, (k) => localStorage.getItem(k), repo) ?? + (storedLegacyMode === "base" ? "@auto" : "HEAD"); +syncPickerLabel(); // Apply persisted file-tree side and reflect stored prefs in the overflow menu. appEl.dataset.treeSide = treeSide; @@ -1388,6 +1645,21 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape") setOverflowOpen(false); }); +// 버전은 /api/ping이 헤더로 보고한다 — 이 라우트가 존재하는 이유가 바로 +// 그것이다(장수 데몬이 디스크의 패키지보다 오래 살 수 있어, 클라이언트가 +// 자기가 기대하는 버전과 대조하라고 pid·version을 싣는다). +const versionValue = document.getElementById("version-value"); +if (versionValue) { + void fetch("/api/ping") + .then((res) => { + const v = res.headers.get("x-diffdeck-version"); + if (v) versionValue.textContent = `v${v}`; + }) + .catch(() => { + // 부가 정보다 — 못 읽어도 메뉴의 나머지는 그대로 동작한다. + }); +} + findBar = createFindBar({ elements: { bar: document.getElementById("find-bar") as HTMLElement, diff --git a/apps/viewer/browser/prefs.ts b/apps/viewer/browser/prefs.ts index fa7ce8b..cc7c134 100644 --- a/apps/viewer/browser/prefs.ts +++ b/apps/viewer/browser/prefs.ts @@ -71,3 +71,26 @@ export const readTreeWidth = (get: Getter): number => { const stored = get(TREE_WIDTH_KEY); return stored === null ? DEFAULT_TREE_WIDTH : clampTreeWidth(Number(stored)); }; + +/** + * 견줄 기준의 저장 키. 리포 경로로 네임스페이스한다 — 워크트리마다 견주는 + * 기준이 다른데, 위의 여섯 `cc-statusline:` 키처럼 공유해 버리면 한 워크트리의 + * 선택이 다른 워크트리로 새어 나간다. (그 여섯은 마이그레이션 코드가 없어 + * 이름을 바꾸면 모든 사용자의 설정이 조용히 초기화되므로 그대로 둔다.) + */ +export const compareBaseKey = (repo: string): string => + `diffdeck:compare-base:${repo}`; + +/** + * URL 파라미터 → localStorage → null. + * + * null은 "고른 적 없음"이라는 뜻일 뿐이고, 그 경우 무엇을 보낼지는 호출부가 + * 정한다(main.ts는 워킹트리를 뜻하는 `HEAD`로 떨어진다). 이 함수 자체는 + * 어떤 wire 값도 가정하지 않는다. + */ +export const resolveCompareBase = ( + urlParam: string | null, + get: Getter, + repo: string, +): string | null => + urlParam !== null && urlParam !== "" ? urlParam : get(compareBaseKey(repo)); diff --git a/apps/viewer/browser/refPicker/model.ts b/apps/viewer/browser/refPicker/model.ts new file mode 100644 index 0000000..9d3abb5 --- /dev/null +++ b/apps/viewer/browser/refPicker/model.ts @@ -0,0 +1,107 @@ +/** + * 피커가 보여줄 "무엇과 견줄까" 목록의 순수 로직. + * + * DOM도 fetch도 모르므로 유닛으로 전부 덮인다 — 배선만 main.ts에 남는다. + */ +import type { RefRecord } from "../../server/refs.ts"; + +/** 목록의 두 구역. 종류가 다르다는 것을 화면에서 가르는 근거다. */ +export type RowSection = "uncommitted" | "branches"; + +export interface BaseRow { + /** `base=` 쿼리에 실릴 값. */ + value: string; + /** 화면에 보이는 이름. */ + label: string; + kind: "working" | "local" | "remote"; + section: RowSection; + /** 맥락 표시. note에 합쳐져 오른쪽에 붙는다. */ + tag: "default" | "HEAD" | null; + /** 행 오른쪽 보조 텍스트. 모르는 것은 지어내지 않으므로 null일 수 있다. */ + note: string | null; +} + +/** + * 서버가 **이미 아는** 개수만 담는다. /api/summary가 매 로드마다 계산하는 + * 값이라 git 호출이 늘지 않는다. 브랜치마다 개수를 붙이려면 브랜치당 호출이 + * 하나씩 더 들기 때문에, 여기 없는 행에는 숫자를 쓰지 않는다. + */ +export interface RowCounts { + /** 미커밋 변경 파일 수. 모르면 null. */ + working: number | null; + /** 그 개수가 측정된 비교 대상과 파일 수. */ + base: { name: string; files: number } | null; +} + +const files = (n: number): string => `${n} file(s)`; + +/** + * 아직 커밋하지 않은 변경만 보는 선택. `merge-base(HEAD, HEAD) === HEAD`라서 + * 서버에 특별한 분기 없이 오늘의 워킹트리 뷰와 같은 결과가 된다. + */ +const WORKING_VALUE = "HEAD"; + +export const buildBaseRows = ( + refs: readonly RefRecord[], + defaultBranch: string | null, + currentBranch: string | null, + counts?: RowCounts, +): BaseRow[] => { + // 0을 "nothing yet"으로 쓰는 이유: 숫자 0은 훑어볼 때 눈에 안 걸리는데, + // 이 행이 비어 있다는 사실이야말로 고르기 전에 알아야 하는 것이다. + const workingNote = + counts?.working == null + ? null + : counts.working === 0 + ? "nothing yet" + : files(counts.working); + + const workingRow: BaseRow = { + value: WORKING_VALUE, + label: "Working tree", + kind: "working", + section: "uncommitted", + tag: null, + note: workingNote, + }; + + const toRow = (r: RefRecord): BaseRow => { + // 자기 자신과 견주면 언제나 비어 보인다. 막지는 않되 왜 그런지 + // 읽히도록 표시한다 — 조용한 빈 화면이 이 기능의 가장 큰 위험이다. + const tag = + r.name === defaultBranch + ? "default" + : r.name === currentBranch + ? "HEAD" + : null; + const measured = + counts?.base && counts.base.name === r.name + ? files(counts.base.files) + : null; + const parts = [tag, measured].filter((s): s is string => s !== null); + return { + value: r.name, + label: r.name, + kind: r.kind, + section: "branches", + tag, + note: parts.length > 0 ? parts.join(" · ") : null, + }; + }; + + // 로컬을 먼저, 원격을 뒤로. 각 무리 안에서는 받은 순서를 그대로 둔다. + return [ + workingRow, + ...refs.filter((r) => r.kind === "local").map(toRow), + ...refs.filter((r) => r.kind === "remote").map(toRow), + ]; +}; + +export const filterBaseRows = ( + rows: readonly BaseRow[], + query: string, +): BaseRow[] => { + const needle = query.trim().toLowerCase(); + if (needle === "") return [...rows]; + return rows.filter((r) => r.label.toLowerCase().includes(needle)); +}; diff --git a/apps/viewer/e2e/empty-state.e2e.ts b/apps/viewer/e2e/empty-state.e2e.ts index c63c493..a283bc4 100644 --- a/apps/viewer/e2e/empty-state.e2e.ts +++ b/apps/viewer/e2e/empty-state.e2e.ts @@ -49,9 +49,9 @@ test.describe("informative empty state", () => { card.locator("button.empty-action", { hasText: "untracked" }), ).toHaveText("1 untracked file(s) hidden — show"); - // 클릭 → 드롭다운이 base로 바뀌고 커밋된 diff가 렌더된다. + // 클릭 → 피커가 자동 해석 base로 바뀌고 커밋된 diff가 렌더된다. await switchBtn.click(); - await expect(page.locator("#diff-mode")).toHaveValue("base"); + await expect(page.locator("#ref-picker-label")).toHaveText("vs main"); await expect(page.locator("#empty")).toHaveCount(0); await expect(page.locator("#status")).toHaveText("1 file(s)"); await expect.poll(() => treeHasPath(page, "src/hello.ts")).toBe(true); @@ -93,10 +93,15 @@ test.describe("informative empty state", () => { await expect(card.locator(".empty-context")).toHaveText("on main"); await expect(card.locator("button.empty-action")).toHaveCount(0); - // 회귀: 양쪽 모드가 다 빈 상태에서 모드를 전환해도 카드가 새 모드 - // 문구로 갱신되어야 한다 — 빈 payload의 etag가 모드와 무관하게 같아 - // 304로 이전 카드에 고착되던 버그의 가드 (모드 전환 시 lastEtag 리셋). - await page.selectOption("#diff-mode", "base"); + // 회귀: 양쪽이 다 빈 상태에서 기준을 바꿔도 카드가 새 문구로 + // 갱신되어야 한다 — 빈 payload의 etag가 기준과 무관하게 같아 304로 + // 이전 카드에 고착되던 버그의 가드 (선택 변경 시 lastEtag 리셋). + await page.locator("#ref-picker-btn").click(); + await page + .locator("#ref-picker .ref-row") + .filter({ hasText: /^main/ }) + .first() + .click(); await expect( page.locator("#empty.empty-card .empty-headline"), ).toHaveText("No changes vs main"); diff --git a/apps/viewer/e2e/fixtures/repo.ts b/apps/viewer/e2e/fixtures/repo.ts index 119f9eb..5c9f485 100644 --- a/apps/viewer/e2e/fixtures/repo.ts +++ b/apps/viewer/e2e/fixtures/repo.ts @@ -102,6 +102,14 @@ export interface FixtureRepoOptions { * `row.side === side` filter. grab-highlight.e2e.ts 전용. */ contextBetweenDeletions?: boolean; + /** + * Opt-in: rename to `main` and create these extra branches at the base + * commit, so the compare-base picker has a list worth filtering. Kept + * opt-in like every other shape here — the default fixture must stay + * byte-identical for the ~30 specs written against it. + * ref-picker.e2e.ts 전용. + */ + branches?: string[]; } // Wide enough that each line is one diff row; deliberately free of the words @@ -206,8 +214,12 @@ export const makeFixtureRepo = ( git(dir, ["add", "-A"]); git(dir, ["commit", "-qm", "base"]); - if (options.clean || options.featureBranchCommit) { - git(dir, ["branch", "-M", "main"]); + // 옵션과 무관하게 항상 고정한다. git init은 머신의 init.defaultBranch를 + // 따르므로(개발자 로컬 main, CI 러너 master) 고정하지 않으면 브랜치명을 + // 건드리는 스펙이 개발자 머신에서만 통과한다 — 실제로 CI에서 한 번 밟았다. + git(dir, ["branch", "-M", "main"]); + for (const branch of options.branches ?? []) { + git(dir, ["branch", branch]); } if (options.featureBranchCommit) { git(dir, ["checkout", "-qb", "feature"]); diff --git a/apps/viewer/e2e/flags-sync.e2e.ts b/apps/viewer/e2e/flags-sync.e2e.ts index d0a1ff6..535065c 100644 --- a/apps/viewer/e2e/flags-sync.e2e.ts +++ b/apps/viewer/e2e/flags-sync.e2e.ts @@ -90,3 +90,29 @@ test("launch flags are reflected in the in-app toggle state", async ({ foldWithTreeToggle: true, }); }); + +// 메뉴 최하단의 버전 줄. 값은 /api/ping의 x-diffdeck-version 헤더에서 오는데, +// 브라우저는 원래 그 라우트를 부르지 않았으므로 이 배선이 유일한 소비자다. +// main.ts는 커버리지 게이트 밖이라 여기서만 지켜진다. +test("the overflow menu ends with a version line linking to the repository", async ({ + page, +}) => { + const { url, stop } = await launchViewer(); + try { + await page.goto(url); + await page.locator("#overflow-btn").click(); + + const link = page.locator("#version-link"); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute( + "href", + "https://github.com/say8425/diffdeck", + ); + // 새 탭으로 여는 링크는 opener를 끊어야 한다. + await expect(link).toHaveAttribute("rel", /noopener/); + // 서버가 실제로 보고한 버전이어야 한다 — 하드코딩된 문자열이 아니라. + await expect(page.locator("#version-value")).toHaveText(/^v\d+\.\d+\.\d+/); + } finally { + await stop(); + } +}); diff --git a/apps/viewer/e2e/ref-picker.e2e.ts b/apps/viewer/e2e/ref-picker.e2e.ts new file mode 100644 index 0000000..0d63d45 --- /dev/null +++ b/apps/viewer/e2e/ref-picker.e2e.ts @@ -0,0 +1,226 @@ +// 견줄 기준 피커: 툴바의 2지선다 를 없앤 대가로 키보드 조작을 직접 져야 한다. 순서를 + // 하드코딩하지 않고 보이는 목록에서 위치를 찾아 그만큼 내려간다. + test("moves with the arrow keys and applies with Enter", async ({ page }) => { + const { url, stop } = await launchViewer([], OPTS); + try { + 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); + for (let i = 0; i < target; i++) { + await page.keyboard.press("ArrowDown"); + } + await expect(rows.nth(target)).toHaveAttribute("data-active", "true"); + await page.keyboard.press("Enter"); + + await expect(page.locator("#ref-picker-label")).toHaveText("vs develop"); + } finally { + await stop(); + } + }); + + // 고른 브랜치가 나중에 사라지면(PR 머지 후 원격 브랜치 삭제 + prune) 저장된 + // 기준이 400을 부르고, 400은 재시도 없는 terminal이라 손대지 않으면 이후 + // 모든 실행이 실패 카드로 시작한다. + test("recovers when the remembered branch no longer exists", async ({ + page, + }) => { + const { url, repoDir, 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"); + + spawnSync("git", ["-C", repoDir, "branch", "-D", "develop"], { + stdio: "pipe", + }); + await page.reload(); + + await expect(page.locator("#ref-picker-label")).toHaveText( + "Working tree", + ); + await expect(page.locator("diffs-container").first()).toBeVisible(); + await expect(page.locator("#diff")).not.toContainText( + "Failed to load diff", + ); + } 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", + ]); + + // 고르기 전에 얼마나 들어 있는지 보여야 "골랐더니 비어 있더라"가 + // 안 생긴다. + 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\)/); + } finally { + await stop(); + } + }); +}); diff --git a/apps/viewer/index.html b/apps/viewer/index.html index f325f37..7c52c1d 100644 --- a/apps/viewer/index.html +++ b/apps/viewer/index.html @@ -54,8 +54,7 @@ } /* One control height for every toolbar widget — without it, line-height: normal + differing paddings yield 25/26/26.5/27px mismatches. */ - #toolbar button, - #toolbar select { + #toolbar button { box-sizing: border-box; height: 26px; background: var(--vd-inset); @@ -70,18 +69,9 @@ display: inline-flex; align-items: center; } - #toolbar button:hover, - #toolbar select:hover { + #toolbar button:hover { background: var(--vd-hover); } - #toolbar select { - appearance: none; - -webkit-appearance: none; - padding-right: 26px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%2384848a' stroke-width='1.5' d='M3 4.5 6 7.5 9 4.5'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - } /* Segmented control (Pierre-style): inset pill, active segment raised. fieldset 기본 스타일(margin/min-inline-size)을 리셋해 div 시절과 동일하게 유지. */ #toolbar .tb-seg { @@ -327,6 +317,133 @@ #overflow-menu input[type="checkbox"] { order: 1; } + /* 설정 항목과 부가 정보를 가른다. 메뉴의 flex gap(10px)이 위아래 + 여백을 주므로 margin은 두지 않는다. */ + #overflow-menu .menu-divider { + height: 1px; + background: var(--vd-border); + } + /* 토글 행과 같은 배치(이름 왼쪽·값 오른쪽)를 따르되, 조작이 아니라 + 정보이므로 muted로 눕는다. */ + #overflow-menu #version-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + white-space: nowrap; + color: var(--vd-fg-muted); + text-decoration: none; + } + #overflow-menu #version-link:hover { + color: var(--vd-fg); + } + #overflow-menu #version-value { + font-variant-numeric: tabular-nums; + } + /* 견줄 기준 피커. 오버플로 메뉴와 같은 종(種)이다 — 툴바 컨트롤에 + 앵커된 절대 위치 패널이라 grab 팝오버의 좌표 계산이 필요 없다. */ + .tb-picker { + position: relative; + } + #toolbar #ref-picker-btn { + gap: 8px; + max-width: 260px; + } + #ref-picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + #ref-picker { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 260px; + max-width: min(420px, 90vw); + padding: 6px; + background: var(--vd-bg); + border: 1px solid var(--vd-border); + border-radius: var(--vd-radius); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + } + /* author가 display를 선언했으면 [hidden] 짝을 반드시 함께 둔다 — + 없으면 author origin이 UA 규칙을 이겨 패널이 영구히 열린 채 남는다. + happy-dom은 textContent만 보므로 유닛이 못 잡고 실브라우저에서만 + 드러난다(#grab-popover에서 이미 한 번, .grab-hint에서 두 번 밟았다). */ + #ref-picker[hidden] { + display: none; + } + #ref-picker-search { + box-sizing: border-box; + height: 26px; + padding: 0 8px; + background: var(--vd-inset); + border: 1px solid var(--vd-border); + border-radius: var(--vd-radius); + color: var(--vd-fg); + font: inherit; + outline: none; + } + #ref-picker-list { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; + } + #ref-picker .ref-row { + box-sizing: border-box; + position: relative; + min-height: 26px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px 0 26px; + border-radius: 4px; + color: var(--vd-fg); + cursor: pointer; + white-space: nowrap; + } + #ref-picker .ref-row:hover, + #ref-picker .ref-row[data-active="true"] { + background: var(--vd-hover); + } + #ref-picker .ref-row svg { + position: absolute; + left: 8px; + color: var(--vd-accent); + } + #ref-picker .ref-row-label { + overflow: hidden; + text-overflow: ellipsis; + } + /* 구역 이름이 "무엇과 견주는가"를 말한다 — Working tree(미커밋)와 + 브랜치(갈라진 뒤 전부)는 종류가 다른데 예전엔 같은 줄에 같은 + 모양으로 있어 구분이 안 됐다. */ + #ref-picker .ref-section { + padding: 6px 8px 3px; + font-size: 11px; + letter-spacing: 0.04em; + color: var(--vd-fg-muted); + } + #ref-picker .ref-divider { + height: 1px; + background: var(--vd-border); + margin: 6px 2px 0; + } + #ref-picker .ref-row-tag { + margin-left: auto; + padding-left: 10px; + font-size: 11px; + color: var(--vd-fg-muted); + } + #ref-picker-empty { + padding: 4px 8px; + color: var(--vd-fg-muted); + } #tree { grid-row: 2; grid-column: 1; @@ -608,10 +725,49 @@
- +
+ + +
Fold with tree + + diffdeck
diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index a5822ea..f365b83 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -24,6 +24,32 @@ const refExists = async (repo: string, ref: string): Promise => { return r.exitCode === 0; }; +/** + * 호출자가 고른 base 참조를 검증하고 표시명을 만든다. + * + * **보안 경계다.** Bun의 `$`는 셸을 이스케이프하지 git의 옵션 파싱을 막아주지 + * 않는다 — 첫 글자가 `-`인 참조가 `git diff`에 도달하면 `--output=`로 + * 데몬이 쓸 수 있는 아무 경로나 만들거나 비울 수 있다. `refExists`가 쓰는 + * `rev-parse --verify --quiet`는 옵션 꼴 문자열을 거부하지만, 그 방어에만 + * 기대지 않고 여기서 먼저 끊는다. + * + * 알려진 한계: `rev-parse --verify`는 커밋이 아닌 리비전(`HEAD:a.txt` 같은 + * blob)도 통과시킨다. 그런 값은 `merge-base`가 실패해 빈 diff가 되는데, + * 보안 문제는 아니지만 조용한 빈 화면이라 목록 밖 값은 애초에 고를 수 없게 + * 하는 것이 옳다(피커는 /api/refs가 준 목록에서만 고른다). + */ +export const verifyBaseRef = async ( + repo: string, + ref: string, +): Promise<{ base: string; ref: string } | null> => { + if (ref === "" || ref.startsWith("-")) return null; + if (!(await refExists(repo, ref))) return null; + return { + base: ref.startsWith("origin/") ? ref.slice("origin/".length) : ref, + ref, + }; +}; + export const prBaseName = async (repo: string): Promise => { try { const out = await $`gh pr view --json baseRefName -q .baseRefName` diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts new file mode 100644 index 0000000..fe470ba --- /dev/null +++ b/apps/viewer/server/refs.ts @@ -0,0 +1,146 @@ +/** + * 피커가 고를 수 있는 것들 — 살아 있는 워크트리와 참조 — 의 목록. + * + * git 호출 두 번이면 끝난다. 브랜치와 워크트리를 잇는 것은 UI가 지어낸 + * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 + * 그것을 물고 있는 워크트리를 알려준다. + */ +import { $ } from "bun"; + +export interface WorktreeRecord { + path: string; + /** 짧은 브랜치명. detached면 null. */ + branch: string | null; + head: string | null; + detached: boolean; +} + +export interface RefRecord { + name: string; + kind: "local" | "remote"; + /** 이 참조를 물고 있는 **살아 있는** 워크트리의 경로. 없으면 null. */ + worktreePath: string | null; +} + +export interface RefsResult { + worktrees: WorktreeRecord[]; + refs: RefRecord[]; + defaultBranch: string | null; +} + +const REMOTES_PREFIX = "refs/remotes/"; +const HEADS_PREFIX = "refs/heads/"; + +/** + * `git worktree list --porcelain -z` 파싱. + * + * 실측 형식(git 2.54.0): 속성 한 줄마다 NUL이 붙고 레코드 사이는 빈 항목이다. + * 속성은 `key value` 또는 홀로 선 불리언 단어(`detached`, `bare`)다. + */ +export const parseWorktreeList = (raw: string): WorktreeRecord[] => { + const out: WorktreeRecord[] = []; + let path: string | null = null; + let branch: string | null = null; + let head: string | null = null; + let detached = false; + let usable = true; + + const flush = (): void => { + if (path && usable) out.push({ path, branch, head, detached }); + path = null; + branch = null; + head = null; + detached = false; + usable = true; + }; + + for (const token of raw.split("\0")) { + if (token === "") { + flush(); + continue; + } + if (token.startsWith("worktree ")) path = token.slice("worktree ".length); + else if (token.startsWith("HEAD ")) head = token.slice("HEAD ".length); + else if (token.startsWith(`branch ${HEADS_PREFIX}`)) + branch = token.slice(`branch ${HEADS_PREFIX}`.length); + else if (token === "detached") detached = true; + // bare에는 워킹트리가 없고, prunable은 디렉토리가 이미 사라진 등록이다. + // 둘 다 고를 수 있게 두면 존재하지 않는 경로로 데려간다. + else if (token === "bare" || token.startsWith("prunable")) usable = false; + } + flush(); + return out; +}; + +const REF_FIELDS = 4; + +/** + * `for-each-ref --format=%(refname)%00%(refname:short)%00%(worktreepath)%00%(symref)%00` 파싱. + * + * 필드 구분자가 NUL인 이유: git은 refname에 `|`를 허용한다(실측 — `weird|pipe` + * 브랜치를 만들어 확인했다). 레코드 사이에는 git이 리터럴 개행을 하나 끼워 + * 넣는데, refname에는 개행이 못 들어가므로 필드마다 선행 개행 하나만 벗기면 + * 안전하다. + * + * `liveWorktrees`와 교차 확인하는 것이 핵심이다: for-each-ref는 **이미 삭제된** + * 워크트리 경로도 그대로 실어 보낸다(실측). + */ +export const parseRefList = ( + raw: string, + liveWorktrees: ReadonlySet, +): { refs: RefRecord[]; defaultBranch: string | null } => { + const fields = raw + .split("\0") + .map((f) => (f.startsWith("\n") ? f.slice(1) : f)); + // 포맷이 %00으로 끝나므로 후행 빈 항목이 정확히 하나 생긴다. 전부 + // 벗기면 마지막 필드(symref)가 정당하게 비어 있는 레코드까지 먹어 + // 치워서 레코드가 통째로 사라진다. + if (fields.at(-1) === "") fields.pop(); + + const refs: RefRecord[] = []; + let defaultBranch: string | null = null; + + for (let i = 0; i + REF_FIELDS <= fields.length; i += REF_FIELDS) { + const [refname = "", short = "", worktreePath = "", symref = ""] = + fields.slice(i, i + REF_FIELDS); + // refs/remotes//HEAD는 브랜치가 아니라 기본 브랜치를 가리키는 + // 심볼릭 참조다. 짧게 쓰면 그냥 "origin"이라 목록에 두면 헛 항목이 된다. + if (refname.startsWith(REMOTES_PREFIX) && refname.endsWith("/HEAD")) { + if (symref.startsWith(REMOTES_PREFIX)) { + const withRemote = symref.slice(REMOTES_PREFIX.length); + const slash = withRemote.indexOf("/"); + if (slash !== -1) defaultBranch = withRemote.slice(slash + 1); + } + continue; + } + refs.push({ + name: short, + kind: refname.startsWith(HEADS_PREFIX) ? "local" : "remote", + worktreePath: + worktreePath !== "" && liveWorktrees.has(worktreePath) + ? worktreePath + : null, + }); + } + return { refs, defaultBranch }; +}; + +const REF_FORMAT = + "--format=%(refname)%00%(refname:short)%00%(worktreepath)%00%(symref)%00"; + +export const getRefs = async (repo: string): Promise => { + // 정렬을 붙이지 않는다. `%(committerdate)` 정렬은 참조마다 커밋 객체를 + // 읽게 만들어 브랜치가 많은 리포에서 비용을 지배한다 — 목록은 검색으로 + // 찾는 것이고, 기본(refname) 순서면 충분하다. + const [wtRaw, refRaw] = await Promise.all([ + $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), + $`git -C ${repo} for-each-ref ${REF_FORMAT} refs/heads refs/remotes` + .nothrow() + .quiet() + .text(), + ]); + const worktrees = parseWorktreeList(wtRaw); + const live = new Set(worktrees.map((w) => w.path)); + const { refs, defaultBranch } = parseRefList(refRaw, live); + return { worktrees, refs, defaultBranch }; +}; diff --git a/apps/viewer/server/selection.ts b/apps/viewer/server/selection.ts new file mode 100644 index 0000000..f7e2e20 --- /dev/null +++ b/apps/viewer/server/selection.ts @@ -0,0 +1,90 @@ +/** + * diff 선택(무엇을 무엇과 견주는가)의 단일 파서. + * + * /api/diff·/api/blob·/api/summary가 각자 `mode` 삼항을 복제해 갖고 있으면 + * 셋이 조용히 갈라질 수 있다 — 텍스트 diff는 base를 보는데 이미지 blob은 + * 워킹트리를 보는 식으로. 그 삼항을 여기 한 곳으로 모은다. + */ + +/** 무엇을 기준으로 견줄 것인가. */ +export type BaseSelector = + /** HEAD 대비 — 아직 커밋하지 않은 변경만 (레거시 `mode=working`). */ + | { kind: "head" } + /** 서버가 해석한 base 브랜치 대비 (레거시 `mode=base`, 또는 `base=@auto`). */ + | { kind: "auto" } + /** 사용자가 고른 참조 대비. `base=HEAD`면 head와 같은 뷰가 된다. */ + | { kind: "ref"; ref: string }; + +/** + * `base`에 실린 "서버가 알아서 골라라" 표식. 그냥 `auto`로 하면 실제로 + * `auto`라는 이름의 브랜치와 구별되지 않는다. + */ +export const AUTO_BASE = "@auto"; + +const parseBase = (params: URLSearchParams): BaseSelector => { + const raw = params.get("base") ?? ""; + if (raw === AUTO_BASE) return { kind: "auto" }; + // HEAD는 참조로 취급하지 않고 head 종류로 **정규화**한다. 겉보기엔 + // merge-base(HEAD, HEAD) === HEAD라 같지만 두 가지가 다르다. + // ① 커밋이 하나도 없는 리포(unborn HEAD)에서는 `rev-parse --verify HEAD`가 + // 실패해 참조 검증이 400을 내고, 새 프로젝트에서 처음 켠 화면이 통째로 + // 실패 카드가 된다. + // ② 정규화해야 prewarm이 데운 슬롯과 브라우저 첫 요청의 캐시 키가 같아진다 + // (`head` vs `ref:HEAD`는 서로 다른 슬롯이다). + if (raw === "HEAD") return { kind: "head" }; + // base가 있으면 mode는 무시한다. 한 축을 두 파라미터가 인코딩하면 + // `mode=working&base=main` 같은 모순 상태가 생기고 우선순위 규칙이 + // 필요해진다 — 새 파라미터가 이긴다는 규칙 하나로 그 상태를 없앤다. + if (raw !== "") return { kind: "ref", ref: raw }; + // 알 수 없는 mode 값은 400이 아니라 working으로 떨어진다 — 오늘의 + // 관용이고, 밖에서 만들어진 오래된 링크가 깨지지 않는 쪽이다. + return params.get("mode") === "base" ? { kind: "auto" } : { kind: "head" }; +}; + +export interface Selection { + repo: string; + untracked: boolean; + base: BaseSelector; +} + +export const parseSelection = (params: URLSearchParams): Selection => ({ + repo: params.get("repo") ?? "", + untracked: params.get("untracked") === "1", + base: parseBase(params), +}); + +/** + * payload 캐시와 single-flight가 공유하는 키. + * + * **계약: 이 키는 flight 클로저가 읽는 모든 입력의 전함수여야 한다.** + * 클로저는 repo·untracked·base 종류·해석된 base ref를 읽으므로 넷이 전부 + * 들어간다. 예전에는 해석된 ref가 키에 없었는데, createSingleFlight는 키가 + * 같으면 fn을 다시 읽지 않고 기존 프라미스를 돌려주므로(singleFlight.ts), + * base가 다르게 해석된 두 요청 중 한쪽이 남의 ref로 만든 diff를 받을 수 + * 있었다(baseCache TTL 만료나 `gh pr view` 결과 변화로 도달한다). + * + * ref는 **이름**으로 넣는다. 커밋 OID를 넣으면 커밋마다 새 슬롯이 생겨 + * 8칸짜리 LRU가 헛돈다 — OID는 지문(fingerprint)이 볼 몫이다. + */ +const baseIdentity = ( + base: BaseSelector, + resolvedBaseRef: string | null, +): string => { + if (base.kind === "auto") return resolvedBaseRef ?? ""; + if (base.kind === "ref") return base.ref; + return ""; +}; + +export const selectionCacheKey = ( + sel: Selection, + resolvedBaseRef: string | null, +): string => + [ + sel.repo, + String(sel.untracked), + sel.base.kind, + // auto만 해석값에 의존한다. 사용자가 고른 ref는 서버가 해석할 것이 + // 없고, head 기준에 해석값을 넣으면 origin/HEAD가 움직일 때마다 + // 워킹트리 뷰의 캐시가 이유 없이 날아간다. + baseIdentity(sel.base, resolvedBaseRef), + ].join("\0"); diff --git a/apps/viewer/server/server.ts b/apps/viewer/server/server.ts index 4b4ccf6..4ce3470 100644 --- a/apps/viewer/server/server.ts +++ b/apps/viewer/server/server.ts @@ -8,6 +8,7 @@ import { getFileBytes, isGitRepo, resolveBaseRef, + verifyBaseRef, } from "./diff.ts"; import { repoFingerprint } from "./fingerprint.ts"; import { imageContentType, isImagePath } from "./imageTypes.ts"; @@ -16,6 +17,12 @@ import { type PayloadCacheEntry, payloadEtag, } from "./payloadCache.ts"; +import { getRefs, type RefsResult } from "./refs.ts"; +import { + parseSelection, + type Selection, + selectionCacheKey, +} from "./selection.ts"; import { createSingleFlight, SingleFlightTimeoutError, @@ -43,6 +50,9 @@ export interface DiffServerHandle { // 시작해 baseFlight가 miss로 되돌아가고 그 테스트는 조용히 baseFlight // 가드만 다시 증명하게 된다 — 첫 번째 테스트와 똑같은 것을, 티 나지 않게. const BASE_TTL_MS = 10_000; +// 피커 목록의 수명. 브랜치·워크트리는 diff 내용보다 훨씬 덜 움직이므로 +// 짧게 잡아도 팝오버를 열 때마다 git을 두 번 부르지 않는다. +const REFS_TTL_MS = 5_000; const baseCache = new Map< string, { value: { base: string | null; ref: string | null }; at: number } @@ -129,6 +139,46 @@ const createHandler = (cfg: { // 같은 (repo, untracked, mode)의 지문 계산+파이프라인을 동시에 한 번만 — // 콜드 상태에서 프리워밍과 첫 화면 요청이 겹쳐도 중복 실행되지 않는다. const diffFlight = createSingleFlight(cfg.flightTimeoutMs); + // 피커 목록. baseCache와 달리 **핸들러 스코프**에 둔다 — baseCache가 모듈 + // 스코프인 것은 flight 타임아웃 테스트 둘이 "따로 띄운 두 서버가 같은 warm + // 항목을 본다"에 의존하는 특수 사정 때문이고(CLAUDE.md), 여기엔 그런 요구가 + // 없다. 서버 인스턴스가 자기 캐시를 갖는 쪽이 격리에 낫다. + const refsFlight = createSingleFlight(cfg.flightTimeoutMs); + const refsCache = new Map(); + // base 해석의 단일 지점. /api/diff와 /api/blob이 서로 다른 기준을 고르면 + // 텍스트 diff와 이미지 카드가 다른 비교를 보여주게 된다. + // + // 사용자가 고른 ref는 서버가 해석할 것이 없다. 목록 밖 값이면 조용히 + // auto로 흘려보내지 않고 거절한다 — 고르지도 않은 기준의 diff를 보여주는 + // 것이 에러보다 나쁘다. + const resolveSelectionBase = async ( + repo: string, + sel: Selection, + ): Promise<{ base: string | null; ref: string | null } | Response> => { + if (sel.base.kind === "ref") { + const verified = await verifyBaseRef(repo, sel.base.ref); + // 상태 코드만으로는 "not a git repository" 400과 구분되지 않는다. + // 클라이언트가 저장된 기준만 골라 버리려면 그 둘을 갈라야 하므로 + // 이 응답에만 표식을 얹는다(본문 문자열 매칭은 취약하다). + return ( + verified ?? + new Response(`unknown base ref: ${sel.base.ref}`, { + status: 400, + headers: { "x-diff-error": "unknown-base" }, + }) + ); + } + return awaitFlight(resolveBaseCached(repo)); + }; + const getRefsCached = (repo: string): Promise => + refsFlight(repo, async () => { + const now = Date.now(); + const hit = refsCache.get(repo); + if (hit && now - hit.at < REFS_TTL_MS) return hit.value; + const value = await getRefs(repo); + refsCache.set(repo, { value, at: now }); + return value; + }); return async (req: Request): Promise => { const url = new URL(req.url); @@ -180,20 +230,21 @@ const createHandler = (cfg: { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } - const untracked = url.searchParams.get("untracked") === "1"; - const mode = url.searchParams.get("mode") === "base" ? "base" : "working"; - const baseResult = await awaitFlight(resolveBaseCached(repo)); + const untracked = sel.untracked; + const mode = sel.base.kind === "head" ? "working" : "base"; + const baseResult = await resolveSelectionBase(repo, sel); if (baseResult instanceof Response) return baseResult; const { base, ref } = baseResult; // 파이프라인(파일당 git 서브프로세스) 전에 싼 지문으로 변경 여부를 // 판정한다. 지문은 파이프라인 "이전"에 뜨므로, 그 사이에 리포가 // 바뀌면 저장된 지문이 이미 낡은 값이 되어 다음 요청이 무조건 // 재계산한다 — 낡은 payload가 눌러앉는 방향의 레이스는 없다. - const cacheKey = `${repo}\0${untracked}\0${mode}`; + const cacheKey = selectionCacheKey(sel, ref); const entryResult = await awaitFlight( diffFlight(cacheKey, async () => { const fingerprint = await repoFingerprint(repo, { @@ -227,14 +278,14 @@ const createHandler = (cfg: { if (req.headers.get("if-none-match") === etag) { return new Response(null, { status: 304, - headers: { etag, "x-diff-base": base ?? "" }, + headers: { etag, "x-diff-base": encodeURIComponent(base ?? "") }, }); } // NOTE: intentionally no Access-Control-Allow-Origin — cross-origin pages must not read this. return new Response(entry.body, { headers: { "content-type": "application/json; charset=utf-8", - "x-diff-base": base ?? "", + "x-diff-base": encodeURIComponent(base ?? ""), etag, }, }); @@ -244,11 +295,15 @@ const createHandler = (cfg: { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } - const baseResult = await awaitFlight(resolveBaseCached(repo)); + // 빈 상태 카드가 diff와 **다른 비교**를 설명하면 안 된다. 여기서 + // resolveBaseCached를 그냥 부르면 사용자가 develop을 골라 놓고도 + // 카드는 "No changes vs main"이라고 말한다. + const baseResult = await resolveSelectionBase(repo, sel); if (baseResult instanceof Response) return baseResult; const { base, ref } = baseResult; const summary = await getRepoSummary(repo, { base, ref }); @@ -258,11 +313,30 @@ const createHandler = (cfg: { }); } + // 피커가 고를 수 있는 것들. /api/diff의 순차 flight 사슬에 끼우지 않고 + // 자기 라우트에서 자기 예산으로 돈다. + if (url.pathname === "/api/refs") { + if (url.searchParams.get("token") !== cfg.token) { + return new Response("forbidden", { status: 403 }); + } + const sel = parseSelection(url.searchParams); + const repo = sel.repo; + if (!repo || !(await isGitRepo(repo))) { + return new Response("not a git repository", { status: 400 }); + } + const result = await awaitFlight(getRefsCached(repo)); + if (result instanceof Response) return result; + return new Response(JSON.stringify(result), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } + if (url.pathname === "/api/blob") { if (url.searchParams.get("token") !== cfg.token) { return new Response("forbidden", { status: 403 }); } - const repo = url.searchParams.get("repo") ?? ""; + const sel = parseSelection(url.searchParams); + const repo = sel.repo; if (!repo || !(await isGitRepo(repo))) { return new Response("not a git repository", { status: 400 }); } @@ -272,10 +346,10 @@ const createHandler = (cfg: { return new Response("not found", { status: 404 }); } const side = url.searchParams.get("side") === "old" ? "old" : "new"; - const mode = url.searchParams.get("mode") === "base" ? "base" : "working"; + const mode = sel.base.kind === "head" ? "working" : "base"; let ref: string | null = null; if (mode === "base") { - const baseResult = await awaitFlight(resolveBaseCached(repo)); + const baseResult = await resolveSelectionBase(repo, sel); if (baseResult instanceof Response) return baseResult; ref = baseResult.ref; } diff --git a/apps/viewer/server/summary.ts b/apps/viewer/server/summary.ts index b896785..422f5ef 100644 --- a/apps/viewer/server/summary.ts +++ b/apps/viewer/server/summary.ts @@ -9,7 +9,14 @@ import { resolveDiffBaseRev } from "./diff.ts"; export interface RepoSummary { branch: string | null; head: string; + /** 표시용 이름 — origin/ 접두가 벗겨져 있다. */ base: string | null; + /** + * baseFiles를 **실제로 잰** 참조. `base`와 달리 접두가 살아 있어 + * (`origin/main` vs `main`) 목록의 어느 행이 그 숫자의 주인인지 + * 가릴 수 있다. 표시명으로 맞추면 로컬 동명 브랜치에 남의 숫자가 붙는다. + */ + ref: string | null; workingFiles: number; baseFiles: number | null; untrackedFiles: number; @@ -64,6 +71,7 @@ export const getRepoSummary = async ( branch: branch || null, head, base: opts.base, + ref: opts.ref ?? null, workingFiles, baseFiles, untrackedFiles, diff --git a/docs/README.es.md b/docs/README.es.md index b4ee03b..6a2a960 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -29,9 +29,19 @@ Lo que ofrece el motor de renderizado de diffs: - **Renderizado virtualizado** que se mantiene fluido en diffs grandes, con cabeceras de archivo fijas (sticky). - **Encapsulación con Shadow DOM** por archivo, de modo que los estilos del visor nunca se filtran a la página. -El chrome interactivo del visor que envuelve este motor — plegado con clic, copiar ruta, búsqueda integrada, watch/auto-actualización, y modos working-tree-vs-base — proviene del visor de [cc-statusline](https://github.com/say8425/cc-statusline) y ahora vive en `apps/viewer/` de diffdeck. +El chrome interactivo del visor que envuelve este motor — plegado con clic, copiar ruta, búsqueda integrada, watch/auto-actualización, y un selector de base de comparación con búsqueda — proviene del visor de [cc-statusline](https://github.com/say8425/cc-statusline) y ahora vive en `apps/viewer/` de diffdeck. -![El visor de diffdeck — árbol de archivos con insignias de estado de git, diff de imagen en línea y diffs con resaltado de sintaxis](screenshot.png) +![El visor de diffdeck — árbol de archivos con insignias de estado de git y diffs con resaltado de sintaxis](screenshot.png) + +### Comparar contra cualquier rama + +El selector de la barra de herramientas decide contra qué se mide el diff: tu trabajo sin confirmar (**Working tree**), o cualquier rama local o remota. Escribe para filtrar. + +![El selector de base de comparación abierto, con Working tree junto a las ramas del repositorio](ref-picker.png) + +Dos etiquetas te ahorran un momento de desconcierto. La rama que este worktree tiene activa lleva `HEAD` — comparar contra ella siempre se ve vacío, porque es donde ya estás. La rama por defecto del repositorio lleva `default`, así que la elección habitual se encuentra de inmediato. + +Al elegir una rama, la comparación es contra la **merge base**: el commit del que se bifurcó tu trabajo. Así ves solo tus propios cambios, y no todo lo que haya llegado a la otra rama desde entonces. ### Grab — envía una selección del diff a tu agente de código diff --git a/docs/README.ja.md b/docs/README.ja.md index 0f4c686..e77ba0d 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -29,9 +29,19 @@ diff レンダリングエンジンが提供する機能: - **仮想化レンダリング**: 大規模な diff でもスムーズに動作し、ファイルヘッダーは sticky。 - **ファイル単位の Shadow DOM カプセル化**: ビューアのスタイルがページに漏れ出すことはありません。 -このエンジンをラップするインタラクティブなビューア chrome — クリックでの折りたたみ、パスのコピー、アプリ内検索、watch(自動更新)、working-tree-vs-base モード — は [cc-statusline](https://github.com/say8425/cc-statusline) のビューアに由来し、現在は diffdeck の `apps/viewer/` に置かれています。 +このエンジンをラップするインタラクティブなビューア chrome — クリックでの折りたたみ、パスのコピー、アプリ内検索、watch(自動更新)、検索可能な比較ベースピッカー — は [cc-statusline](https://github.com/say8425/cc-statusline) のビューアに由来し、現在は diffdeck の `apps/viewer/` に置かれています。 -![diffdeck ビューア — git ステータスバッジ付きのファイルツリー、インライン画像 diff、シンタックスハイライトされた diff](screenshot.png) +![diffdeck ビューア — git ステータスバッジ付きのファイルツリーとシンタックスハイライトされた diff](screenshot.png) + +### 任意のブランチと比較する + +ツールバーのピッカーが、diff の基準を決めます。まだコミットしていない変更(**Working tree**)か、ローカル・リモートを問わない任意のブランチです。入力すると絞り込まれます。 + +![比較ベースピッカーを開き、Working tree とリポジトリのブランチが並んでいるところ](ref-picker.png) + +2 つのラベルが迷いを減らします。このワークツリーがチェックアウトしているブランチには `HEAD` が付きます — 今いる場所そのものなので、それと比べると常に空に見えます。リポジトリの既定ブランチには `default` が付き、よく使う選択がすぐ見つかります。 + +ブランチを選ぶと **merge base**、つまり自分の作業が分岐したコミットと比較します。だから自分が変えたものだけが見え、分岐後に相手側へ積まれたものは混ざりません。 ### Grab — diff の選択範囲をコーディングエージェントへ diff --git a/docs/README.ko.md b/docs/README.ko.md index a667694..07c10d3 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -29,9 +29,19 @@ diff 렌더링 엔진이 제공하는 기능: - 큰 diff에서도 부드럽게 동작하는 **가상화 렌더링**, sticky 파일 헤더 포함. - 파일마다 적용되는 **shadow DOM 캡슐화**로 뷰어 스타일이 페이지로 새어나가지 않습니다. -이 엔진을 감싸는 인터랙티브 뷰어 chrome — 클릭으로 접기, 경로 복사, 인앱 검색, watch/자동 새로고침, working-tree-vs-base 모드 — 는 [cc-statusline](https://github.com/say8425/cc-statusline) 뷰어에서 왔으며, 현재는 diffdeck의 `apps/viewer/`에 있습니다. +이 엔진을 감싸는 인터랙티브 뷰어 chrome — 클릭으로 접기, 경로 복사, 인앱 검색, watch/자동 새로고침, 검색 가능한 비교 기준 피커 — 는 [cc-statusline](https://github.com/say8425/cc-statusline) 뷰어에서 왔으며, 현재는 diffdeck의 `apps/viewer/`에 있습니다. -![diffdeck 뷰어 — git 상태 배지가 있는 파일 트리, 인라인 이미지 diff, 구문 강조된 diff](screenshot.png) +![diffdeck 뷰어 — git 상태 배지가 있는 파일 트리와 구문 강조된 diff](screenshot.png) + +### 어떤 브랜치와도 견주기 + +툴바의 피커가 무엇을 기준으로 diff를 잴지 정합니다. 아직 커밋하지 않은 변경(**Working tree**)이거나, 로컬·원격 브랜치 아무거나입니다. 타이핑하면 걸러집니다. + +![기준 피커가 열려 Working tree와 리포의 브랜치들을 함께 보여주는 모습](ref-picker.png) + +라벨 두 개가 헷갈릴 순간을 덜어줍니다. 이 워크트리가 체크아웃한 브랜치에는 `HEAD`가 붙습니다 — 이미 서 있는 자리라 그것과 견주면 언제나 비어 보입니다. 리포의 기본 브랜치에는 `default`가 붙어 흔한 선택을 바로 찾을 수 있습니다. + +브랜치를 고르면 **merge base**, 즉 내 작업이 갈라져 나온 커밋과 견줍니다. 그래서 내가 바꾼 것만 보이고, 갈라진 뒤 저쪽 브랜치에 쌓인 것들은 섞이지 않습니다. ### Grab — diff 선택을 코딩 에이전트에게 넘기기 diff --git a/docs/README.zh.md b/docs/README.zh.md index 90044f6..fbe6ecf 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -29,9 +29,19 @@ diff 渲染引擎提供的功能: - **虚拟化渲染**,在大型 diff 下仍保持流畅,并带有粘性(sticky)文件头。 - **每个文件独立的 Shadow DOM 封装**,确保查看器的样式不会泄漏到页面中。 -包裹这一引擎的交互式查看器外壳——点击折叠、复制路径、应用内搜索、watch/自动刷新,以及 working-tree 与 base 对比模式——沿用自 [cc-statusline](https://github.com/say8425/cc-statusline) 的查看器,现已移入 diffdeck 的 `apps/viewer/` 中。 +包裹这一引擎的交互式查看器外壳——点击折叠、复制路径、应用内搜索、watch/自动刷新,以及可搜索的对比基准选择器——沿用自 [cc-statusline](https://github.com/say8425/cc-statusline) 的查看器,现已移入 diffdeck 的 `apps/viewer/` 中。 -![diffdeck 查看器 —— 带 git 状态徽章的文件树、内联图片 diff 与语法高亮 diff](screenshot.png) +![diffdeck 查看器 —— 带 git 状态徽章的文件树与语法高亮 diff](screenshot.png) + +### 与任意分支对比 + +工具栏的选择器决定 diff 以什么为基准:尚未提交的改动(**Working tree**),或任意本地/远程分支。输入即可筛选。 + +![打开的对比基准选择器,同时列出 Working tree 与仓库中的分支](ref-picker.png) + +两个标签可以省下一次困惑。当前工作树检出的分支带 `HEAD` —— 那正是你所在的位置,与它对比永远看起来是空的。仓库的默认分支带 `default`,常用的选择一眼可见。 + +选定分支后,对比的是 **merge base**,也就是你的工作分叉出去的那个提交。因此你只会看到自己改动的部分,而不会混入分叉之后落到对方分支上的内容。 ### Grab — 把 diff 选区交给编码智能体 diff --git a/docs/ref-picker.png b/docs/ref-picker.png new file mode 100644 index 0000000..6500204 Binary files /dev/null and b/docs/ref-picker.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png index 7b2ec5c..68418b0 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/skills/diffdeck/SKILL.md b/skills/diffdeck/SKILL.md index c609293..95cfe3f 100644 --- a/skills/diffdeck/SKILL.md +++ b/skills/diffdeck/SKILL.md @@ -7,7 +7,7 @@ license: Apache-2.0 # diffdeck — show the human a visual diff diffdeck runs a local web server that renders the current git repository's diff -(working-tree changes, or a branch vs its base) in the browser: a file tree with +(working-tree changes, or a comparison against any branch) in the browser: a file tree with git-status badges, unified/split views, in-app search, image diffs, live watch, and **grab** — the human can select code in the diff and copy it back to you with an exact reference attached. Use it to let the **human** see changes visually