Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
3550228
fix: 비ASCII 브랜치명이 diff 응답을 500으로 만드는 것을 고친다
say8425 Aug 20, 2026
3db5086
refactor: diff 선택 파싱을 한 곳으로 모으고 flight 키를 전함수로 만든다
say8425 Aug 21, 2026
572b04a
feat: 워크트리와 참조 목록을 내려주는 /api/refs를 추가한다
say8425 Aug 21, 2026
4e208a7
feat: 견줄 기준을 호출자가 고를 수 있게 base 파라미터를 받는다
say8425 Aug 21, 2026
1f29826
feat: 피커 목록과 기준 프리퍼런스의 순수 로직을 추가한다
say8425 Aug 21, 2026
91d2dad
feat: 툴바의 2지선다 select를 검색 가능한 기준 피커로 바꾼다
say8425 Aug 21, 2026
1791c0f
docs: 기준 피커를 문서 일곱 곳과 CLAUDE.md에 반영하고 스크린샷을 다시 찍는다
say8425 Aug 21, 2026
9321cdd
fix: 코드 리뷰가 잡은 경계면 결함들을 고친다
say8425 Aug 21, 2026
bd1786c
fix: 자가복구가 엉뚱한 400에 발동하던 것과 문서 드리프트를 고친다
say8425 Aug 21, 2026
9e8254a
fix: CI에서만 깨지던 테스트 둘을 고친다 — 둘 다 내 이식성 버그였다
say8425 Aug 21, 2026
a724adf
feat: 피커에서 Working tree와 브랜치를 구역으로 가르고 개수를 미리 보여준다
say8425 Aug 21, 2026
8ef0b2a
feat: 오버플로 메뉴 최하단에 버전을 두고 레포로 잇는다
say8425 Aug 21, 2026
85fb050
fix: 피커 개수가 엉뚱한 행에 붙던 것과 목록이 summary를 기다리던 것을 고친다
say8425 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@ What the diff-rendering engine provides:
- **Virtualized rendering** that stays smooth on large diffs, with sticky file headers.
- **Shadow-DOM encapsulation** per file, so the viewer's styles never leak into the page.

The interactive viewer chrome that wraps this engine — click-to-fold, copy-path, in-app search, watch/auto-refresh, and working-tree-vs-base modes — comes from the [cc-statusline](https://github.com/say8425/cc-statusline) viewer and now lives in diffdeck's `apps/viewer/`.
The interactive viewer chrome that wraps this engine — click-to-fold, copy-path, in-app search, watch/auto-refresh, and a searchable compare-base picker — comes from the [cc-statusline](https://github.com/say8425/cc-statusline) viewer and now lives in diffdeck's `apps/viewer/`.

![The diffdeck viewer — file tree with git-status badges, an inline image diff, and syntax-highlighted diffs](docs/screenshot.png)
![The diffdeck viewer — file tree with git-status badges and a syntax-highlighted diff](docs/screenshot.png)

### Compare against any branch

The toolbar's picker chooses what the diff is measured against: your uncommitted work (**Working tree**), or any local or remote branch. Type to filter.

![The compare-base picker, open and listing Working tree alongside the repository's branches](docs/ref-picker.png)

Two labels save you a puzzled moment. The branch your worktree has checked out is tagged `HEAD` — comparing against it always looks empty, because it is where you already are. The repository's default branch is tagged `default`, so the usual choice is easy to spot.

Picking a branch compares against the **merge base** — the commit your work forked from — so you see your own changes, not everything that has landed on the other branch since you branched.

### Grab — hand a diff selection to your coding agent

Expand Down
6 changes: 3 additions & 3 deletions apps/viewer/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @say8425/diffdeck

A local git diff viewer. Run it in any git repository to browse working-tree
and branch diffs in your browser, with syntax highlighting, a file tree, and
inline image diffs.
A local git diff viewer. Run it in any git repository to browse your
working-tree changes — or compare them against any branch — in your browser,
with syntax highlighting, a file tree, and inline image diffs.

## Usage

Expand Down
200 changes: 200 additions & 0 deletions apps/viewer/__tests__/diff-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
import { getRefs, parseRefList, parseWorktreeList } from "../server/refs.ts";

// `git worktree list --porcelain -z`의 실측 형식(git 2.54.0): 속성 한 줄마다
// NUL이 붙고, 레코드 사이는 빈 항목이다.
const wt = (...records: string[][]): string =>
`${records.map((lines) => lines.map((l) => `${l}\0`).join("")).join("\0")}\0`;

// `for-each-ref --format=...%00...%00`의 실측 형식: 필드마다 NUL, 레코드
// 사이에 리터럴 개행이 하나 들어간다.
const refs = (...records: string[][]): string =>
records.map((fields) => `${fields.join("\0")}\0`).join("\n");

describe("parseWorktreeList", () => {
test("reads path, branch and head from an attached worktree", () => {
const out = parseWorktreeList(
wt(["worktree /repo", "HEAD abc123", "branch refs/heads/main"]),
);
expect(out).toEqual([
{ path: "/repo", branch: "main", head: "abc123", detached: false },
]);
});

test("marks a detached worktree and leaves its branch null", () => {
const out = parseWorktreeList(
wt(["worktree /wt", "HEAD abc123", "detached"]),
);
expect(out).toEqual([
{ path: "/wt", branch: null, head: "abc123", detached: true },
]);
});

// 이 레포에서 실제로 밟은 상태다. 디렉토리가 사라져도 등록은 남고,
// branch 줄까지 그대로 달고 나온다 — 고를 수 있게 두면 워크트리가 없는
// 경로로 이동해 빠져나올 수 없는 화면이 된다.
test("drops a worktree whose directory is gone", () => {
const out = parseWorktreeList(
wt(
["worktree /alive", "HEAD abc", "branch refs/heads/main"],
[
"worktree /gone",
"HEAD abc",
"branch refs/heads/feat",
"prunable gitdir file points to non-existent location",
],
),
);
expect(out.map((w) => w.path)).toEqual(["/alive"]);
});

test("drops a bare repository, which has no working tree at all", () => {
const out = parseWorktreeList(
wt(
["worktree /bare.git", "bare"],
["worktree /real", "HEAD abc", "branch refs/heads/main"],
),
);
expect(out.map((w) => w.path)).toEqual(["/real"]);
});

test("tolerates an empty listing", () => {
expect(parseWorktreeList("")).toEqual([]);
});
});

describe("parseRefList", () => {
const live = new Set(["/repo", "/wt-a"]);

test("splits local and remote refs by their prefix", () => {
const { refs: out } = parseRefList(
refs(
["refs/heads/main", "main", "", ""],
["refs/remotes/origin/main", "origin/main", "", ""],
),
live,
);
expect(out).toEqual([
{ name: "main", kind: "local", worktreePath: null },
{ name: "origin/main", kind: "remote", worktreePath: null },
]);
});

test("attributes a branch to the worktree holding it", () => {
const { refs: out } = parseRefList(
refs(["refs/heads/feat", "feat", "/wt-a", ""]),
live,
);
expect(out[0]?.worktreePath).toBe("/wt-a");
});

// for-each-ref는 죽은 워크트리 경로도 그대로 실어 보낸다(실측). 살아 있는
// 워크트리 집합과 교차 확인하지 않으면 목록이 그 경로를 광고하게 된다.
test("ignores a worktree path that is no longer live", () => {
const { refs: out } = parseRefList(
refs(["refs/heads/feat", "feat", "/wt-gone", ""]),
live,
);
expect(out[0]?.worktreePath).toBeNull();
});

// git은 refname에 "|"를 허용한다 — 실제로 만들어 확인했다. 필드 구분자로
// NUL을 쓰는 이유다.
test("keeps a refname containing a pipe intact", () => {
const { refs: out } = parseRefList(
refs(["refs/heads/weird|pipe", "weird|pipe", "", ""]),
live,
);
expect(out[0]?.name).toBe("weird|pipe");
});

test("reports the default branch from origin/HEAD and hides the pseudo-ref", () => {
const { refs: out, defaultBranch } = parseRefList(
refs(
["refs/remotes/origin/HEAD", "origin", "", "refs/remotes/origin/main"],
["refs/remotes/origin/main", "origin/main", "", ""],
),
live,
);
expect(defaultBranch).toBe("main");
expect(out.map((r) => r.name)).toEqual(["origin/main"]);
});

test("reports no default branch when origin/HEAD is unset", () => {
const { defaultBranch } = parseRefList(
refs(["refs/heads/main", "main", "", ""]),
live,
);
expect(defaultBranch).toBeNull();
});

test("tolerates an empty listing", () => {
expect(parseRefList("", live)).toEqual({ refs: [], defaultBranch: null });
});
});

// 파서 둘이 진짜 git 출력 위에서 합쳐지는지 본다. 픽스처 문자열은 형식을
// 내가 옳게 적었다는 것만 증명하지, git이 실제로 그렇게 내보낸다는 것은
// 증명하지 않는다.
describe("getRefs against a real repository", () => {
let root: string;
let repo: string;

beforeEach(async () => {
// macOS의 /var는 /private/var 심링크다. git은 워크트리 경로를
// realpath로 돌려주므로 기대값도 realpath여야 한다. (프로덕션에는
// 영향이 없다 — 교차 확인은 git 출력끼리 비교하므로 양쪽 다
// realpath다.)
root = realpathSync(mkdtempSync(join(tmpdir(), "dd-refs-")));
repo = join(root, "main-wt");
await $`git init -q ${repo}`;
await $`git -C ${repo} config user.email t@t.co`;
await $`git -C ${repo} config user.name test`;
await $`git -C ${repo} commit -q --allow-empty -m init`;
// 초기 브랜치 이름을 고정한다. git init은 머신의
// init.defaultBranch를 따르므로(로컬 main, CI 러너 master) 고정하지
// 않으면 이 스위트가 개발자 머신에서만 통과한다 — 실제로 CI에서
// "master"를 받고 깨졌다.
await $`git -C ${repo} branch -M main`;
await $`git -C ${repo} branch feat-a`;
await $`git -C ${repo} branch feat-gone`;
// 워크트리는 repo의 형제로 만든다 — 안에 만들면 git status에 잡힌다.
await $`git -C ${repo} worktree add -q ${join(root, "wt-a")} feat-a`;
await $`git -C ${repo} worktree add -q ${join(root, "wt-gone")} feat-gone`;
rmSync(join(root, "wt-gone"), { recursive: true, force: true });
});

afterEach(() => rmSync(root, { recursive: true, force: true }));

test("lists live worktrees and drops the one whose directory is gone", async () => {
const { worktrees } = await getRefs(repo);
expect(
worktrees
.map((w) => w.branch)
.toSorted((a, b) => String(a).localeCompare(String(b))),
).toEqual(["feat-a", "main"]);
});

test("attributes a branch to its worktree but not to a dead one", async () => {
const { refs: out } = await getRefs(repo);
const byName = new Map(out.map((r) => [r.name, r]));
expect(byName.get("feat-a")?.worktreePath).toBe(join(root, "wt-a"));
// git은 죽은 워크트리 경로도 그대로 실어 보낸다 — 교차 확인이 그걸 막는다.
expect(byName.get("feat-gone")?.worktreePath).toBeNull();
});

test("reads the default branch from origin/HEAD when it is set", async () => {
const head = (await $`git -C ${repo} rev-parse HEAD`.text()).trim();
const ref = "refs/remotes/origin/main";
await $`git -C ${repo} update-ref ${ref} ${head}`;
await $`git -C ${repo} symbolic-ref refs/remotes/origin/HEAD ${ref}`;
const { refs: out, defaultBranch } = await getRefs(repo);
expect(defaultBranch).toBe("main");
// 짧게 쓰면 "origin"이 되는 유사 참조는 목록에 없어야 한다.
expect(out.map((r) => r.name)).not.toContain("origin");
});
});
148 changes: 148 additions & 0 deletions apps/viewer/__tests__/diff-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, test } from "bun:test";
import { parseSelection, selectionCacheKey } from "../server/selection.ts";

const params = (query: string): URLSearchParams => new URLSearchParams(query);

describe("parseSelection", () => {
test("defaults to the head base when mode is absent", () => {
expect(parseSelection(params("repo=/r")).base).toEqual({ kind: "head" });
});

test("mode=base selects the auto-resolved base", () => {
expect(parseSelection(params("repo=/r&mode=base")).base).toEqual({
kind: "auto",
});
});

test("mode=working selects the head base", () => {
expect(parseSelection(params("repo=/r&mode=working")).base).toEqual({
kind: "head",
});
});

// 오늘의 삼항(=== "base" ? ... : "working")과 같은 관용: 알 수 없는 값은
// 400이 아니라 조용히 working으로 떨어진다. 링크가 깨지지 않는 쪽이다.
test("an unknown mode falls back to the head base", () => {
expect(parseSelection(params("repo=/r&mode=nonsense")).base).toEqual({
kind: "head",
});
});

test("untracked is on only for the exact string 1", () => {
expect(parseSelection(params("repo=/r&untracked=1")).untracked).toBe(true);
expect(parseSelection(params("repo=/r&untracked=true")).untracked).toBe(
false,
);
expect(parseSelection(params("repo=/r")).untracked).toBe(false);
});

test("a missing repo becomes the empty string, as the routes expect", () => {
expect(parseSelection(params("")).repo).toBe("");
});
});

describe("selectionCacheKey", () => {
// 이 스위트가 지키는 진짜 계약: 키는 flight 클로저가 읽는 모든 입력의
// 전함수여야 한다. 오늘 diffFlight의 클로저는 repo·untracked·mode·ref를
// 읽는데 키에는 ref가 없어서, 해석된 base가 바뀐 두 요청이 같은 키로
// 합류하면 한쪽이 남의 ref로 만든 diff를 받는다.
test("distinguishes two auto selections whose base resolved differently", () => {
const sel = parseSelection(params("repo=/r&mode=base"));
expect(selectionCacheKey(sel, "origin/main")).not.toBe(
selectionCacheKey(sel, "origin/develop"),
);
});

test("ignores the resolved ref when the base is head", () => {
const sel = parseSelection(params("repo=/r&mode=working"));
expect(selectionCacheKey(sel, "origin/main")).toBe(
selectionCacheKey(sel, "origin/develop"),
);
});

test("separates repo, untracked and base kind", () => {
const keys = new Set([
selectionCacheKey(parseSelection(params("repo=/a&mode=working")), null),
selectionCacheKey(parseSelection(params("repo=/b&mode=working")), null),
selectionCacheKey(
parseSelection(params("repo=/a&mode=working&untracked=1")),
null,
),
selectionCacheKey(parseSelection(params("repo=/a&mode=base")), null),
]);
expect(keys.size).toBe(4);
});

// 경로·refname에 구분자가 섞여도 서로 다른 선택이 같은 키로 뭉개지지
// 않아야 한다 (NUL은 두 값 어디에도 들어갈 수 없다).
test("does not collide when a repo path contains the separator's neighbours", () => {
expect(
selectionCacheKey(parseSelection(params("repo=/a%00false")), null),
).not.toBe(selectionCacheKey(parseSelection(params("repo=/a")), null));
});
});

describe("parseSelection with an explicit base", () => {
test("base names a ref to compare against", () => {
expect(parseSelection(params("repo=/r&base=develop")).base).toEqual({
kind: "ref",
ref: "develop",
});
});

test("the @auto sentinel asks the server to resolve the base itself", () => {
expect(parseSelection(params("repo=/r&base=@auto")).base).toEqual({
kind: "auto",
});
});

// base가 있으면 mode는 무시된다 — 한 축을 두 파라미터가 인코딩하면
// 서로 모순되는 상태가 생기므로, 새 파라미터가 이긴다는 규칙 하나로
// 그 상태를 없앤다.
test("an explicit base wins over the legacy mode", () => {
expect(
parseSelection(params("repo=/r&mode=working&base=develop")).base,
).toEqual({ kind: "ref", ref: "develop" });
});

test("an empty base falls back to the legacy mode", () => {
expect(parseSelection(params("repo=/r&base=&mode=base")).base).toEqual({
kind: "auto",
});
});

// 참조로 두지 않고 정규화한다: unborn HEAD 리포에서 참조 검증이 실패해
// 첫 화면이 400이 되는 것을 막고, prewarm 슬롯과 캐시 키를 일치시킨다.
test("base=HEAD normalizes to the head selector, not a ref named HEAD", () => {
expect(parseSelection(params("repo=/r&base=HEAD")).base).toEqual({
kind: "head",
});
});
});

describe("selectionCacheKey with an explicit base", () => {
test("two different chosen refs never share a slot", () => {
expect(
selectionCacheKey(parseSelection(params("repo=/r&base=a")), null),
).not.toBe(
selectionCacheKey(parseSelection(params("repo=/r&base=b")), null),
);
});

// 사용자가 고른 ref는 서버가 해석할 필요가 없으므로 해석값과 무관해야 한다.
test("a chosen ref ignores the server-resolved base", () => {
const sel = parseSelection(params("repo=/r&base=develop"));
expect(selectionCacheKey(sel, "origin/main")).toBe(
selectionCacheKey(sel, "origin/other"),
);
});

// 고른 ref "auto"와 자동 해석은 서로 다른 질문이다.
test("a ref literally named auto is not the auto selector", () => {
expect(
selectionCacheKey(parseSelection(params("repo=/r&base=auto")), null),
).not.toBe(
selectionCacheKey(parseSelection(params("repo=/r&base=@auto")), null),
);
});
});
Loading