diff --git a/DESIGN.md b/DESIGN.md index a1dd7b8..1fe6216 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -647,3 +647,120 @@ and no new endpoint. 5. Fenced code with `lexLine` highlighting, size caps, info-string allowlist. 6. Images (relative via blob, remote placeholder), size gate with "Render anyway", palette command, README and SECURITY notes, fuzz test. + +## 15. Round 6: Branch changes review + +**Brief.** Let the user review what an agent changed: both the uncommitted work and everything the +branch has accumulated since it left its base branch, since agents commit every few minutes. Three +panelists reviewed a draft: the interaction designer, the visual/information designer, and a +developer-experience researcher who argued for the smallest product that does the job. Before this, +the app had gutter markers vs `HEAD` with click-to-peek, and ⌘⇧G opened a palette list of changed +files (a binding that, it turned out, was advertised but never wired). + +### Positions and decisions + +**Where it lives (all three).** A third sidebar view, "Changes", next to Explorer and Search, for the +reason Search is one (§13): the list must stay visible and keep updating while the agent works, and a +palette closes when you act. Not a panel (the terminal owns it), not a tab (it would fight the file +being read). +- ⌘⇧G shows the sidebar, switches to Changes and focuses the list. The status bar's branch and + change-count buttons do the same. The palette list is gone: two surfaces for one list is a + discoverability and maintenance tax. +- **Decision: adopted.** + +**Scope (disagreement, resolved).** The interaction designer wanted a single list, branch vs base, +falling back to `HEAD`, because a switch makes the user choose before seeing anything and +"uncommitted" is a shrinking subset of "branch". The other two wanted both scopes: "what did the +agent do since the last commit" is a real question mid-task. The DX researcher settled the semantics: +the branch scope compares the *working tree* with the merge-base (not `HEAD` with the merge-base), +because the file the agent is editing this second is the one the user most wants to see; the two +scopes are therefore nested. +- A segmented control (Uncommitted · vs main) appears only when a base branch exists and HEAD has + commits on top of it; otherwise there is one list and the summary reads "vs HEAD". The default is + the branch scope. +- Base detection (server): the remote's default branch (`refs/remotes/origin/HEAD`), else the first + of `main`/`master`/`develop`/`trunk` that exists and is not the current branch, else the upstream. + On `main` itself the base is `origin/main`, so unpushed commits are what the branch scope shows. + A detached HEAD still compares with the base. No user-configurable base in v1. +- **Decision: two nested scopes, the control only when they differ.** + +**Summary and list (visual designer).** One 28px summary line, mono tabular counts: `7 files · ++120 −38 · 3 commits`, the base right-aligned and faint, a 2px two-colour bar under it. The commit +list was cut (the DX researcher: nobody reviews agent output commit by commit; the count answers "did +it squash?") and lives in the tooltip of the count. Rows are 24px like the tree: badge, basename, +faint directory, fixed-width `+N` `−N` columns so counts align without a table. Names are not tinted +(the counts already carry colour). Renames show `old → new`, deletions strike the name through, +binaries say `bin`, conflicts get a left rule. Surprises sort first: conflicts, deletions, new files, +renames, then edits. A file that was opened dims ("reviewed") until it changes again, so a finished +review is a list of dim rows (the interaction designer's "know when you're done"). +- **Decision: adopted.** + +**Diff tab vs re-based gutter (disagreement, resolved).** The interaction designer proposed no new +view: re-base the existing gutter markers on the merge-base and keep deletions behind click-to-peek, +to avoid a second renderer. The other two held that reading deletions is the core of reviewing agent +output ("did the tests shrink?") and that a peek-per-hunk does not scale to a 40-file review. The +diff tab won, kept lean: unified only (side-by-side needs ~160 columns; sidebar + terminal on a +laptop leaves ~70), two line-number columns and a `+`/`−` marker, row backgrounds from the existing +git tokens at 12% (20% in the number cells), syntax highlighting untouched, three lines of context +with a clickable "N unchanged lines" row, virtualized like the code view. No word-level diff in v1. +The interaction designer's two fixes were kept: deleted files open (their old side comes from the +merge-base blob), and ⌥F5/⌥⇧F5 walk the hunks of a diff and, at the ends, move on to the +next/previous file, so a review is one key held down. The header shows the file, `+/−`, the base, +`n/N` with ‹ › buttons, and "Open file" (the live file at its first change). +- **Decision: unified diff tab; no side-by-side, ever.** + +**Live behaviour (DX researcher).** A stale review list in an agent-watching tool is a bug. The list +refreshes on every git status change while shown (or while diff tabs are open); diff tabs reload when +their file changes on disk and re-diff when the base moves (a commit in the uncommitted scope), keeping +their scroll position. A file the agent reverts keeps its tab and shows "No differences". Diff tabs are +not restored across sessions. +- **Decision: adopted.** + +**Engine and security.** One new endpoint, `/api/git/changes?scope=`, built from `git diff --raw +--numstat -z -M -- .` (raw records give status and rename pairs, numstat the counts; both are +parsed as NUL-separated records, never lines) plus `git ls-files --others --exclude-standard` for +untracked files, whose lines are counted by the server for the first 200 files under 1 MiB, opened +with `O_NOFOLLOW` so an untracked symlink to `~/.ssh` is never read. The list is capped at 1,000 +entries. The old side of a diff reuses `/api/git/base` with a `rev` parameter that must be a full +hex object id the server itself issued (never a ref name, never `-`-prefixed); with a `rev` the path +need not exist on disk (deleted and renamed files) but is checked syntactically. Everything runs +through the hardened runner of §10. Client: paths and subjects are escaped and control characters +made visible (Trojan Source), rows are addressed by index, never by path in an attribute. + +**Cut from v1, by the panel:** commits list (count only), user-chosen base, whitespace toggle, +word-level diff, side-by-side, anything that writes (staging, commenting, stash). Those are an SCM +client; this is a viewer. + +### As built + +- `src/server/git.ts`: `changes()`, `detectBase()`, `base(rel, max, rev)`, `isOid`, `MAX_CHANGES`. +- `src/client/changes.ts`: `ChangesView` (scope control, summary, sorted rows, keyboard, reviewed + dimming, live refresh) and the pure `letterOf`, `labelOf`, `sortChanges`, `summarize`. +- `src/client/diffview.ts`: `DiffView` (header, virtualized unified rows, gaps, hunk stepping) and the + pure `buildRows`, `spansHtml`. +- `src/client/editor.ts`: diff tabs (`openDiff`, `refreshDiffs`, `setDiffPositions`); ⌥F5 falls + through to the next file. `src/client/app.ts`: the Changes side view, ⌘⇧G and ⌥F5 bindings, the + "Git: Review Changes" and "Git: Compare With Base Branch / HEAD" commands. +- Tests: `servergit.changes`, `http.changes`, `client.changes`, `client.diffview`. + +### Adversarial review (security, performance, correctness) + +Three independent reviewers attacked the feature with repros and measurements. Every confirmed +finding was fixed and locked in with a test: + +| Area | Finding | Fix | +| --- | --- | --- | +| Security (high) | `diff.orderFile=/dev/zero` in a hostile `.git/config` made the new `git diff` grow past 4 GB and never return; `mailmap.file=/dev/zero` did the same to `git log` (and the pre-existing `log -1`/`blame`) | `-O/dev/null` on the diff, `--no-mailmap` on every log, `mailmap.file=/dev/null`, `mailmap.blob=` and `log.mailmap=false` in `HARDEN`; a 30 s kill timer on every git run | +| Security (medium) | `diff.relative=true` made paths cwd-relative, so a subfolder view silently listed nothing; `core.bigFileThreshold=1` turned every count into "bin" | `--no-relative`; `core.bigFileThreshold=512m` in `HARDEN` | +| Security (low) | 200 untracked files × 1 MiB read concurrently into full-size buffers; a rename from outside the viewed folder showed a path above the root | Streamed counting through one 64 KiB buffer, 8 at a time; such renames are shown as added, without the old path | +| Correctness (high) | Merge conflicts never appeared: `git diff ` compares a tree with the working tree and never emits unmerged entries, so the whole conflict path was dead code | `git ls-files --unmerged` overlays `U` entries (test: `UU` and modify/delete) | +| Correctness (high) | Past the 1,000-file cap the parser stopped before the numstat section, so every row lost its counts and never un-dimmed | Skip entries over the cap but keep parsing; `truncated` is set only when something was cut | +| Correctness (medium) | A diff tab kept a stale status when its file left the list: an added file that got committed showed entirely green instead of "No differences"; a restored deleted file stayed "deleted" | A file missing from the list is re-diffed as a plain modification | +| Correctness (low) | `git rm --cached` listed the same path twice (D and ?); submodules were listed as `M +1 −1`; an orphan branch with a staged index showed nothing; EOL-only changes read "No differences" while the list said `+1 −1` | Merged into one `M` entry; mode `160000` skipped; the empty tree is the diff base of an unborn branch; "Only line endings or the final newline changed" | +| Performance (medium) | `/api/git/changes` ran ~10 git processes per call with no coalescing (4 concurrent callers: 650 ms on 100k files) and the client fired a request on every status push | One in-flight run per scope on the server; the client keeps one request in flight and re-runs once afterwards | +| Performance (low) | Every reload of a diff tab refetched the unchanged old blob; ArrowDown rebuilt 1,000 rows | The old side is cached per tab by rev and path; selection moves in place | + +Measured and left alone: a 100k-line file with every other line changed builds 200k rows in +2.3 ms and renders a window in 0.6 ms; one change at line 99,990 costs 9.5 ms including lexing +both sides; a diff tab of an 8 MB file holds 11.6 MB. + diff --git a/README.md b/README.md index 5ac383f..6e2b2b8 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,11 @@ signed build provenance; see [how to verify](https://codeviewer.21no.de/#verify) - Colored tabs. - Gutter markers vs `HEAD`; click one to peek at the old lines. - Inline blame on the active line. - - Status bar: branch, ahead/behind, merge/rebase state, stash count, and change counts that open - a changed-files list (⌘⇧G). + - Status bar: branch, ahead/behind, merge/rebase state, stash count, and change counts. + - **Review changes (⌘⇧G):** a Changes view in the sidebar lists every file the branch touched + (vs its base branch, working tree included) or only the uncommitted ones (vs `HEAD`), with + `+/−` per file and a one-line summary. Each file opens as a read-only unified diff; ⌥F5 walks + the changes and moves on to the next file. Reviewed files dim until they change again. - Everything updates live as files, the index or refs change. - **Built-in updates:** "Check for Updates…" (command palette, or the Mac app menu), and an update pill in the status bar when a new version is out. Installing verifies the release's @@ -86,7 +89,7 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ | ⌘B · ⌘⇧E | Toggle sidebar · focus explorer | | ⌘W · ⌘⇧[ ⌘⇧] | Close tab · switch tabs | | ⌘= ⌘- ⌘0 | Font size | -| ⌘⇧G · ⌥F5 / ⌥⇧F5 | Changed files · next / previous change in file | +| ⌘⇧G · ⌥F5 / ⌥⇧F5 | Review changes · next / previous change (then next file) | | ⌘-click in terminal | Open `path:line` or URL | | Left ⌥ | Meta in the terminal (right ⌥ still types special characters) | diff --git a/docs/index.html b/docs/index.html index bb82be4..ac3f645 100644 --- a/docs/index.html +++ b/docs/index.html @@ -103,7 +103,7 @@

Native and small

See what HEAD had

-

Click any gutter marker to peek at the lines as they were in the last commit. Inline blame follows your cursor; the status bar shows the branch, how far ahead or behind you are, and every changed file is one ⌘⇧G away.

+

Click any gutter marker to peek at the lines as they were in the last commit. Inline blame follows your cursor; the status bar shows the branch and how far ahead or behind you are. ⌘⇧G opens the Changes view: everything the branch touched since it left main, or just what is uncommitted, each file one click from a read-only diff.

  • Explorer letters: M modified, A added, U untracked, D deleted, C conflict
  • Live as you or an agent edit, stage, commit or switch branch
  • diff --git a/src/client/api.ts b/src/client/api.ts index 6cf6973..c3b4a38 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -67,6 +67,27 @@ function parseEvent(line: string): SearchEvent | null { const q = (p: string) => encodeURIComponent(p); +export interface ChangeFile { + path: string; + oldPath?: string; + /** git's raw status letter; "?" = untracked, "U" = unmerged */ + status: "M" | "A" | "D" | "R" | "C" | "T" | "U" | "X" | "?"; + added: number | null; + removed: number | null; + binary: boolean; +} + +export interface ChangesData { + repo: boolean; + scope: "head" | "branch"; + base: { name: string; oid: string; mergeBase: string; same: boolean } | null; + rev: string | null; + files: ChangeFile[]; + truncated: boolean; + commits: number; + subjects: string[]; +} + export interface UpdateInfo { current: string; latest: string | null; @@ -113,11 +134,13 @@ export const api = { return res.ok ? ((await res.json()) as { type: "file" | "dir" }).type : null; }, gitStatus: async () => (await check(await fetch("/api/git/status"))).json(), - /** HEAD version of a file; null when it isn't in HEAD (or is binary). */ - gitBase: async (path: string): Promise => { - const r = await fetch(`/api/git/base?path=${q(path)}`); + /** HEAD version of a file (or the version at commit `rev`); null when it isn't there (or is binary). */ + gitBase: async (path: string, rev?: string): Promise => { + const r = await fetch(`/api/git/base?path=${q(path)}${rev ? `&rev=${q(rev)}` : ""}`); return r.ok ? r.text() : null; }, + /** Changed files vs HEAD or vs the base branch (see src/server/git.ts Changes). */ + gitChanges: async (scope: "head" | "branch"): Promise => (await check(await fetch(`/api/git/changes?scope=${scope}`))).json(), gitBlame: async (path: string, line: number) => { const r = await fetch(`/api/git/blame?path=${q(path)}&line=${line}`); return r.ok diff --git a/src/client/app.ts b/src/client/app.ts index 971c1c6..c92ae5d 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -12,6 +12,8 @@ import { Updates } from "./updates.ts"; import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, MOD, store } from "./util.ts"; import { isContextMenuOpen } from "./menu.ts"; import { SearchView } from "./search.ts"; +import { ChangesView } from "./changes.ts"; +import type { ChangeFile } from "./api.ts"; import { normSideView, searchKey, type SideView } from "./keys.ts"; type ThemeName = "dark" | "light" | "system"; @@ -71,6 +73,7 @@ class App { private info!: Info; private tree!: FileTree; private search!: SearchView; + private changes!: ChangesView; private sideTabs = new Map(); private sideTools!: HTMLElement; private editor!: Editor; @@ -115,9 +118,12 @@ class App { this.git.onChange(() => { this.tree.onGitChanged(); this.editor.onGitChanged(); + this.changes.setRepo(this.git.repo); + this.changes.onGitChanged(); this.renderStatus(this.lastStatus); }); await api.gitStatus().then((s) => this.git.set(s), () => {}); + this.changes.setRepo(this.git.repo); await this.tree.init(); this.bindKeys(); this.connectEvents(); @@ -127,7 +133,10 @@ class App { const tabs = store.get<{ tabs: string[]; active: string | null }>(`tabs:${this.info.root}`, { tabs: [], active: null }); for (const p of tabs.tabs) await this.editor.open(p, { pin: true, focus: false }); if (tabs.active) await this.editor.open(tabs.active, { focus: false }); - this.editor.onChange = () => store.set(`tabs:${this.info.root}`, this.editor.serialize()); + this.editor.onChange = () => { + store.set(`tabs:${this.info.root}`, this.editor.serialize()); + this.changes.watching = this.editor.tabs.some((t) => t.isDiff); + }; } private build() { @@ -172,6 +181,12 @@ class App { this.tree.onSearchIn = (dir, ignored) => this.search.searchIn(dir, ignored); this.search.onReveal = () => this.showSearch(); this.search.onOpen = (path, line, col, len) => void this.openFile(path, { line, col, len, focus: false }); + this.changes = new ChangesView(); + this.changes.onOpen = (f, pin) => void this.openDiff(f, pin); + this.changes.onData = (d) => { + this.editor.refreshDiffs({ rev: d?.rev ?? null, vs: this.changes.vs }, this.changes.files); + this.editor.setDiffPositions((p) => this.changes.position(p)); + }; const sideTab = (v: SideView, label: string) => { const b = h("button", { class: "side-tab", type: "button", onclick: () => this.setSideView(v) }, label); this.sideTabs.set(v, b); @@ -186,9 +201,10 @@ class App { this.sidebar = h( "aside", { class: "sidebar" }, - h("div", { class: "side-head" }, sideTab("explorer", "Explorer"), sideTab("search", "Search"), h("span", { class: "side-title" }), this.sideTools), + h("div", { class: "side-head" }, sideTab("explorer", "Explorer"), sideTab("search", "Search"), sideTab("changes", "Changes"), h("span", { class: "side-title" }), this.sideTools), this.tree.el, this.search.el, + this.changes.el, ); this.setSideView(this.L.sideView, false); const sideSplit = h("div", { class: "splitter vertical", role: "separator" }); @@ -211,6 +227,7 @@ class App { kb(IS_MAC ? "⌘⇧F" : "Ctrl+Shift+F", "Search in files"), kb("F4", "Next search result"), kb(IS_MAC ? "⌃G" : "Ctrl+G", "Go to line"), + kb(`${MOD}⇧G`, "Review changes"), kb(`${MOD}B`, "Toggle sidebar"), ), h("p", { class: "welcome-hint" }, "Tip: run ", h("code", null, "codeviewer open :"), " in the terminal, or ", h("kbd", null, IS_MAC ? "⌘" : "Ctrl"), "-click a path."), @@ -236,6 +253,7 @@ class App { } return false; }; + this.editor.onDiffStep = (dir) => this.changes.step(dir); this.editor.onReveal = (p) => { if (!L.sidebar) this.toggleSidebar(); void this.tree.reveal(p); @@ -349,15 +367,16 @@ class App { c("tree.collapse", "Collapse All Folders", () => this.tree.collapseAll()), c("tree.clear", "Clear Change Markers", () => this.tree.clearChanges()), c("tree.focus", "Focus Explorer", () => this.tree.focus(), `${MOD}⇧E`), - c("git.changes", "Git: Show Changed Files", () => this.showChanges(), `${MOD}⇧G`), + c("git.changes", "Git: Review Changes", () => this.showChanges(), `${MOD}⇧G`), + c("git.scope", "Git: Compare With Base Branch / HEAD", () => this.changes.toggleScope()), c("app.update", "Check for Updates…", () => void this.updates.checkNow()), c("app.autoUpdate", "Toggle Automatic Update Checks", () => { const auto = !(store.get<{ auto?: boolean }>("updates", {}).auto ?? true); store.set("updates", { auto }); this.notify(`Automatic update checks ${auto ? "on" : "off"} (from the next launch)`); }), - c("git.next", "Git: Next Change in File", () => this.editor.nextChange(1), "⌥F5"), - c("git.prev", "Git: Previous Change in File", () => this.editor.nextChange(-1), "⌥⇧F5"), + c("git.next", "Git: Next Change", () => this.editor.nextChange(1), "⌥F5"), + c("git.prev", "Git: Previous Change", () => this.editor.nextChange(-1), "⌥⇧F5"), ]; } @@ -390,10 +409,19 @@ class App { this.notify(`Not found in ${this.info.name}: ${p}`); } + /** ⌘⇧G: the Changes view, focused on the list. */ private showChanges() { if (!this.git.repo) return this.notify("Not a git repository"); - const items = this.git.changedFiles().map(({ path, git }) => ({ path, badge: git.letter, cls: `git-${git.letter}`, detail: git.label })); - this.palette.openList(items, `${items.length} changed file${items.length === 1 ? "" : "s"}: type to filter`); + if (!this.L.sidebar) this.toggleSidebar(true); + this.setSideView("changes"); + this.changes.focus(); + } + + private async openDiff(f: ChangeFile, pin: boolean) { + const d = this.changes.data; + await this.editor.openDiff(f, { rev: d?.rev ?? null, vs: this.changes.vs }, { pin, focus: false }); + this.changes.setCurrent(f.path); + this.editor.setDiffPositions((p) => this.changes.position(p)); } private trackRecent(p: string) { @@ -413,7 +441,10 @@ class App { this.L.sideView = v; this.tree.el.hidden = v !== "explorer"; this.search.el.hidden = v !== "search"; + this.changes.el.hidden = v !== "changes"; if (v === "search") this.search.onShown(); + if (v === "changes") this.changes.onShown(); + else this.changes.onHidden(); this.sideTools.hidden = v !== "explorer"; for (const [k, b] of this.sideTabs) { b.classList.toggle("active", k === v); @@ -604,7 +635,9 @@ class App { if (e.shiftKey) this.newTerminal(); else if (this.L.panel && !inTerm) this.terms.focus(); else this.togglePanel(); - } else if (mod && e.shiftKey && k === "p") this.palette.open(">"); + } else if (mod && e.shiftKey && k === "g") this.showChanges(); + else if (e.altKey && e.key === "F5" && !inTerm) this.editor.nextChange(e.shiftKey ? -1 : 1); + else if (mod && e.shiftKey && k === "p") this.palette.open(">"); else if (mod && !e.shiftKey && k === "p") this.palette.open(); else if (mod && e.shiftKey && k === "e") { if (!this.L.sidebar) this.toggleSidebar(true); diff --git a/src/client/changes.ts b/src/client/changes.ts new file mode 100644 index 0000000..a271836 --- /dev/null +++ b/src/client/changes.ts @@ -0,0 +1,335 @@ +import { api, type ChangeFile, type ChangesData } from "./api.ts"; +import type { Letter } from "./git.ts"; +import { basename, dirname, esc, h, showHidden } from "./util.ts"; + +export type Scope = "head" | "branch"; + +/** Explorer vocabulary for git's raw status letters: untracked → U, unmerged → C, copied → R, type change → M. */ +export function letterOf(status: ChangeFile["status"]): Letter { + switch (status) { + case "?": return "U"; + case "U": case "X": return "C"; + case "C": return "R"; + case "A": case "D": case "R": case "M": return status; + default: return "M"; + } +} + +const LABEL: Record = { M: "Modified", A: "Added", U: "Untracked", D: "Deleted", R: "Renamed", C: "Conflict", I: "Ignored" }; +export function labelOf(f: ChangeFile): string { + if (f.status === "T") return "Type changed"; + if (f.status === "C") return "Copied"; + return LABEL[letterOf(f.status)]; +} + +/** Surprises first: conflicts, deletions, new files, renames, then edits; ties by path. */ +const ORDER: Record = { C: 0, D: 1, U: 2, A: 2, R: 3, M: 4, I: 5 }; +export function sortChanges(files: readonly ChangeFile[]): ChangeFile[] { + return [...files].sort((a, b) => ORDER[letterOf(a.status)] - ORDER[letterOf(b.status)] || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +export function summarize(files: readonly ChangeFile[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const f of files) { + added += f.added ?? 0; + removed += f.removed ?? 0; + } + return { added, removed }; +} + +/** What "reviewed" was reviewed against: any later edit to the file un-dims its row. */ +const seenKey = (f: ChangeFile) => `${f.status}:${f.added}:${f.removed}:${f.oldPath ?? ""}`; + +const safe = (s: string) => esc(showHidden(s)); +const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + +/** + * Sidebar view listing the files changed on the branch (vs the merge-base with the base branch, + * working tree included) or only the uncommitted ones (vs HEAD), with a one-line summary. + */ +export class ChangesView { + readonly el: HTMLElement; + onOpen: (file: ChangeFile, pin: boolean) => void = () => {}; + /** Fresh data arrived (or the repo went away): diff tabs follow the comparison base. */ + onData: (d: ChangesData | null) => void = () => {}; + data: ChangesData | null = null; + files: ChangeFile[] = []; + private scope: Scope = "branch"; + private repo = false; + private visible = false; + private stale = true; + private loading = false; + private again = false; + private error = false; + private gen = 0; + private timer = 0; + private debounce: number; + private sel = -1; + private current: string | null = null; + /** Diff tabs are open: keep refreshing while the view is hidden so they follow the base. */ + watching = false; + private seen = new Map(); + private scopeEl: HTMLElement; + private summary: HTMLElement; + private list: HTMLElement; + + constructor(opts: { debounce?: number } = {}) { + this.debounce = opts.debounce ?? 150; + this.scopeEl = h("div", { class: "ch-scope", role: "group", "aria-label": "Comparison", hidden: true }); + this.summary = h("div", { class: "ch-summary-wrap", hidden: true }); + this.list = h("div", { class: "ch-list", role: "listbox", tabindex: "0", "aria-label": "Changed files" }); + this.el = h("div", { class: "changes-view" }, this.scopeEl, this.summary, this.list); + this.scopeEl.addEventListener("click", (e) => { + const b = (e.target as HTMLElement).closest(".ch-seg"); + if (b?.dataset.scope) this.setScope(b.dataset.scope as Scope); + }); + this.list.addEventListener("click", (e) => { + const row = (e.target as HTMLElement).closest(".ch-row"); + if (!row) return; + this.sel = Number(row.dataset.i); + this.accept(false); + }); + this.list.addEventListener("dblclick", (e) => { + const row = (e.target as HTMLElement).closest(".ch-row"); + if (!row) return; + this.sel = Number(row.dataset.i); + this.accept(true); + }); + this.list.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") this.move(e.key === "ArrowDown" ? 1 : -1); + else if (e.key === "Enter") this.accept(!!e.shiftKey); + else if (e.key === "Home" || e.key === "End") this.select(e.key === "Home" ? 0 : this.files.length - 1); + else return; + e.preventDefault(); + }); + this.render(); + } + + get scopeName(): Scope { + return this.scope; + } + + /** Human label of what the list is compared with ("vs main", "vs HEAD"). */ + get vs(): string { + const d = this.data; + return d?.scope === "branch" && d.base ? `vs ${d.base.name}` : "vs HEAD"; + } + + setRepo(repo: boolean) { + if (repo === this.repo) return; + this.repo = repo; + if (!repo) { + this.gen++; + this.data = null; + this.files = []; + this.loading = false; + this.onData(null); + } else this.stale = true; + this.render(); + if (repo && this.visible) void this.refresh(); + } + + setScope(scope: Scope) { + if (scope === this.scope) return; + this.scope = scope; + this.sel = -1; + void this.refresh(); + } + + /** Status, index or refs changed: refresh now when the list is in use, else on the next show. */ + onGitChanged() { + if (!this.repo) return; + this.stale = true; + if (this.visible || this.watching) this.schedule(); + } + + onShown() { + this.visible = true; + if (this.repo && this.stale) void this.refresh(); + } + + onHidden() { + this.visible = false; + } + + private schedule() { + clearTimeout(this.timer); + if (this.debounce <= 0) return void this.refresh(); + this.timer = window.setTimeout(() => void this.refresh(), this.debounce); + } + + async refresh() { + clearTimeout(this.timer); + if (!this.repo) return; + if (this.loading) { + this.again = true; // one request at a time: the newest wish is honoured when it ends + return; + } + const gen = ++this.gen; + this.stale = false; + this.loading = true; + if (!this.data) this.render(); + try { + const d = await api.gitChanges(this.scope); + if (gen !== this.gen) return; + this.error = false; + if (!d.repo) { + this.data = null; + this.files = []; + } else { + this.data = d; + this.files = sortChanges(d.files); + for (const k of [...this.seen.keys()]) if (!this.files.some((f) => f.path === k)) this.seen.delete(k); + } + } catch { + if (gen !== this.gen) return; + this.error = true; + } finally { + if (gen === this.gen) { + this.loading = false; + this.render(); + this.onData(this.data); + if (this.again) { + this.again = false; + void this.refresh(); + } + } + } + } + + /** The file whose diff is open (highlighted and marked as reviewed). */ + setCurrent(path: string | null) { + this.current = path; + const f = path === null ? undefined : this.files.find((x) => x.path === path); + if (f) this.seen.set(f.path, seenKey(f)); + this.render(); + } + + /** 1-based position of a file in the list and the list length; null when it is not listed. */ + position(path: string | null = this.current): { i: number; n: number } | null { + const i = this.files.findIndex((f) => f.path === path); + return i < 0 ? null : { i: i + 1, n: this.files.length }; + } + + /** Switches between the two comparisons when a base branch is known. */ + toggleScope() { + if (!this.data?.base || this.data.base.same) return; + this.setScope(this.scope === "branch" ? "head" : "branch"); + } + + /** Opens the file after/before the current one (wrapping); null when the list is empty. */ + step(dir: 1 | -1): ChangeFile | null { + const n = this.files.length; + if (!n) return null; + const at = this.files.findIndex((f) => f.path === this.current); + const next = at < 0 ? (dir === 1 ? 0 : n - 1) : (at + dir + n) % n; + this.sel = next; + const f = this.files[next]!; + this.onOpen(f, false); + return f; + } + + focus() { + if (this.sel < 0) this.sel = Math.max(0, this.files.findIndex((f) => f.path === this.current)); + this.render(); + this.list.focus(); + } + + private move(dir: 1 | -1) { + if (!this.files.length) return; + this.select(this.sel < 0 ? (dir === 1 ? 0 : this.files.length - 1) : Math.max(0, Math.min(this.files.length - 1, this.sel + dir))); + } + + private select(i: number) { + if (!this.files.length) return; + this.sel = Math.max(0, Math.min(this.files.length - 1, i)); + // Move the selection on the rows in place; a full rebuild is for data changes. + const rows = this.list.querySelectorAll(".ch-row"); + if (rows.length === this.files.length) { + rows.forEach((r, k) => { + r.classList.toggle("sel", k === this.sel); + r.setAttribute("aria-selected", String(k === this.sel)); + }); + } else this.render(); + this.list.querySelector(".ch-row.sel")?.scrollIntoView?.({ block: "nearest" }); + } + + private accept(pin: boolean) { + const f = this.files[this.sel]; + if (f) this.onOpen(f, pin); + } + + private render() { + const d = this.data; + // Scope control: only when there is a base branch and HEAD has commits on top of it. + const switchable = !!d?.base && !d.base.same; + this.scopeEl.hidden = !switchable; + if (switchable) { + const seg = (scope: Scope, label: string) => + ``; + this.scopeEl.innerHTML = seg("head", "Uncommitted") + seg("branch", `vs ${safe(d!.base!.name)}`); + } + if (!this.repo) { + this.summary.hidden = true; + this.list.innerHTML = `
    Not a git repository.
    `; + return; + } + if (this.error) { + this.summary.hidden = true; + this.list.innerHTML = `
    Could not read the changes.
    `; + return; + } + if (!d) { + this.summary.hidden = true; + this.list.innerHTML = `
    ${this.loading ? "Reading changes…" : ""}
    `; + return; + } + const files = this.files; + const { added, removed } = summarize(files); + this.summary.hidden = false; + const commits = d.scope === "branch" && d.commits + ? `·${plural(d.commits, "commit", "commits")}` + : ""; + const p = added + removed ? Math.round((added / (added + removed)) * 100) : 50; + this.summary.innerHTML = + `
    ${files.length} ${files.length === 1 ? "file" : "files"}` + + `·+${added.toLocaleString()} −${removed.toLocaleString()}${commits}` + + `${safe(this.vs)}
    ` + + `
    `; + if (!files.length) { + const msg = d.scope === "branch" && d.base + ? d.commits ? `No changes ${safe(this.vs)}.` : `Branch is up to date with ${safe(d.base.name)}.` + : "No uncommitted changes."; + this.list.innerHTML = `
    ${msg}
    `; + return; + } + let html = ""; + files.forEach((f, i) => { + const letter = letterOf(f.status); + const cls = ["ch-row", `git-${letter}`]; + if (i === this.sel) cls.push("sel"); + if (f.path === this.current) cls.push("cur"); + if (this.seen.get(f.path) === seenKey(f)) cls.push("seen"); + if (letter === "D") cls.push("deleted"); + let name = safe(basename(f.path)); + let dir = safe(dirname(f.path)); + if (f.oldPath !== undefined) { + const ob = basename(f.oldPath); + const od = dirname(f.oldPath); + if (ob !== basename(f.path)) name = `${safe(ob)}→${name}`; + if (od !== dirname(f.path)) dir = `${safe(od)}→${dir}`; + } + const counts = f.binary + ? `bin` + : f.added === null + ? `—` + : `${f.added ? `+${f.added}` : ""}${f.removed ? `−${f.removed}` : ""}`; + html += + `
    ` + + `${letter}${name}${dir}${counts}
    `; + }); + if (d.truncated) html += `
    Showing the first ${files.length.toLocaleString()} files
    `; + this.list.innerHTML = html; + } +} diff --git a/src/client/diffview.ts b/src/client/diffview.ts new file mode 100644 index 0000000..15b7bd6 --- /dev/null +++ b/src/client/diffview.ts @@ -0,0 +1,360 @@ +import { diffLines, type Hunk } from "./diff.ts"; +import { Highlighter, type Tok } from "./highlight.ts"; +import { icons } from "./icons.ts"; +import { basename, dirname, esc, h, showHidden } from "./util.ts"; +import type { Letter } from "./git.ts"; + +const TOKS: Tok[] = ["k", "s", "c", "n", "f", "t", "p", "o", "v", "m", "h", "a", "x", "b", "i", "l"]; +const TOK_ID = new Map(TOKS.map((t, i) => [t, i + 1])); +const OVERSCAN = 30; +const MAX_COLS = 4000; +/** Unchanged lines kept on each side of a change; longer runs collapse into one expandable row. */ +export const CONTEXT = 3; + +export type DiffRow = + | { k: "ctx"; a: number; b: number } + | { k: "del"; a: number; hk: number } + | { k: "add"; b: number; hk: number } + /** `n` unchanged lines hidden: old lines from `a`, new lines from `b`; `g` indexes the gap. */ + | { k: "gap"; a: number; b: number; n: number; g: number }; + +/** + * Unified rows for `hunks` (old lines [a0,a1) replaced by new lines [b0,b1)) over an old file of + * `aLen` lines and a new one of `bLen`. Unchanged runs show CONTEXT lines next to each change; + * the rest of a run collapses into a "gap" row unless its index is in `open`. + */ +export function buildRows(hunks: readonly Hunk[], aLen: number, bLen: number, open: ReadonlySet, ctx = CONTEXT): DiffRow[] { + const rows: DiffRow[] = []; + let a = 0; + let b = 0; + let g = 0; + const run = (toA: number, first: boolean, last: boolean) => { + const n = toA - a; + const lead = first ? 0 : Math.min(ctx, n); // lines kept after the previous change + const trail = last ? 0 : Math.min(ctx, n - lead); // lines kept before the next change + const hidden = n - lead - trail; + const show = hidden <= 1 || open.has(g); // never hide a single line behind a click + const gi = g; + if (hidden > 0) g++; + for (let i = 0; i < n; i++) { + if (!show && i === lead) { + rows.push({ k: "gap", a, b, n: hidden, g: gi }); + a += hidden; + b += hidden; + i += hidden - 1; + continue; + } + rows.push({ k: "ctx", a: a++, b: b++ }); + } + }; + hunks.forEach((hk, i) => { + run(hk.a0, i === 0, false); + for (let x = hk.a0; x < hk.a1; x++) rows.push({ k: "del", a: x, hk: i }); + for (let y = hk.b0; y < hk.b1; y++) rows.push({ k: "add", b: y, hk: i }); + a = hk.a1; + b = hk.b1; + }); + if (hunks.length) run(aLen, false, true); + void bLen; + return rows; +} + +/** Highlighted HTML for one line, from the lexer's spans (same classes as the code view). */ +export function spansHtml(text: string, spans: readonly [number, number, Tok][]): string { + if (text.length > MAX_COLS) text = text.slice(0, MAX_COLS); + const n = text.length; + if (n === 0) return ""; + const cls = new Uint8Array(n); + for (const [s, e, t] of spans) if (s < n) cls.fill(TOK_ID.get(t)!, s, Math.min(e, n)); + let out = ""; + let s = 0; + for (let p = 1; p <= n; p++) { + if (p === n || cls[p] !== cls[s]) { + const seg = esc(showHidden(text.slice(s, p))); + const t = cls[s] ? TOKS[cls[s]! - 1] : ""; + out += t ? `${seg}` : seg; + s = p; + } + } + return out; +} + +export interface DiffInfo { + path: string; + oldPath?: string; + letter: Letter; + /** "Modified", "Deleted", … */ + label: string; + /** What the old side is: "vs main", "vs HEAD". */ + vs: string; +} + +const splitLines = (t: string) => { + const l = t.split(/\r\n|\r|\n/); + if (l.length > 1 && l[l.length - 1] === "") l.pop(); + return t === "" ? [] : l; +}; + +/** + * Read-only unified diff of one file: two line-number columns, a +/− marker, highlighted text, + * collapsed context with expandable gaps. Virtualized like the code view. + */ +export class DiffView { + readonly el: HTMLElement; + onOpenFile: () => void = () => {}; + onStep: (dir: 1 | -1) => void = () => {}; + private head: HTMLElement; + private pathEl: HTMLElement; + private countsEl: HTMLElement; + private vsEl: HTMLElement; + private posEl: HTMLElement; + private posWrap: HTMLElement; + private badge: HTMLElement; + private scroller: HTMLElement; + private sizer: HTMLElement; + private rowsEl: HTMLElement; + private notice: HTMLElement; + private oldLines: string[] = []; + private newLines: string[] = []; + private hlA: Highlighter; + private hlB: Highlighter; + private hunks: Hunk[] = []; + private rows: DiffRow[] = []; + private open = new Set(); + private lh = 20; + private frame = 0; + private stale = true; + private window = { first: 0, last: -1 }; + private cur = -1; + private flashAt = 0; + private ro: ResizeObserver | null = null; + + constructor( + private info: DiffInfo, + private lang: string, + fontSize: number, + ) { + this.badge = h("span", { class: "gbadge" }); + this.pathEl = h("span", { class: "diff-path" }); + this.countsEl = h("span", { class: "diff-counts" }); + this.vsEl = h("span", { class: "diff-vs" }); + this.posEl = h("span", { class: "diff-n" }); + this.head = h( + "div", + { class: "diff-head" }, + this.badge, + this.pathEl, + this.countsEl, + this.vsEl, + (this.posWrap = h( + "span", + { class: "diff-pos" }, + h("button", { class: "find-nav", title: "Previous changed file (⌥⇧F5 at the first change)", onclick: () => this.onStep(-1) }, "‹"), + this.posEl, + h("button", { class: "find-nav", title: "Next changed file (⌥F5 at the last change)", onclick: () => this.onStep(1) }, "›"), + )), + h("button", { class: "diff-open", type: "button", title: "Open the file in the code view", onclick: () => this.onOpenFile() }, h("span", { html: icons.code }), "Open file"), + ); + this.rowsEl = h("div", { class: "diff-rows" }); + this.sizer = h("div", { class: "diff-sizer" }, this.rowsEl); + this.notice = h("div", { class: "diff-notice", hidden: true }); + this.scroller = h("div", { class: "diff-scroll", tabindex: "0" }, this.notice, this.sizer); + this.el = h("div", { class: "diffview" }, this.head, this.scroller); + this.hlA = new Highlighter(lang, []); + this.hlB = new Highlighter(lang, []); + this.setFontSize(fontSize); + this.setInfo(info); + this.scroller.addEventListener("scroll", () => this.onScroll(), { passive: true }); + this.ro = new ResizeObserver(() => this.schedule()); + this.ro.observe(this.scroller); + this.rowsEl.addEventListener("click", (e) => { + const gap = (e.target as HTMLElement).closest(".dr-gap"); + if (!gap) return; + this.open.add(Number(gap.dataset.g)); + this.rebuild(); + }); + } + + setInfo(info: DiffInfo) { + this.info = info; + this.badge.className = `gbadge git-${info.letter}`; + this.badge.textContent = info.letter; + this.badge.title = info.label; + const name = (p: string) => `${esc(showHidden(dirname(p)))}${dirname(p) ? "/" : ""}${esc(showHidden(basename(p)))}`; + this.pathEl.innerHTML = info.oldPath !== undefined && info.oldPath !== info.path ? `${name(info.oldPath)}→${name(info.path)}` : name(info.path); + this.vsEl.textContent = info.vs; + } + + /** "3/7" in the header; hidden when the file is not in the list. */ + setPosition(pos: { i: number; n: number } | null) { + this.posEl.textContent = pos ? `${pos.i}/${pos.n}` : ""; + this.posWrap.hidden = !pos; + } + + setFontSize(px: number) { + this.el.style.setProperty("--code-size", `${px}px`); + this.lh = Math.round(px * 1.6); + this.el.style.setProperty("--lh", `${this.lh}px`); + this.stale = true; + this.relayout(); + } + + /** + * Sets both sides (null = that side cannot be shown) and re-diffs, keeping the scroll position. + * `note` replaces the rows with a message (binary, too large). + */ + setTexts(oldText: string | null, newText: string | null, note?: string) { + const top = this.scroller.scrollTop; + if (note || oldText === null || newText === null) { + this.oldLines = []; + this.newLines = []; + this.hunks = []; + this.rows = []; + this.showNotice( + note ?? + (oldText === null && newText === null + ? "This change can't be shown." + : `The ${oldText === null ? "previous" : "current"} version can't be shown (binary or too large).`), + ); + } else { + this.oldLines = splitLines(oldText ?? ""); + this.newLines = splitLines(newText ?? ""); + this.hlA = new Highlighter(this.lang, this.oldLines); + this.hlB = new Highlighter(this.lang, this.newLines); + this.hunks = diffLines(this.oldLines, this.newLines); + this.open.clear(); + if (this.hunks.length) { + this.notice.hidden = true; + this.rows = buildRows(this.hunks, this.oldLines.length, this.newLines.length, this.open); + } else { + this.rows = []; + // The line diff ignores CR LF vs LF and a missing final newline; git does not. + this.showNotice(oldText === newText ? "No differences." : "Only line endings or the final newline changed."); + } + } + this.cur = -1; + this.updateCounts(); + this.relayout(); + this.scroller.scrollTop = Math.min(top, Math.max(0, this.rows.length * this.lh - (this.scroller.clientHeight || 0))); + } + + private showNotice(msg: string) { + this.notice.textContent = msg; + this.notice.hidden = false; + } + + get stats(): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const hk of this.hunks) { + added += hk.b1 - hk.b0; + removed += hk.a1 - hk.a0; + } + return { added, removed }; + } + + get hunkCount() { + return this.hunks.length; + } + + private updateCounts() { + const { added, removed } = this.stats; + this.countsEl.innerHTML = `${added ? `+${added.toLocaleString()}` : ""}${removed ? `−${removed.toLocaleString()}` : ""}`; + } + + /** + * Scrolls to the next/previous change. Returns false instead of wrapping when there is no change + * further in that direction (the caller then moves on to the next file). + */ + nextChange(dir: 1 | -1): boolean { + const n = this.hunks.length; + if (!n) return false; + const next = this.cur < 0 ? (dir === 1 ? 0 : n - 1) : this.cur + dir; + if (next < 0 || next >= n) return false; + this.showHunk(next); + return true; + } + + /** Reveals hunk `i` (0-based), opening any gap that hides its neighbourhood. */ + showHunk(i: number) { + this.cur = i; + const row = this.rows.findIndex((r) => (r.k === "del" || r.k === "add") && r.hk === i); + if (row < 0) return; + const top = row * this.lh; + const s = this.scroller; + const ch = s.clientHeight || 800; + if (top < s.scrollTop || top + this.lh > s.scrollTop + ch) s.scrollTop = Math.max(0, top - ch / 3); + this.flashAt = performance.now(); + this.stale = true; + this.schedule(); + } + + focus() { + this.scroller.focus(); + } + + /** Stops observing and cancels a pending frame (the tab was closed or its view replaced). */ + dispose() { + this.ro?.disconnect(); + this.ro = null; + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = 0; + } + + private rebuild() { + const top = this.scroller.scrollTop; + this.rows = buildRows(this.hunks, this.oldLines.length, this.newLines.length, this.open); + this.relayout(); + this.scroller.scrollTop = top; + } + + private relayout() { + const digits = (n: number) => Math.max(2, String(n).length); + this.el.style.setProperty("--dn-a", `${digits(this.oldLines.length)}ch`); + this.el.style.setProperty("--dn-b", `${digits(this.newLines.length)}ch`); + this.sizer.style.height = `${this.rows.length * this.lh + 8}px`; + this.stale = true; + this.schedule(); + } + + schedule() { + if (!this.frame) this.frame = requestAnimationFrame(() => this.render()); + } + + private onScroll() { + const top = this.scroller.scrollTop; + const first = Math.floor(top / this.lh); + const last = Math.ceil((top + (this.scroller.clientHeight || 800)) / this.lh); + if ((first < this.window.first || last > this.window.last) && !this.frame) this.frame = requestAnimationFrame(() => this.render()); + } + + private render() { + this.frame = 0; + const top = this.scroller.scrollTop; + const height = this.scroller.clientHeight || 800; + const first = Math.max(0, Math.floor(top / this.lh) - OVERSCAN); + const last = Math.min(this.rows.length, Math.ceil((top + height) / this.lh) + OVERSCAN); + if (!this.stale && first >= this.window.first && last <= this.window.last) return; + this.stale = false; + this.window = { first, last: first === 0 && last === this.rows.length ? Infinity : last }; + const flash = performance.now() - this.flashAt < 1500; + let html = ""; + for (let i = first; i < last; i++) { + const r = this.rows[i]!; + if (r.k === "gap") { + html += `
    ⋯${r.n.toLocaleString()} unchanged lines
    `; + continue; + } + let cls = `dr dr-${r.k}`; + if ((r.k === "del" || r.k === "add") && r.hk === this.cur) cls += ` cur${flash ? " flash" : ""}`; + const a = r.k === "add" ? "" : String(r.a + 1); + const b = r.k === "del" ? "" : String(r.b + 1); + const text = r.k === "del" ? this.oldLines[r.a]! : this.newLines[r.k === "add" ? r.b : r.b]!; + const spans = r.k === "del" ? this.hlA.line(r.a) : this.hlB.line(r.k === "add" ? r.b : r.b); + const mark = r.k === "del" ? "−" : r.k === "add" ? "+" : " "; + html += `
    ${a}${b}${mark}${spansHtml(text, spans)}
    `; + } + this.rowsEl.style.transform = `translateY(${first * this.lh}px)`; + this.rowsEl.innerHTML = html; + } +} diff --git a/src/client/editor.ts b/src/client/editor.ts index ff66160..793ff6b 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -1,5 +1,7 @@ -import { api } from "./api.ts"; +import { api, type ChangeFile } from "./api.ts"; import { CodeView } from "./codeview.ts"; +import { DiffView, type DiffInfo } from "./diffview.ts"; +import { labelOf, letterOf } from "./changes.ts"; import { languageFor, languageName } from "./highlight.ts"; import { fileIcon, icons } from "./icons.ts"; import { isMarkdown } from "./markdown.ts"; @@ -21,6 +23,12 @@ export interface Tab { /** Rendered Markdown preview; mutually exclusive with `code`. */ md?: MarkdownView; image?: ImageView; + /** Read-only diff of a changed file (a "diff tab"): `file` is the change it shows. */ + diff?: DiffView; + isDiff?: boolean; + file?: ChangeFile; + /** The old side of a diff tab, kept across reloads of the live file (keyed by rev and path). */ + oldBlob?: { key: string; text: string | null }; lang: string; size: number; mtime: number; @@ -58,6 +66,14 @@ export interface StatusInfo { git?: string; } +/** What diff tabs compare the working tree with. */ +export interface DiffContext { + /** Full object id of the old side; null when there is none (unborn branch). */ + rev: string | null; + /** "vs main", "vs HEAD" */ + vs: string; +} + /** Tabs + content area. Dispatches files to the code, image or binary views. */ export class Editor { readonly el: HTMLElement; @@ -79,7 +95,10 @@ export class Editor { }; /** Called whenever the set of tabs or the active tab changes. */ onChange: () => void = () => {}; + /** A diff tab asked for the next/previous changed file. */ + onDiffStep: (dir: 1 | -1) => void = () => {}; git: GitState | null = null; + private diffContext: DiffContext = { rev: null, vs: "vs HEAD" }; constructor(emptyContent: HTMLElement) { this.tabsEl = h("div", { class: "tabs", role: "tablist" }); @@ -94,7 +113,7 @@ export class Editor { } async open(path: string, opts: { pin?: boolean; line?: number; col?: number; len?: number; focus?: boolean } = {}) { - let tab = this.tabs.find((t) => t.path === path); + let tab = this.tabs.find((t) => t.path === path && !t.isDiff); if (!tab) { // A single unpinned "preview" tab is reused while browsing. const preview = !opts.pin ? this.tabs.find((t) => !t.pinned) : undefined; @@ -123,13 +142,115 @@ export class Editor { this.render(); } - private createTab(path: string, pinned: boolean): Tab { + /** + * Opens (or re-shows) the diff tab of a changed file. Like `open`, an unpinned tab is reused + * while stepping through the list; a double-click or Shift+Enter pins it. + */ + async openDiff(file: ChangeFile, ctx: DiffContext, opts: { pin?: boolean; focus?: boolean } = {}) { + this.diffContext = ctx; + let tab = this.tabs.find((t) => t.isDiff && t.path === file.path); + if (!tab) { + const preview = !opts.pin ? this.tabs.find((t) => !t.pinned) : undefined; + tab = this.createTab(file.path, !!opts.pin, true); + tab.file = file; + if (preview) { + const i = this.tabs.indexOf(preview); + this.disposeTab(preview); + this.tabs.splice(i, 1, tab); + } else { + const i = this.active ? this.tabs.indexOf(this.active) + 1 : this.tabs.length; + this.tabs.splice(i, 0, tab); + } + this.activate(tab); + await this.loadDiff(tab); + } else { + if (opts.pin) tab.pinned = true; + const same = tab.gitKey === (ctx.rev ?? "") && tab.file?.status === file.status && tab.file.oldPath === file.oldPath; + tab.file = file; + this.activate(tab); + if (!same) await this.loadDiff(tab); + } + if (opts.focus !== false) tab.diff?.focus(); + this.render(); + } + + /** + * New comparison data: diff tabs follow the base (a commit moved HEAD, the scope changed) and + * pick up their file's new status. Files that left the list keep their tab (it shows no differences). + */ + refreshDiffs(ctx: DiffContext, files: readonly ChangeFile[]) { + this.diffContext = ctx; + for (const t of this.tabs) { + if (!t.isDiff || !t.file) continue; + // A file that left the list now equals the base: diff it as a plain modification (no differences). + const f = files.find((x) => x.path === t.path) ?? { ...t.file, status: "M" as const, oldPath: undefined, added: null, removed: null }; + const moved = f.status !== t.file.status || f.oldPath !== t.file.oldPath; + t.file = f; + if (t.gitKey !== (ctx.rev ?? "") || moved) void this.loadDiff(t); + else t.diff?.setInfo(this.diffInfo(t.file)); + } + } + + /** Shows "n/N" in every diff tab's header. */ + setDiffPositions(pos: (path: string) => { i: number; n: number } | null) { + for (const t of this.tabs) if (t.isDiff) t.diff?.setPosition(pos(t.path)); + } + + private diffInfo(f: ChangeFile): DiffInfo { + return { path: f.path, oldPath: f.oldPath, letter: letterOf(f.status), label: labelOf(f), vs: this.diffContext.vs }; + } + + private async loadDiff(tab: Tab) { + const f = tab.file!; + const ctx = this.diffContext; + const gen = ++tab.gen; + const letter = letterOf(f.status); + if (!tab.diff) { + tab.diff = new DiffView(this.diffInfo(f), tab.lang, this.fontSize); + tab.diff.onStep = (d) => this.onDiffStep(d); + tab.diff.onOpenFile = () => void this.open(tab.path, { pin: true }).then(() => this.active?.code?.nextChange(1)); + tab.el.replaceChildren(tab.diff.el); + } else tab.diff.setInfo(this.diffInfo(f)); + const diff = tab.diff; + tab.gitKey = ctx.rev ?? ""; + tab.kind = "text"; + if (f.binary) diff.setTexts(null, null, "Binary file changed."); + else { + // Untracked and added files have no old side; deleted files no new side. + const wantOld = !!ctx.rev && letter !== "U" && letter !== "A"; + const wantNew = letter !== "D"; + // The old side only changes with the base: reuse it while the live file is reloaded. + const oldKey = `${ctx.rev}:${f.oldPath ?? f.path}`; + const cached = wantOld && tab.oldBlob?.key === oldKey ? tab.oldBlob : null; + const [oldText, nf] = await Promise.all([ + !wantOld ? Promise.resolve("") : cached ? Promise.resolve(cached.text) : api.gitBase(f.oldPath ?? f.path, ctx.rev!), + wantNew ? api.file(f.path).catch(() => null) : Promise.resolve(null), + ]); + if (gen !== tab.gen || tab.diff !== diff) return; + if (wantOld) tab.oldBlob = { key: oldKey, text: oldText }; + if (nf) { + tab.size = nf.size; + tab.mtime = nf.mtime; + tab.truncated = nf.truncated; + tab.encoding = nf.encoding; + } + if (wantOld && oldText === null) diff.setTexts(null, null, "The previous version can't be shown (binary or too large)."); + else if (nf && nf.kind !== "text") diff.setTexts(null, null, "Binary file changed."); + else diff.setTexts(oldText ?? "", nf ? nf.text : ""); // a file gone from disk diffs as deleted + } + tab.deleted = false; + if (tab === this.active) this.status(); + this.render(); + } + + private createTab(path: string, pinned: boolean, isDiff = false): Tab { const el = h("div", { class: "view" }); const tabEl = h("div", { class: "tab", role: "tab", title: path }); const tab: Tab = { path, pinned, kind: "text", el, tabEl, lang: languageFor(path), size: 0, mtime: 0, truncated: false, eol: "LF", encoding: "UTF-8", gen: 0, gitKey: "", gitStale: false, asSource: false, changedOnDisk: false, deleted: false, }; + if (isDiff) tab.isDiff = true; tabEl.addEventListener("mousedown", (e) => { if (e.button === 1) { e.preventDefault(); @@ -144,6 +265,7 @@ export class Editor { } private async load(tab: Tab, reload = false) { + if (tab.isDiff) return this.loadDiff(tab); const gen = ++tab.gen; try { const f = await api.file(tab.path, tab.asSource); @@ -279,6 +401,7 @@ export class Editor { this.body.replaceChildren(tab.el); tab.code?.setVisible(true); tab.code?.schedule(); + tab.diff?.schedule(); if (tab.gitStale) void this.refreshGit(tab); this.render(); this.status(); @@ -324,6 +447,7 @@ export class Editor { private disposeTab(tab: Tab) { tab.code?.dispose(); + tab.diff?.dispose(); tab.md?.dispose(); tab.el.remove(); tab.tabEl.remove(); @@ -388,6 +512,7 @@ export class Editor { for (const t of this.tabs) { t.code?.setFontSize(px); t.md?.setFontSize(px); + t.diff?.setFontSize(px); } } @@ -397,8 +522,14 @@ export class Editor { t?.code?.openFind(); } + /** Next/previous change in the file; in a diff tab, past the last change it moves on to the next file. */ nextChange(dir: 1 | -1) { - this.active?.code?.nextChange(dir); + const t = this.active; + if (t?.diff) { + if (!t.diff.nextChange(dir)) this.onDiffStep(dir); + return; + } + t?.code?.nextChange(dir); } goToLine(line: number) { @@ -411,6 +542,13 @@ export class Editor { const t = this.active; if (!t) return this.onStatus(null); const s: StatusInfo = { path: t.path, kind: t.kind, size: t.size, truncated: t.truncated }; + if (t.diff && t.file) { + s.lang = languageName(t.lang); + s.git = `${labelOf(t.file)} ${this.diffContext.vs}`; + s.diff = t.diff.stats; + s.encoding = t.encoding; + return this.onStatus(s); + } const g = this.git?.repo ? this.git.file(t.path) : null; if (g && g.letter !== "I") s.git = g.label; if (t.code) { @@ -439,12 +577,13 @@ export class Editor { if (t === this.active) cls.push("active"); if (!t.pinned) cls.push("preview"); if (t.deleted) cls.push("deleted"); - const g = this.git?.repo ? this.git.file(t.path) : null; + if (t.isDiff) cls.push("diff"); + const g = t.isDiff && t.file ? { letter: letterOf(t.file.status), label: labelOf(t.file) } : this.git?.repo ? this.git.file(t.path) : null; if (g) cls.push(`git-${g.letter}`); t.tabEl.className = cls.join(" "); - t.tabEl.title = g ? `${t.path} · ${g.label}` : t.path; + t.tabEl.title = g ? `${t.path} · ${g.label}${t.isDiff ? ` · diff ${this.diffContext.vs}` : ""}` : t.path; t.tabEl.setAttribute("aria-selected", String(t === this.active)); - t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(basename(t.path))}`; + t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(basename(t.path))}${t.isDiff ? 'diff' : ""}`; t.tabEl.querySelector(".tab-close")!.addEventListener("click", (e) => { e.stopPropagation(); this.close(t); @@ -463,6 +602,7 @@ export class Editor { ); if (!isLast) this.crumbs.append(h("span", { class: "crumb-sep" }, "›")); }); + if (this.active.isDiff) this.crumbs.append(h("span", { class: "pill" }, `diff ${this.diffContext.vs}`)); if (this.active.truncated) this.crumbs.append(h("span", { class: "pill warn" }, `showing first ${formatBytes(8 * 1024 * 1024)}`)); if (this.active.deleted) this.crumbs.append(h("span", { class: "pill warn" }, "deleted on disk")); } @@ -471,7 +611,8 @@ export class Editor { } serialize() { - return { tabs: this.tabs.filter((t) => t.pinned).map((t) => t.path), active: this.active?.path ?? null }; + // Diff tabs are not restored: the changes they showed are gone by the next session. + return { tabs: this.tabs.filter((t) => t.pinned && !t.isDiff).map((t) => t.path), active: this.active && !this.active.isDiff ? this.active.path : null }; } } diff --git a/src/client/keys.ts b/src/client/keys.ts index 03530bb..e332636 100644 --- a/src/client/keys.ts +++ b/src/client/keys.ts @@ -1,6 +1,6 @@ /** Keyboard routing helpers shared by the app shell (pure, so they can be unit tested). */ -export type SideView = "explorer" | "search"; +export type SideView = "explorer" | "search" | "changes"; export interface KeyLike { key: string; @@ -28,5 +28,5 @@ export function searchKey(e: KeyLike, o: { isMac: boolean; inTerm: boolean }): S /** Normalises a persisted sidebar view; anything unknown falls back to the Explorer. */ export function normSideView(v: unknown): SideView { - return v === "search" ? "search" : "explorer"; + return v === "search" || v === "changes" ? v : "explorer"; } diff --git a/src/client/palette.ts b/src/client/palette.ts index acb8fbf..d434d7a 100644 --- a/src/client/palette.ts +++ b/src/client/palette.ts @@ -105,8 +105,6 @@ export class Palette { recent: string[] = []; commands: Command[] = []; onOpenFile: (path: string, line?: number) => void = () => {}; - /** When set, the palette lists these entries instead of all files (e.g. git changes). */ - private custom: { path: string; badge: string; cls: string; detail: string }[] | null = null; onGoToLine: (line: number) => void = () => {}; constructor() { @@ -154,13 +152,6 @@ export class Palette { await this.loading; } - /** Shows a filterable list of specific files (e.g. changed files), each with a badge. */ - openList(items: { path: string; badge: string; cls: string; detail: string }[], placeholder: string) { - this.custom = items; - this.input.placeholder = placeholder; - this.open(); - } - open(prefix = "") { this.prevFocus = document.activeElement; this.el.hidden = false; @@ -172,8 +163,6 @@ export class Palette { close() { this.el.hidden = true; - this.custom = null; - this.input.placeholder = ""; (this.prevFocus as HTMLElement | null)?.focus?.(); } @@ -184,27 +173,7 @@ export class Palette { private update() { const v = this.input.value; this.items = []; - if (this.custom) { - const q = v.trim(); - const rows = this.custom - .map((it) => ({ it, m: q ? scorePath(q, it.path) : { score: 0, pos: [] } })) - .filter((x): x is { it: (typeof x)["it"]; m: Match } => !!x.m); - if (q) rows.sort((a, b) => b.m.score - a.m.score); - for (const { it, m } of rows) { - const slash = it.path.lastIndexOf("/", it.path.length - 2) + 1; - const base = it.path.slice(slash); - const html = this.row( - fileIcon(base), - `${highlight(base, m.pos.filter((i) => i >= slash).map((i) => i - slash))}`, - `${esc(it.detail)} · ${highlight(it.path.slice(0, Math.max(0, slash - 1)), m.pos.filter((i) => i < slash))}`, - ).replace("
", `${it.badge}`); - this.items.push({ label: it.path, html, run: () => this.onOpenFile(it.path.replace(/\/$/, "")) }); - } - if (!this.items.length) { - this.list.innerHTML = `
${this.custom.length ? "No matching changes" : "No changes: the working tree is clean"}
`; - return; - } - } else if (v.startsWith(">")) { + if (v.startsWith(">")) { const q = v.slice(1).trim(); const scored: { c: Command; m: Match }[] = []; for (const c of this.commands) { @@ -274,8 +243,6 @@ export class Palette { const it = this.items[i]; if (!it) return; this.el.hidden = true; - this.custom = null; - this.input.placeholder = ""; it.run(); } } diff --git a/src/client/styles.css b/src/client/styles.css index 619674e..17bfa46 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -2097,3 +2097,97 @@ li[data-state="done"] .upd-mark { .blame *::selection, .trunc::selection, .trunc *::selection { background: transparent; } + +/* ───────────────────────── changes (review) ───────────────────────── */ +.changes-view { display: flex; flex-direction: column; min-height: 0; height: 100%; } +.changes-view[hidden] { display: none; } +.ch-scope { display: flex; margin: 8px 8px 0; padding: 2px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); } +.ch-scope[hidden] { display: none; } +.ch-seg { + flex: 1; height: 22px; border: 0; border-radius: 4px; background: none; cursor: pointer; + font: 11px var(--font-ui); color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.ch-seg:hover { color: var(--fg); } +.ch-seg[aria-pressed="true"] { background: var(--bg-active); color: var(--accent); } +.ch-summary-wrap { flex: none; border-bottom: 1px solid var(--border); } +.ch-summary-wrap[hidden] { display: none; } +.ch-summary { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; font-size: 12px; color: var(--fg-muted); white-space: nowrap; } +.ch-counts { flex: 1; overflow: hidden; text-overflow: ellipsis; } +.ch-counts b { color: var(--fg); font-weight: 600; } +.ch-counts .git-A, .ch-counts .git-D, .ch-add, .ch-del, .diff-counts { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.ch-sep { margin: 0 6px; color: var(--fg-faint); } +.ch-commits { cursor: help; } +.ch-vs { color: var(--fg-faint); } +.ch-bar { height: 2px; background: linear-gradient(90deg, var(--git-add) var(--p), var(--git-del) var(--p)); } +.ch-bar.empty { background: var(--border); } +.ch-list { flex: 1; overflow: auto; outline: none; padding-bottom: 8px; overscroll-behavior: contain; } +.ch-list:focus-visible .ch-row.sel { box-shadow: inset 2px 0 0 var(--accent); } +.ch-row { + display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px 0 12px; cursor: pointer; + white-space: nowrap; color: var(--fg); -webkit-user-select: none; user-select: none; font-size: 12.5px; +} +.ch-row:hover { background: var(--bg-hover); } +.ch-row.sel, .ch-row.cur { background: var(--bg-active); } +.ch-row.cur .ch-name { color: var(--fg-strong); } +.ch-row.seen:not(.cur) { opacity: 0.55; } +.ch-row .gbadge { margin-left: 0; } +.ch-row.deleted .ch-name { text-decoration: line-through; } +.ch-row.git-C { box-shadow: inset 2px 0 0 var(--git-conflict); } +.ch-name { flex: none; overflow: hidden; text-overflow: ellipsis; max-width: 60%; } +.ch-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--fg-faint); font-size: 11.5px; } +.ch-arrow { margin: 0 4px; color: var(--fg-faint); } +.ch-add, .ch-del, .ch-bin { flex: none; min-width: 4ch; text-align: right; font-size: 11px; } +.ch-add { color: var(--git-add); } +.ch-del { color: var(--git-del); } +.ch-bin { color: var(--fg-faint); font-family: var(--font-mono); } + +/* diff tab */ +.tab-kind { margin-left: 2px; padding: 0 4px; border-radius: 3px; font: 600 9.5px/14px var(--font-ui); letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg-muted); background: var(--bg-active); } +.diffview { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } +.diff-head { + display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 8px 0 12px; flex: none; + background: var(--bg-side); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--fg); white-space: nowrap; +} +.diff-head .gbadge { margin-left: 0; } +.diff-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.diff-dir { color: var(--fg-muted); } +.diff-counts { display: flex; gap: 6px; font-size: 11.5px; } +.diff-vs { color: var(--fg-faint); } +.diff-pos { display: flex; align-items: center; gap: 2px; } +.diff-pos[hidden] { display: none; } +.diff-n { min-width: 3ch; text-align: center; font: 11px var(--font-mono); color: var(--fg-muted); font-variant-numeric: tabular-nums; } +.diff-open { + display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border: 1px solid var(--border-strong); border-radius: 5px; + background: var(--bg-elev); color: var(--fg); font: 11.5px var(--font-ui); cursor: pointer; +} +.diff-open:hover { background: var(--bg-hover); border-color: var(--accent); } +.diff-open svg { width: 13px; height: 13px; } +.diff-scroll { + flex: 1; overflow: auto; outline: none; font-family: var(--font-mono); font-size: var(--code-size, 13px); + font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; tab-size: 4; overscroll-behavior: contain; +} +.diff-sizer { position: relative; min-width: 100%; } +.diff-rows { position: absolute; top: 4px; left: 0; min-width: 100%; will-change: transform; } +.diff-notice { padding: 14px 16px; font: 12.5px var(--font-ui); color: var(--fg-muted); } +.diff-notice[hidden] { display: none; } +.dr { display: flex; height: var(--lh); line-height: var(--lh); white-space: pre; } +.dr .dn { + position: sticky; z-index: 1; flex: none; padding: 0 6px; text-align: right; color: var(--fg-faint); + background: var(--bg-editor); -webkit-user-select: none; user-select: none; font-variant-numeric: tabular-nums; +} +.dr .dn:first-child { left: 0; width: calc(var(--dn-a, 3ch) + 12px); } +.dr .dn:nth-child(2) { left: calc(var(--dn-a, 3ch) + 12px); width: calc(var(--dn-b, 3ch) + 12px); } +.dr .dm { flex: none; width: 1.5ch; text-align: center; color: var(--fg-faint); -webkit-user-select: none; user-select: none; } +.dr .dt { padding-right: 48px; color: var(--fg); } +.dr-add { background: color-mix(in srgb, var(--git-add) 12%, var(--bg-editor)); } +.dr-add .dn { background: color-mix(in srgb, var(--git-add) 20%, var(--bg-editor)); } +.dr-add .dm { color: var(--git-add); } +.dr-del { background: color-mix(in srgb, var(--git-del) 12%, var(--bg-editor)); } +.dr-del .dn { background: color-mix(in srgb, var(--git-del) 20%, var(--bg-editor)); } +.dr-del .dm { color: var(--git-del); } +.dr-gap { cursor: pointer; background: var(--bg-side); color: var(--fg-muted); font-size: 0.92em; } +.dr-gap .dn { background: var(--bg-side); } +.dr-gap .dt { color: var(--fg-muted); font-family: var(--font-ui); } +.dr-gap:hover .dt { color: var(--accent); } +.dr.cur { box-shadow: inset 3px 0 0 var(--accent); } +.dr.flash { animation: flash 1.5s var(--ease) both; } diff --git a/src/server/git.ts b/src/server/git.ts index 77b0691..6eb201a 100644 --- a/src/server/git.ts +++ b/src/server/git.ts @@ -2,6 +2,7 @@ import { watch, type FSWatcher } from "node:fs"; import { HEAVY_DIRS } from "./index.ts"; import { existsSync } from "node:fs"; import path from "node:path"; +import { looksBinary, openRegular } from "./files.ts"; /** * Read-only git integration via the system `git` CLI. @@ -44,6 +45,47 @@ export interface BlameLine { uncommitted: boolean; } +/** One changed file in a Changes listing. `status` is git's raw letter; "?" = untracked. */ +export interface ChangeEntry { + /** root-relative, "/" separated */ + path: string; + /** Previous path of a rename or copy. */ + oldPath?: string; + status: "M" | "A" | "D" | "R" | "C" | "T" | "U" | "X" | "?"; + /** Lines added/removed; null for binary files and files that were not counted. */ + added: number | null; + removed: number | null; + binary: boolean; +} + +export interface Changes { + scope: "head" | "branch"; + /** The base branch comparison, or null when no base branch could be found. */ + base: { name: string; oid: string; mergeBase: string; same: boolean } | null; + /** Commit the working tree is compared with: HEAD, or the merge-base in branch scope. Null on an unborn branch. */ + rev: string | null; + files: ChangeEntry[]; + /** The file list was cut at MAX_CHANGES entries. */ + truncated: boolean; + /** Commits between the merge-base and HEAD (branch scope only), and up to 20 of their subjects. */ + commits: number; + subjects: string[]; +} + +/** Most entries a Changes listing carries (tracked diffs and untracked files each). */ +export const MAX_CHANGES = 1000; +/** Untracked files whose lines are counted (the rest show no counts). */ +const COUNT_UNTRACKED = 200; +const COUNT_MAX_BYTES = 1 << 20; +/** Local branches tried as the base when the remote has no HEAD. */ +const BASE_NAMES = ["main", "master", "develop", "trunk"]; +const OID = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/; + +/** True for a full hex object id (the only form of revision the HTTP layer accepts). */ +export function isOid(s: string): boolean { + return OID.test(s); +} + const ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0", @@ -61,8 +103,17 @@ const HARDEN = [ "-c", "core.attributesFile=/dev/null", "-c", "submodule.recurse=false", "-c", "status.submoduleSummary=false", + // A mailmap file is read whole by log/blame: a hostile config can point it at /dev/zero. + "-c", "mailmap.file=/dev/null", + "-c", "mailmap.blob=", + "-c", "log.mailmap=false", + // Counts must not silently turn into "binary" because a config set the threshold to 1. + "-c", "core.bigFileThreshold=512m", ]; +/** Wall clock for one git run; a hostile config can make git read /dev/zero forever. */ +const RUN_TIMEOUT_MS = 30_000; + /** Empty tree ids (always known to git, even if absent from the object store). */ const EMPTY_TREE: Record = { sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", @@ -86,6 +137,8 @@ export class Git { private prefix: string, private gitDirs: string[], private env: Record, + /** The empty tree of this repository's object format (the diff base of an unborn branch). */ + private emptyTree: string, ) {} static async open(root: string): Promise { @@ -99,8 +152,9 @@ export class Git { if (rel.startsWith("..")) return null; const prefix = rel ? rel.split(path.sep).join("/") + "/" : ""; const commonDir = common ? path.resolve(root, common) : gitDir; - const env = { ...ENV, GIT_ATTR_SOURCE: EMPTY_TREE[format ?? "sha1"] ?? EMPTY_TREE.sha1 }; - return new Git(bin, root, prefix, [...new Set([gitDir, commonDir])], env); + const emptyTree = EMPTY_TREE[format ?? "sha1"] ?? EMPTY_TREE.sha1!; + const env = { ...ENV, GIT_ATTR_SOURCE: emptyTree }; + return new Git(bin, root, prefix, [...new Set([gitDir, commonDir])], env, emptyTree); } private overrides: Promise | null = null; @@ -156,7 +210,7 @@ export class Git { const [st, stash, head] = await Promise.all([ this.git(["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal", "--ignored=traditional", "--ignore-submodules=all", "--", "."]), this.git(["rev-list", "--walk-reflogs", "--count", "refs/stash"]), - this.git(["log", "-1", "--no-show-signature", "--format=%H%x00%s%x00%an%x00%ct"]), + this.git(["log", "-1", "--no-show-signature", "--no-mailmap", "--format=%H%x00%s%x00%an%x00%ct"]), ]); const s: GitStatus = { repo: true, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, @@ -217,12 +271,215 @@ export class Git { return null; } - /** File content at HEAD (raw blob, no filters), or null if the file is not in HEAD. */ - async base(rel: string, maxBytes: number): Promise { - const r = await this.git(["cat-file", "blob", `HEAD:./${rel}`], maxBytes); + /** + * File content at HEAD (raw blob, no filters), or null if the file is not in HEAD. + * `rev` (a full object id) reads the file as of that commit instead. + */ + async base(rel: string, maxBytes: number, rev = "HEAD"): Promise { + if (rev !== "HEAD" && !isOid(rev)) return null; + const r = await this.git(["cat-file", "blob", `${rev}:./${rel}`], maxBytes); return r.code === 0 && !r.truncated ? r.out : null; } + // ───────────────────────────── changes ───────────────────────────── + + private async rev(...args: string[]): Promise { + const r = await this.git(["rev-parse", "-q", "--verify", ...args], 1 << 16); + const s = new TextDecoder().decode(r.out).trim(); + return r.code === 0 && s ? s : null; + } + + /** + * The branch this one is reviewed against: the remote's default branch (origin/HEAD), else + * the first of main/master/develop/trunk that exists and is not the current branch, else the + * upstream. `same` means the merge-base is HEAD itself (nothing committed on top of the base). + */ + async detectBase(headOid: string): Promise { + const dec = (r: { code: number; out: Uint8Array }) => (r.code === 0 ? new TextDecoder().decode(r.out).trim() : ""); + const [branch, originHead, upstream] = await Promise.all([ + this.git(["symbolic-ref", "-q", "--short", "HEAD"], 1 << 16).then(dec), + this.git(["symbolic-ref", "-q", "refs/remotes/origin/HEAD"], 1 << 16).then(dec), + this.git(["rev-parse", "-q", "--symbolic-full-name", "@{upstream}"], 1 << 16).then(dec), + ]); + const refs: string[] = []; + if (originHead.startsWith("refs/remotes/")) refs.push(originHead); + for (const n of BASE_NAMES) if (n !== branch) refs.push(`refs/heads/${n}`); + if (upstream.startsWith("refs/remotes/") && !refs.includes(upstream)) refs.push(upstream); + for (const ref of refs) { + const oid = await this.rev(`${ref}^{commit}`); + if (!oid) continue; + const mergeBase = dec(await this.git(["merge-base", headOid, oid], 1 << 16)); + if (!isOid(mergeBase)) continue; // unrelated histories + return { name: ref.replace(/^refs\/(heads|remotes)\//, ""), oid, mergeBase, same: mergeBase === headOid }; + } + return null; + } + + private inflight: Partial>> = {}; + + /** + * Files changed in the working tree against HEAD ("head") or against the merge-base with the + * base branch ("branch", which therefore includes uncommitted work), plus untracked files. + * Concurrent callers of the same scope share one run. + */ + changes(scope: "head" | "branch"): Promise { + return (this.inflight[scope] ??= this.computeChanges(scope).finally(() => { + delete this.inflight[scope]; + })); + } + + private async computeChanges(scope: "head" | "branch"): Promise { + const headOid = await this.rev("HEAD^{commit}"); + const base = headOid ? await this.detectBase(headOid) : null; + const branch = scope === "branch" && !!base && !base.same; + // An unborn branch (no commits yet) diffs against the empty tree, so staged files show as added. + const rev = branch ? base!.mergeBase : headOid ?? this.emptyTree; + const [tracked, untracked, unmerged] = await Promise.all([this.diffFiles(rev), this.untracked(), this.unmerged()]); + const byPath = new Map(tracked.files.map((e) => [e.path, e])); + for (const u of untracked.files) { + const t = byPath.get(u.path); + // Deleted from the index but present on disk (git rm --cached): one entry, as a modification. + if (t) Object.assign(t, { status: "M", added: null, removed: null, binary: u.binary }); + else byPath.set(u.path, u); + } + for (const p of unmerged) { + const t = byPath.get(p); + if (t) t.status = "U"; + else byPath.set(p, { path: p, status: "U", added: null, removed: null, binary: false }); + } + const files = [...byPath.values()]; + let commits = 0; + let subjects: string[] = []; + if (branch) { + const [n, log] = await Promise.all([ + this.git(["rev-list", "--count", `${base!.mergeBase}..${headOid}`], 1 << 16), + this.git(["log", "-z", "--no-show-signature", "--no-mailmap", "--format=%s", "-n", "20", `${base!.mergeBase}..${headOid}`], 1 << 20), + ]); + if (n.code === 0) commits = Number(new TextDecoder().decode(n.out).trim()) || 0; + if (log.code === 0) subjects = new TextDecoder().decode(log.out).split("\0").map((s) => s.trim()).filter(Boolean); + } + return { scope: branch ? "branch" : "head", base, rev: headOid ? rev : null, files, truncated: tracked.truncated || untracked.truncated, commits, subjects }; + } + + /** + * `git diff --raw --numstat` of the working tree against `rev`, restricted to the viewed folder. + * Hostile configs are neutralized: no order file (read whole), paths never cwd-relative. + */ + private async diffFiles(rev: string): Promise<{ files: ChangeEntry[]; truncated: boolean }> { + const r = await this.git([ + "diff", "--raw", "--numstat", "-z", "-M", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "-O/dev/null", rev, "--", ".", + ]); + if (r.code !== 0) return { files: [], truncated: false }; + const recs = new TextDecoder().decode(r.out).split("\0"); + const byPath = new Map(); + let truncated = false; + const strip = (p: string) => (p.startsWith(this.prefix) ? p.slice(this.prefix.length) : null); + for (let i = 0; i < recs.length; i++) { + const rec = recs[i]!; + if (!rec) continue; + if (rec[0] === ":") { + // raw: ":srcmode dstmode sha sha STATUS[score]" then the path record(s) + const parts = rec.slice(1).split(" "); + const st = parts[4]?.[0] ?? "M"; + const two = st === "R" || st === "C"; + const oldP = two ? recs[++i] ?? "" : ""; + const newP = recs[++i] ?? ""; + if (parts[0] === "160000" || parts[1] === "160000") continue; // submodules: nothing to diff here + const rel = strip(newP); + if (rel === null || !rel) continue; + if (byPath.size >= MAX_CHANGES) { + truncated = true; + continue; // keep consuming: the numstat records of kept entries follow + } + const status = (/^[MADRCTUX]$/.test(st) ? st : "M") as ChangeEntry["status"]; + const e: ChangeEntry = { path: rel, status, added: null, removed: null, binary: false }; + if (two) { + const o = strip(oldP); + // A rename from outside the viewed folder: show it as new, never a path above the root. + if (o) e.oldPath = o; + else e.status = "A"; + } + byPath.set(rel, e); + continue; + } + const m = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(rec); + if (!m) continue; + let p = m[3]!; + if (!p) { + i++; // rename: old path, then new path + p = recs[++i] ?? ""; + } + const rel = strip(p); + const e = rel ? byPath.get(rel) : undefined; + if (!e) continue; + if (m[1] === "-") e.binary = true; + else { + e.added = Number(m[1]); + e.removed = Number(m[2]); + } + } + return { files: [...byPath.values()], truncated }; + } + + /** Paths with unresolved merge conflicts (root-relative): `git diff ` never reports them. */ + private async unmerged(): Promise { + const r = await this.git(["ls-files", "-z", "--unmerged", "--", "."], 1 << 22); + if (r.code !== 0) return []; + const out = new Set(); + for (const rec of new TextDecoder().decode(r.out).split("\0")) { + const tab = rec.indexOf("\t"); + if (tab > 0) out.add(rec.slice(tab + 1)); + } + return [...out]; + } + + /** Untracked, not ignored files under the root, with line counts for the first few text files. */ + private async untracked(): Promise<{ files: ChangeEntry[]; truncated: boolean }> { + const r = await this.git(["ls-files", "-z", "--others", "--exclude-standard", "--", "."]); + if (r.code !== 0) return { files: [], truncated: false }; + const all = new TextDecoder() + .decode(r.out) + .split("\0") + .filter((f) => f && !f.split("/").some((seg) => HEAVY_DIRS.has(seg))); + const paths = all.slice(0, MAX_CHANGES); + const files: ChangeEntry[] = paths.map((p) => ({ path: p, status: "?", added: null, removed: null, binary: false })); + // A few files at a time through one 64 KiB buffer each: never 200 MiB of buffers per request. + const queue = files.slice(0, COUNT_UNTRACKED); + await Promise.all(Array.from({ length: 8 }, async () => { + for (let e = queue.shift(); e; e = queue.shift()) await this.countLines(e); + })); + return { files, truncated: all.length > MAX_CHANGES }; + } + + private async countLines(e: ChangeEntry) { + try { + const { fh, st } = await openRegular(path.join(this.root, ...e.path.split("/"))); + try { + if (st.size > COUNT_MAX_BYTES) return; + const buf = new Uint8Array(64 * 1024); + let n = 0; + let last = 10; + let total = 0; + for (;;) { + const { bytesRead } = await fh.read(buf, 0, buf.length, total); + if (!bytesRead) break; + if (total === 0 && looksBinary(buf.subarray(0, bytesRead))) { + e.binary = true; + return; + } + for (let i = 0; i < bytesRead; i++) if (buf[i] === 10) n++; + last = buf[bytesRead - 1]!; + total += bytesRead; + } + if (total && last !== 10) n++; + e.added = n; + e.removed = 0; + } finally { + await fh.close(); + } + } catch {} + } + async blame(rel: string, line: number): Promise { const r = await this.git(["blame", "--porcelain", "--no-textconv", "-L", `${line},${line}`, "--", rel], 1 << 20); if (r.code !== 0) return null; @@ -308,6 +565,10 @@ async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, const chunks: Uint8Array[] = []; let size = 0; let truncated = false; + const timer = setTimeout(() => { + truncated = true; + p.kill(); + }, RUN_TIMEOUT_MS); const reader = (p.stdout as ReadableStream).getReader(); for (let r = await reader.read(); !r.done; r = await reader.read()) { if (size + r.value.length > maxBytes) { @@ -319,6 +580,7 @@ async function run(bin: string, cwd: string, args: string[], maxBytes = MAX_OUT, size += r.value.length; } const code = await p.exited; + clearTimeout(timer); const out = new Uint8Array(size); let o = 0; for (const c of chunks) { diff --git a/src/server/http.ts b/src/server/http.ts index 9b1a794..5b764c6 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -8,7 +8,7 @@ import { watchTree } from "./watch.ts"; import { PtyManager, type PtySocketData } from "./pty.ts"; import { loadAssets, FONTS, type Asset } from "./assets.ts"; import { Settings } from "./settings.ts"; -import { Git } from "./git.ts"; +import { Git, isOid } from "./git.ts"; import { Updater } from "./update.ts"; import { Candidates, compileQuery, globsOk, createWorkerScanner, type SearchEvent } from "./search.ts"; @@ -214,6 +214,9 @@ export function startServer(opts: ServeOptions): { server: Server; u }; const relParam = (url: URL) => url.searchParams.get("path") ?? ""; + /** A well-formed root-relative path (for blobs read from history, which need not exist on disk). */ + const relOk = (rel: string) => + !!rel && !rel.includes("\0") && !path.isAbsolute(rel) && !/^[a-zA-Z]:/.test(rel) && !rel.split(/[\\/]+/).some((p) => p === ".."); const server = Bun.serve({ port: opts.port, @@ -358,12 +361,24 @@ export function startServer(opts: ServeOptions): { server: Server; u return json(g ? await g.status() : { repo: false }); } + case "/api/git/changes": { + // Changed files vs HEAD, or vs the merge-base with the base branch (default). + const g = await gitReady; + if (!g) return json({ repo: false }); + return json({ repo: true, ...(await g.changes(url.searchParams.get("scope") === "head" ? "head" : "branch")) }); + } + case "/api/git/base": { // HEAD version of a file, for gutter diffs. 404 = not tracked at HEAD. + // `rev` (a full object id from /api/git/changes) reads the file as of that commit instead; + // the file need not exist in the working tree then (deleted or renamed files). const g = await gitReady; const rel = relParam(url); - if (!g || !(await resolveSafe(opts.root, rel))) return fail(404, "not tracked"); - const blob = await g.base(rel, MAX_TEXT_BYTES); + const rev = url.searchParams.get("rev"); + if (rev !== null && !isOid(rev)) return fail(400, "bad rev"); + const ok = rev === null ? !!(await resolveSafe(opts.root, rel)) : relOk(rel); + if (!g || !ok) return fail(404, "not tracked"); + const blob = await g.base(rel, MAX_TEXT_BYTES, rev ?? "HEAD"); if (!blob) return fail(404, "not tracked"); if (looksBinary(blob)) return fail(415, "binary"); return new Response(blob as BodyInit, { diff --git a/test/client.changes.test.ts b/test/client.changes.test.ts new file mode 100644 index 0000000..a2b9c66 --- /dev/null +++ b/test/client.changes.test.ts @@ -0,0 +1,280 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; +import type { ChangeFile, ChangesData } from "../src/client/api.ts"; + +let C: typeof import("../src/client/changes.ts"); +beforeAll(async () => { + installDom(); + C = await import("../src/client/changes.ts"); +}); +afterAll(() => uninstallDom()); + +const f = (path: string, status: ChangeFile["status"], added: number | null = 1, removed: number | null = 0, extra: Partial = {}): ChangeFile => ({ + path, status, added, removed, binary: false, ...extra, +}); +const data = (files: ChangeFile[], o: Partial = {}): ChangesData => ({ + repo: true, scope: "branch", base: { name: "main", oid: "b".repeat(40), mergeBase: "c".repeat(40), same: false }, rev: "c".repeat(40), + files, truncated: false, commits: 2, subjects: ["feat: one", "fix: two"], ...o, +}); +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("pure helpers", () => { + test("letterOf maps git's letters to the Explorer vocabulary", () => { + expect(C.letterOf("?")).toBe("U"); + expect(C.letterOf("U")).toBe("C"); + expect(C.letterOf("X")).toBe("C"); + expect(C.letterOf("C")).toBe("R"); + expect(C.letterOf("T")).toBe("M"); + for (const s of ["M", "A", "D", "R"] as const) expect(C.letterOf(s)).toBe(s); + }); + test("labelOf", () => { + expect(C.labelOf(f("a", "T"))).toBe("Type changed"); + expect(C.labelOf(f("a", "C"))).toBe("Copied"); + expect(C.labelOf(f("a", "?"))).toBe("Untracked"); + expect(C.labelOf(f("a", "U"))).toBe("Conflict"); + }); + test("sortChanges: conflicts, deletions, new files, renames, edits; then by path", () => { + const out = C.sortChanges([f("z.ts", "M"), f("b.ts", "?"), f("a.ts", "M"), f("d.ts", "D"), f("r.ts", "R"), f("c.ts", "U"), f("n.ts", "A")]); + expect(out.map((x) => x.path)).toEqual(["c.ts", "d.ts", "b.ts", "n.ts", "r.ts", "a.ts", "z.ts"]); + }); + test("summarize ignores uncounted files", () => { + expect(C.summarize([f("a", "M", 3, 1), f("b", "?", null, null), f("c", "D", 0, 7)])).toEqual({ added: 3, removed: 8 }); + }); +}); + +describe("ChangesView", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + + function make(handler: (url: string) => ChangesData | number) { + const m = mockFetch((url) => { + const r = handler(url); + return typeof r === "number" ? new Response("{}", { status: r }) : Response.json(r); + }); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + const el = v.el as unknown as FakeElement; + const q = (sel: string) => el.querySelector(sel)!; + return { v, el, calls: m.calls, list: q(".ch-list"), summary: q(".ch-summary-wrap"), scope: q(".ch-scope") }; + } + + test("not a repository / loading / error states", async () => { + const t = make(() => 500); + expect(t.list.innerHTML).toContain("Not a git repository."); + expect(t.summary.hidden).toBe(true); + t.v.setRepo(true); + expect(t.calls.length).toBe(0); // hidden: nothing fetched yet + t.v.onShown(); + expect(t.calls.length).toBe(1); + expect(t.list.innerHTML).toContain("Reading changes…"); + await tick(); + await tick(); + expect(t.list.innerHTML).toContain("Could not read the changes."); + t.v.setRepo(false); + expect(t.list.innerHTML).toContain("Not a git repository."); + }); + + test("summary, scope control, sorted rows, renames, deletions, binaries, truncation", async () => { + const files = [ + f("src/z.ts", "M", 10, 2), + f("src/a.ts", "D", 0, 5), + f("docs/new.md", "?", 3, 0), + f("img.png", "?", null, null, { binary: true }), + f("lib/renamed.ts", "R", 1, 0, { oldPath: "lib/old.ts" }), + f("moved/same.ts", "R", 0, 0, { oldPath: "lib/same.ts" }), + f("x.lock", "M", null, null), + ]; + const t = make(() => data(files, { truncated: true })); + t.v.setRepo(true); + await t.v.refresh(); + expect(t.calls[0]!.url).toBe("/api/git/changes?scope=branch"); + const s = t.summary.innerHTML; + expect(s).toContain("7 files"); + expect(s).toContain('+14'); + expect(s).toContain('−7'); + expect(s).toContain("2 commits"); + expect(s).toContain('title="feat: one\nfix: two"'); + expect(s).toContain('vs main'); + expect(s).toContain("--p:67%"); + expect(t.scope.hidden).toBe(false); + expect(t.scope.innerHTML).toContain('data-scope="head" aria-pressed="false">Uncommitted<'); + expect(t.scope.innerHTML).toContain('data-scope="branch" aria-pressed="true">vs main<'); + const rows = [...t.list.innerHTML.matchAll(/
]*title="([^"]*)"/g)].map((m) => [m[2], m[3], m[1]!.trim()]); + expect(rows.map((r) => r[1])).toEqual([ + "src/a.ts · Deleted", + "docs/new.md · Untracked", + "img.png · Untracked", + "lib/renamed.ts · Renamed", + "moved/same.ts · Renamed", + "src/z.ts · Modified", + "x.lock · Modified", + ]); + expect(rows[0]![2]).toBe("git-D deleted"); + const html = t.list.innerHTML; + expect(html).toContain('old.ts→renamed.tslib'); + expect(html).toContain('same.tslib→moved'); + expect(html).toContain('bin'); + expect(html).toContain('—'); + expect(html).toContain('+10−2'); + expect(html).toContain('−5'); + expect(html).toContain("Showing the first 7 files"); + expect(t.v.files.map((x) => x.path)[0]).toBe("src/a.ts"); + }); + + test("paths are escaped and control characters made visible", async () => { + const t = make(() => data([f("x‮.ts", "M")])); + t.v.setRepo(true); + await t.v.refresh(); + expect(t.list.innerHTML).not.toContain("x"); + expect(t.list.innerHTML).toContain("<b>x</b>\\u202e.ts"); + }); + + test("empty states depend on the scope and the base", async () => { + let d = data([]); + const t = make(() => d); + t.v.setRepo(true); + await t.v.refresh(); + expect(t.list.innerHTML).toContain("No changes vs main."); + d = data([], { commits: 0, subjects: [] }); + await t.v.refresh(); + expect(t.list.innerHTML).toContain("Branch is up to date with main."); + d = data([], { scope: "head", base: { name: "main", oid: "b".repeat(40), mergeBase: "b".repeat(40), same: true }, commits: 0 }); + await t.v.refresh(); + expect(t.list.innerHTML).toContain("No uncommitted changes."); + expect(t.scope.hidden).toBe(true); // nothing on top of the base: no switch + expect(t.summary.innerHTML).toContain("vs HEAD"); + expect(t.summary.innerHTML).toContain('class="ch-bar empty"'); + d = data([], { scope: "head", base: null, rev: null, commits: 0 }); + await t.v.refresh(); + expect(t.list.innerHTML).toContain("No uncommitted changes."); + }); + + test("keyboard: arrows select, Enter opens (Shift pins), Home/End; step wraps; position and seen", async () => { + const files = [f("b.ts", "M", 2, 1), f("a.ts", "M", 1, 1), f("c.ts", "D", 0, 3)]; + const t = make(() => data(files)); + const opened: [string, boolean][] = []; + t.v.onOpen = (x, pin) => opened.push([x.path, pin]); + t.v.setRepo(true); + await t.v.refresh(); + const key = (k: string, init: Record = {}) => t.list.dispatchEvent(new FakeEvent("keydown", { key: k, ...init })); + key("Enter"); + expect(opened).toEqual([]); // nothing selected yet + key("ArrowDown"); + expect(t.list.innerHTML).toContain('class="ch-row git-D sel deleted" data-i="0"'); + key("Enter"); + expect(opened).toEqual([["c.ts", false]]); + key("ArrowDown"); + key("Enter", { shiftKey: true }); + expect(opened[1]).toEqual(["a.ts", true]); + key("End"); + key("Enter"); + expect(opened[2]).toEqual(["b.ts", false]); + key("ArrowDown"); // clamped at the end + key("Enter"); + expect(opened[3]).toEqual(["b.ts", false]); + key("Home"); + key("Enter"); + expect(opened[4]).toEqual(["c.ts", false]); + + t.v.setCurrent("a.ts"); + expect(t.v.position()).toEqual({ i: 2, n: 3 }); + expect(t.v.position("c.ts")).toEqual({ i: 1, n: 3 }); + expect(t.v.position("nope")).toBeNull(); + expect(t.list.innerHTML).toContain('class="ch-row git-M cur seen" data-i="1"'); + expect(t.v.step(1)?.path).toBe("b.ts"); + expect(opened[5]).toEqual(["b.ts", false]); + t.v.setCurrent("b.ts"); + expect(t.v.step(1)?.path).toBe("c.ts"); // wraps + t.v.setCurrent("c.ts"); + expect(t.v.step(-1)?.path).toBe("b.ts"); + + // A reviewed file that changes again is no longer dimmed. + files[1] = f("a.ts", "M", 5, 1); + await t.v.refresh(); + expect(t.list.innerHTML).toContain('class="ch-row git-M" data-i="1"'); + expect(t.list.innerHTML).toContain('class="ch-row git-M sel seen" data-i="2"'); + }); + + test("scope switch and toggleScope refetch; onGitChanged refreshes only when shown or watched", async () => { + let d = data([f("a.ts", "M")]); + const t = make((url) => (url.includes("scope=head") ? { ...d, scope: "head" as const, commits: 0 } : d)); + const got: (ChangesData | null)[] = []; + t.v.onData = (x) => got.push(x); + t.v.setRepo(true); + await t.v.refresh(); + expect(got.length).toBe(1); + t.v.toggleScope(); + await tick(); + await tick(); + expect(t.calls.at(-1)!.url).toBe("/api/git/changes?scope=head"); + expect(t.v.scopeName).toBe("head"); + expect(t.v.vs).toBe("vs HEAD"); + expect(t.summary.innerHTML).toContain("vs HEAD"); + expect(t.scope.innerHTML).toContain('data-scope="head" aria-pressed="true"'); + t.v.setScope("head"); // no-op + const n = t.calls.length; + t.v.onGitChanged(); // hidden, no diff tabs: deferred + await tick(); + expect(t.calls.length).toBe(n); + t.v.watching = true; + t.v.onGitChanged(); + await tick(); + await tick(); + expect(t.calls.length).toBe(n + 1); + t.v.watching = false; + t.v.onHidden(); + t.v.onGitChanged(); + await tick(); + expect(t.calls.length).toBe(n + 1); + t.v.onShown(); // stale: refreshes on show + await tick(); + await tick(); + expect(t.calls.length).toBe(n + 2); + t.v.onShown(); // fresh: nothing + expect(t.calls.length).toBe(n + 2); + // A base-less reply collapses to the single scope and hides the control. + d = data([f("a.ts", "M")], { base: null, scope: "head", commits: 0 }); + await t.v.refresh(); + expect(t.scope.hidden).toBe(true); + t.v.toggleScope(); // nothing to toggle to + expect(t.calls.at(-1)!.url).toBe("/api/git/changes?scope=head"); + expect(got.at(-1)).toMatchObject({ base: null }); + t.v.setRepo(false); + expect(got.at(-1)).toBeNull(); + }); + + test("one request in flight at a time; a refresh asked meanwhile runs once afterwards", async () => { + let release: (() => void) | null = null; + const m = mockFetch(() => new Promise((r) => (release = () => r(Response.json(data([f("a.ts", "M")])))))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + const first = v.refresh(); + expect(m.calls.length).toBe(1); + void v.refresh(); + void v.refresh(); + v.onGitChanged(); + await tick(); + expect(m.calls.length).toBe(1); // queued, not fired + release!(); + await first; + await tick(); + expect(m.calls.length).toBe(2); // exactly one follow-up + release!(); + await tick(); + await tick(); + expect(m.calls.length).toBe(2); + expect(v.files.length).toBe(1); + }); + + test("a server reply without a repo clears the list", async () => { + const t = make(() => ({ repo: false }) as unknown as ChangesData); + t.v.setRepo(true); + await t.v.refresh(); + expect(t.v.data).toBeNull(); + expect(t.v.files).toEqual([]); + }); +}); diff --git a/test/client.diffview.test.ts b/test/client.diffview.test.ts new file mode 100644 index 0000000..7b27835 --- /dev/null +++ b/test/client.diffview.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { FakeEvent, installDom, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; +import type { Hunk } from "../src/client/diff.ts"; + +let D: typeof import("../src/client/diffview.ts"); +const frames: (() => void)[] = []; +let disconnected = 0; +const g = globalThis as Record; +const saved: Record = {}; + +beforeAll(async () => { + installDom(); + for (const k of ["requestAnimationFrame", "ResizeObserver", "cancelAnimationFrame"]) saved[k] = g[k]; + g.requestAnimationFrame = (fn: () => void) => frames.push(fn); + g.ResizeObserver = class { + observe() {} + disconnect() { + disconnected++; + } + }; + g.cancelAnimationFrame = () => {}; + D = await import("../src/client/diffview.ts"); +}); +afterAll(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete g[k]; + else g[k] = v; + } + uninstallDom(); +}); +const flush = () => { + while (frames.length) frames.shift()!(); +}; + +describe("buildRows", () => { + const hk = (a0: number, a1: number, b0: number, b1: number): Hunk => ({ a0, a1, b0, b1 }); + + test("no hunks: no rows", () => { + expect(D.buildRows([], 10, 10, new Set())).toEqual([]); + }); + + test("context around a change, gaps before and after, expandable by index", () => { + const rows = D.buildRows([hk(10, 11, 10, 12)], 20, 21, new Set()); + expect(rows).toEqual([ + { k: "gap", a: 0, b: 0, n: 7, g: 0 }, + { k: "ctx", a: 7, b: 7 }, + { k: "ctx", a: 8, b: 8 }, + { k: "ctx", a: 9, b: 9 }, + { k: "del", a: 10, hk: 0 }, + { k: "add", b: 10, hk: 0 }, + { k: "add", b: 11, hk: 0 }, + { k: "ctx", a: 11, b: 12 }, + { k: "ctx", a: 12, b: 13 }, + { k: "ctx", a: 13, b: 14 }, + { k: "gap", a: 14, b: 15, n: 6, g: 1 }, + ]); + const open = D.buildRows([hk(10, 11, 10, 12)], 20, 21, new Set([1])); + expect(open.length).toBe(rows.length - 1 + 6); + expect(open.at(-1)).toEqual({ k: "ctx", a: 19, b: 20 }); + expect(open[0]).toEqual({ k: "gap", a: 0, b: 0, n: 7, g: 0 }); + }); + + test("a single hidden line is shown rather than collapsed; two or more collapse", () => { + // 4 lines before the change: 3 shown as context, 1 would be hidden -> shown. + expect(D.buildRows([hk(4, 5, 4, 5)], 5, 5, new Set()).slice(0, 4)).toEqual([ + { k: "ctx", a: 0, b: 0 }, { k: "ctx", a: 1, b: 1 }, { k: "ctx", a: 2, b: 2 }, { k: "ctx", a: 3, b: 3 }, + ]); + expect(D.buildRows([hk(5, 6, 5, 6)], 6, 6, new Set())[0]).toEqual({ k: "gap", a: 0, b: 0, n: 2, g: 0 }); + }); + + test("two changes close together share their context; far apart they get a gap between", () => { + const close = D.buildRows([hk(2, 3, 2, 3), hk(5, 6, 5, 6)], 8, 8, new Set()); + expect(close.filter((r) => r.k === "gap")).toEqual([]); + expect(close.map((r) => r.k)).toEqual(["ctx", "ctx", "del", "add", "ctx", "ctx", "del", "add", "ctx", "ctx"]); + const far = D.buildRows([hk(2, 3, 2, 3), hk(50, 51, 50, 51)], 60, 60, new Set()); + const gaps = far.filter((r) => r.k === "gap"); + expect(gaps).toEqual([ + { k: "gap", a: 6, b: 6, n: 41, g: 0 }, + { k: "gap", a: 54, b: 54, n: 6, g: 1 }, + ]); + }); + + test("pure insertions and deletions, and whole-file changes", () => { + expect(D.buildRows([hk(0, 0, 0, 2)], 0, 2, new Set())).toEqual([{ k: "add", b: 0, hk: 0 }, { k: "add", b: 1, hk: 0 }]); + expect(D.buildRows([hk(0, 2, 0, 0)], 2, 0, new Set())).toEqual([{ k: "del", a: 0, hk: 0 }, { k: "del", a: 1, hk: 0 }]); + const ins = D.buildRows([hk(3, 3, 3, 4)], 6, 7, new Set()); + expect(ins.map((r) => r.k)).toEqual(["ctx", "ctx", "ctx", "add", "ctx", "ctx", "ctx"]); + expect(ins[4]).toEqual({ k: "ctx", a: 3, b: 4 }); + }); +}); + +describe("spansHtml", () => { + test("wraps tokens, escapes HTML and shows hidden characters", () => { + expect(D.spansHtml("let x = ", [[0, 3, "k"], [8, 11, "o"]])).toBe('let x = <a>'); + expect(D.spansHtml("", [])).toBe(""); + expect(D.spansHtml("a‮b", [])).toBe("a\\u202eb"); + expect(D.spansHtml("x".repeat(5000), [[0, 5000, "s"]]).length).toBeLessThan(4100); + }); +}); + +describe("DiffView", () => { + const info = { path: "src/a.ts", letter: "M" as const, label: "Modified", vs: "vs main" }; + const make = (lang = "js") => { + const v = new D.DiffView(info, lang, 13); + const el = v.el as unknown as FakeElement; + return { v, el, rows: el.querySelector(".diff-rows")!, notice: el.querySelector(".diff-notice")!, head: el.querySelector(".diff-head")! }; + }; + + test("header: badge, path, counts, base label, position", () => { + const t = make(); + expect(t.head.querySelector(".gbadge")!.className).toBe("gbadge git-M"); + expect(t.head.querySelector(".gbadge")!.textContent).toBe("M"); + expect(t.head.querySelector(".diff-path")!.innerHTML).toBe('src/a.ts'); + expect(t.head.querySelector(".diff-vs")!.textContent).toBe("vs main"); + t.v.setInfo({ path: "b/new.ts", oldPath: "a/old.ts", letter: "R", label: "Renamed", vs: "vs HEAD" }); + expect(t.head.querySelector(".diff-path")!.innerHTML).toBe('a/old.ts→b/new.ts'); + expect(t.head.querySelector(".gbadge")!.className).toBe("gbadge git-R"); + expect(t.head.querySelector(".diff-vs")!.textContent).toBe("vs HEAD"); + t.v.setPosition({ i: 3, n: 7 }); + expect(t.head.querySelector(".diff-n")!.textContent).toBe("3/7"); + expect(t.head.querySelector(".diff-pos")!.hidden).toBe(false); + t.v.setPosition(null); + expect(t.head.querySelector(".diff-pos")!.hidden).toBe(true); + }); + + test("rows: numbers on both sides, markers, highlighted text, stats, counts", () => { + const t = make(); + t.v.setTexts("const a = 1;\nlet b = 2;\nlet c = 3;\n", "const a = 1;\nlet b = 20;\nlet c = 3;\n"); + expect(t.v.stats).toEqual({ added: 1, removed: 1 }); + expect(t.v.hunkCount).toBe(1); + expect(t.head.querySelector(".diff-counts")!.innerHTML).toBe('+1−1'); + flush(); + const html = t.rows.innerHTML; + expect(html).toContain('
11 '); + expect(html).toContain('
2−let b = 2;
'); + expect(html).toContain('
2+let b = 20;
'); + expect(html).toContain('33'); + expect(t.notice.hidden).toBe(true); + }); + + test("new file, deleted file, identical, binary and unavailable old side", () => { + const t = make("text"); + t.v.setTexts("", "x\ny\n"); + expect(t.v.stats).toEqual({ added: 2, removed: 0 }); + flush(); + expect(t.rows.innerHTML.match(/dr-add/g)!.length).toBe(2); + t.v.setTexts("x\ny\n", ""); + expect(t.v.stats).toEqual({ added: 0, removed: 2 }); + t.v.setTexts("same\n", "same\n"); + expect(t.notice.hidden).toBe(false); + expect(t.notice.textContent).toBe("No differences."); + t.v.setTexts("a\r\nb\n", "a\nb"); + expect(t.notice.textContent).toBe("Only line endings or the final newline changed."); + expect(t.v.stats).toEqual({ added: 0, removed: 0 }); + expect(t.v.stats).toEqual({ added: 0, removed: 0 }); + t.v.setTexts(null, null, "Binary file changed."); + expect(t.notice.textContent).toBe("Binary file changed."); + t.v.setTexts(null, "a\n"); + expect(t.notice.textContent).toBe("The previous version can't be shown (binary or too large)."); + t.v.setTexts("a\n", null); + expect(t.notice.textContent).toBe("The current version can't be shown (binary or too large)."); + }); + + test("gaps collapse and expand on click; nextChange walks hunks and refuses to wrap", () => { + const t = make("text"); + const lines = (n: number, f: (i: number) => string) => Array.from({ length: n }, (_, i) => f(i)).join("\n") + "\n"; + const oldT = lines(40, (i) => `line ${i}`); + const newT = lines(40, (i) => (i === 5 || i === 30 ? `LINE ${i}` : `line ${i}`)); + t.v.setTexts(oldT, newT); + expect(t.v.hunkCount).toBe(2); + flush(); + let html = t.rows.innerHTML; + expect(html).toContain('data-g="0"'); + expect(html).toContain("2 unchanged lines"); // lines 0-1 (3 lines of context before the change at 5) + expect(html).toContain("18 unchanged lines"); // between the two changes + expect(html).toContain("6 unchanged lines"); // after the last change + expect(html).not.toContain('11'); + // Clicking a gap row expands it. + const gap = { closest: () => ({ dataset: { g: "1" } }) }; + t.rows.dispatchEvent(new FakeEvent("click", { target: gap })); + flush(); + html = t.rows.innerHTML; + expect(html).not.toContain("18 unchanged lines"); + expect(html).toContain("2 unchanged lines"); + expect(html).toContain("6 unchanged lines"); + expect(html).toContain('2020'); + + expect(t.v.nextChange(-1)).toBe(true); // from nothing: the last hunk + flush(); + expect(t.rows.innerHTML).toContain('class="dr dr-del cur flash">31'); + expect(t.v.nextChange(1)).toBe(false); // nothing after the last hunk: the caller moves on + expect(t.v.nextChange(-1)).toBe(true); + flush(); + expect(t.rows.innerHTML).toContain('class="dr dr-del cur flash">6'); + expect(t.v.nextChange(-1)).toBe(false); + t.v.setTexts("a\n", "a\n"); + expect(t.v.nextChange(1)).toBe(false); + }); + + test("dispose disconnects the observer", () => { + const t = make(); + const before = disconnected; + t.v.dispose(); + expect(disconnected).toBe(before + 1); + }); + + test("font size sets the row height variable", () => { + const t = make(); + t.v.setFontSize(20); + expect(t.el.style["--lh"]).toBe("32px"); + expect(t.el.style["--code-size"]).toBe("20px"); + }); +}); diff --git a/test/client.palette.test.ts b/test/client.palette.test.ts index ca39847..2d02528 100644 --- a/test/client.palette.test.ts +++ b/test/client.palette.test.ts @@ -283,40 +283,6 @@ describe("Palette", () => { expect(lines).toEqual([120]); }); - test("openList: filterable change list with badges, empty-state messages and placeholder reset", async () => { - const t = await make(); - const opened: string[] = []; - t.p.onOpenFile = (p) => opened.push(p); - t.p.openList( - [ - { path: "src/a.ts", badge: "M", cls: "g-M", detail: "Modified" }, - { path: "new/", badge: "U", cls: "g-U", detail: "Untracked" }, - ], - "Changed files", - ); - expect(t.input.placeholder).toBe("Changed files"); - expect(t.rows().length).toBe(2); - expect(t.list.innerHTML).toContain('M'); - expect(t.list.innerHTML).toContain("Modified · src"); - t.type("new"); - expect(t.rows().length).toBe(1); - t.key("Enter"); - expect(opened).toEqual(["new"]); // trailing slash stripped - expect(t.input.placeholder).toBe(""); - - t.p.openList([{ path: "x.ts", badge: "M", cls: "c", detail: "d" }], "p"); - t.type("zzz"); - expect(t.list.innerHTML).toContain("No matching changes"); - t.p.close(); - t.p.openList([], "p"); - expect(t.list.innerHTML).toContain("No changes: the working tree is clean"); - // closing leaves custom mode - t.p.close(); - t.p.open(); - await t.flush(); - expect(t.list.innerHTML).toContain("a.ts"); - }); - test("empty index shows 'No files'", async () => { const t = await make([]); t.p.open(); diff --git a/test/helpers/fakedom.ts b/test/helpers/fakedom.ts index 7eba168..88f76ab 100644 --- a/test/helpers/fakedom.ts +++ b/test/helpers/fakedom.ts @@ -62,7 +62,15 @@ export class FakeElement extends Target { parent: FakeElement | null = null; children: (FakeElement | FakeText)[] = []; attrs = new Map(); - style: Record = {}; + /** Plain property bag; setProperty is non-enumerable so an untouched style has no keys. */ + style: Record & { setProperty(k: string, v: string): void } = Object.defineProperty({} as Record, "setProperty", { + value(this: Record, k: string, v: string) { + this[k] = v; + }, + enumerable: false, + writable: true, + configurable: true, + }) as Record & { setProperty(k: string, v: string): void }; tagName: string; value = ""; placeholder = ""; diff --git a/test/http.changes.test.ts b/test/http.changes.test.ts new file mode 100644 index 0000000..e31fa90 --- /dev/null +++ b/test/http.changes.test.ts @@ -0,0 +1,104 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { startServer } from "../src/server/http.ts"; + +// Captured at load: other test files may swap globalThis.fetch for mocks. +const fetch = globalThis.fetch.bind(globalThis) as typeof globalThis.fetch; +const token = "D".repeat(43); +const auth = { "x-cv-token": token }; + +let dir = ""; +let plain = ""; +let cfg = ""; +let app: ReturnType; +let app2: ReturnType; +let base = ""; +let base2 = ""; +let mainOid = ""; +const saved: Record = {}; + +const git = (...a: string[]) => { + const r = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", ...a], { cwd: dir, stderr: "pipe", env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" } }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + return r.stdout.toString().trim(); +}; +const get = (b: string, p: string) => fetch(`${b}${p}`, { headers: auth }); + +beforeAll(async () => { + for (const k of ["XDG_CONFIG_HOME", "CODEVIEWER_NO_UPDATE_CHECK"]) saved[k] = process.env[k]; + cfg = await realpath(await mkdtemp(path.join(tmpdir(), "cv-cfg-"))); + process.env.XDG_CONFIG_HOME = cfg; + process.env.CODEVIEWER_NO_UPDATE_CHECK = "1"; + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-httpch-"))); + plain = await realpath(await mkdtemp(path.join(tmpdir(), "cv-httpplain-"))); + await writeFile(path.join(plain, "f.txt"), "f\n"); + await mkdir(path.join(dir, "sub")); + await writeFile(path.join(dir, "a.txt"), "line1\nline2\nline3\n"); + await writeFile(path.join(dir, "gone.txt"), "bye\n"); + git("init", "-q", "-b", "main"); + git("add", "."); + git("commit", "-q", "-m", "init"); + mainOid = git("rev-parse", "HEAD"); + git("checkout", "-qb", "topic"); + await writeFile(path.join(dir, "a.txt"), "line1\nCHANGED\nline3\n"); + git("rm", "-q", "gone.txt"); + git("commit", "-qam", "topic work"); + await writeFile(path.join(dir, "sub", "new.ts"), "export const n = 1;\n"); + app = startServer({ root: dir, port: 0, host: "127.0.0.1", shell: "/bin/sh", token, openExternal: () => {} }); + app2 = startServer({ root: plain, port: 0, host: "127.0.0.1", shell: "/bin/sh", token, openExternal: () => {} }); + base = app.url.split("/#")[0]!; + base2 = app2.url.split("/#")[0]!; +}); +afterAll(async () => { + app.stop(); + app2.stop(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await rm(dir, { recursive: true, force: true }); + await rm(plain, { recursive: true, force: true }); + await rm(cfg, { recursive: true, force: true }); +}); + +test("/api/git/changes lists the branch's changes (default) or only uncommitted ones", async () => { + const r = await get(base, "/api/git/changes"); + expect(r.status).toBe(200); + const c = (await r.json()) as { repo: boolean; scope: string; rev: string; base: { name: string; same: boolean }; files: { path: string; status: string }[]; commits: number }; + expect(c.repo).toBe(true); + expect(c.scope).toBe("branch"); + expect(c.base).toMatchObject({ name: "main", same: false }); + expect(c.rev).toBe(mainOid); + expect(c.commits).toBe(1); + expect(c.files.map((f) => `${f.status} ${f.path}`).sort()).toEqual(["? sub/new.ts", "D gone.txt", "M a.txt"]); + + const h = (await (await get(base, "/api/git/changes?scope=head")).json()) as typeof c; + expect(h.scope).toBe("head"); + expect(h.files.map((f) => `${f.status} ${f.path}`)).toEqual(["? sub/new.ts"]); + expect((await (await get(base, "/api/git/changes?scope=bogus")).json()).scope).toBe("branch"); + expect(await (await get(base2, "/api/git/changes")).json()).toEqual({ repo: false }); + expect((await fetch(`${base}/api/git/changes`)).status).toBe(401); +}); + +test("/api/git/base?rev= reads the old side by object id, including files gone from disk", async () => { + const a = await get(base, `/api/git/base?path=a.txt&rev=${mainOid}`); + expect(a.status).toBe(200); + expect(await a.text()).toBe("line1\nline2\nline3\n"); + const gone = await get(base, `/api/git/base?path=gone.txt&rev=${mainOid}`); + expect(gone.status).toBe(200); + expect(await gone.text()).toBe("bye\n"); + expect((await get(base, "/api/git/base?path=gone.txt")).status).toBe(404); // without rev the file must exist + expect((await get(base, `/api/git/base?path=sub/new.ts&rev=${mainOid}`)).status).toBe(404); +}); + +test("/api/git/base rejects anything but a full object id and malformed paths", async () => { + for (const rev of ["main", "HEAD", "main..HEAD", "-" + "a".repeat(39), "a".repeat(39), "%00", "--output=x"]) { + expect((await get(base, `/api/git/base?path=a.txt&rev=${encodeURIComponent(rev)}`)).status).toBe(400); + } + for (const p of ["../a.txt", "/etc/passwd", "a/../../x", "", "C:\\x", "a%00b"]) { + expect((await get(base, `/api/git/base?path=${encodeURIComponent(p)}&rev=${mainOid}`)).status).toBe(404); + } + expect((await get(base2, `/api/git/base?path=f.txt&rev=${mainOid}`)).status).toBe(404); +}); diff --git a/test/servergit.changes.test.ts b/test/servergit.changes.test.ts new file mode 100644 index 0000000..e81df2d --- /dev/null +++ b/test/servergit.changes.test.ts @@ -0,0 +1,288 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, mkdir, realpath, rm, writeFile, rename } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Git, isOid, MAX_CHANGES } from "../src/server/git.ts"; + +let dir = ""; +let upstream = ""; +let repo = ""; +const open: Git[] = []; + +const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; +const sh = (cmd: string[], cwd = repo) => { + const r = Bun.spawnSync(["git", "-c", "user.name=Tester", "-c", "user.email=t@example.com", ...cmd], { cwd, stderr: "pipe", env }); + if (r.exitCode !== 0) throw new Error(`git ${cmd.join(" ")}: ${r.stderr}`); + return r.stdout.toString().trim(); +}; +async function git(root = repo) { + const g = (await Git.open(root))!; + expect(g).not.toBeNull(); + open.push(g); + return g; +} +const byPath = (files: { path: string }[]) => Object.fromEntries(files.map((f) => [f.path, f])); + +beforeAll(async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-changes-"))); + upstream = path.join(dir, "upstream"); + await mkdir(upstream); + sh(["init", "-q", "-b", "main"], upstream); + await writeFile(path.join(upstream, "a.txt"), "one\ntwo\nthree\n"); + await writeFile(path.join(upstream, "bin.dat"), new Uint8Array([0, 1, 2, 3])); + await writeFile(path.join(upstream, "old name.txt"), "rename me, I am long enough to be detected as a rename\nline2\nline3\n"); + await mkdir(path.join(upstream, "sub")); + await writeFile(path.join(upstream, "sub", "s.txt"), "s1\ns2\n"); + sh(["add", "."], upstream); + sh(["commit", "-qm", "init"], upstream); + // A clone has origin/HEAD, which is the preferred base. + sh(["clone", "-q", upstream, "repo"], dir); + repo = path.join(dir, "repo"); +}); +afterAll(async () => { + for (const g of open) g.stop(); + await rm(dir, { recursive: true, force: true }); +}); + +test("isOid accepts full sha1/sha256 ids only", () => { + expect(isOid("a".repeat(40))).toBe(true); + expect(isOid("0123456789abcdef".repeat(4))).toBe(true); + expect(isOid("a".repeat(39))).toBe(false); + expect(isOid("HEAD")).toBe(false); + expect(isOid("main..HEAD")).toBe(false); + expect(isOid("-" + "a".repeat(39))).toBe(false); + expect(isOid("A".repeat(40))).toBe(false); +}); + +test("clean clone on main: base is origin/main and the scopes coincide", async () => { + const g = await git(); + const c = await g.changes("branch"); + const head = sh(["rev-parse", "HEAD"]); + expect(c.base).toEqual({ name: "origin/main", oid: head, mergeBase: head, same: true }); + expect(c.scope).toBe("head"); // nothing on top of the base: branch scope collapses + expect(c.rev).toBe(head); + expect(c.files).toEqual([]); + expect(c.commits).toBe(0); + expect(c.truncated).toBe(false); +}); + +test("feature branch: branch scope diffs the working tree against the merge-base, head scope against HEAD", async () => { + const mainOid = sh(["rev-parse", "HEAD"]); + sh(["checkout", "-qb", "feature"]); + await writeFile(path.join(repo, "a.txt"), "one\nTWO\nthree\nfour\n"); + sh(["mv", "old name.txt", "new.txt"]); + sh(["rm", "-q", "bin.dat"]); + sh(["commit", "-qam", "feat: first"]); + sh(["commit", "-q", "--allow-empty", "-m", "chore: empty"]); + // Uncommitted on top: a further edit, an untracked text file (with a space), an untracked binary. + await writeFile(path.join(repo, "a.txt"), "one\nTWO\nthree\nfour\nfive\n"); + await writeFile(path.join(repo, "new.txt"), "rename me, I am long enough to be detected as a rename\nline2\nline3\nmore\n"); + await writeFile(path.join(repo, "notes todo.md"), "a\nb\nc"); // no trailing newline: still 3 lines + await writeFile(path.join(repo, "blob.bin"), new Uint8Array([0, 255, 0])); + const g = await git(); + const headOid = sh(["rev-parse", "HEAD"]); + + const b = await g.changes("branch"); + expect(b.scope).toBe("branch"); + expect(b.base).toEqual({ name: "origin/main", oid: mainOid, mergeBase: mainOid, same: false }); + expect(b.rev).toBe(mainOid); + expect(b.commits).toBe(2); + expect(b.subjects).toEqual(["chore: empty", "feat: first"]); + const bf = byPath(b.files); + expect(bf["a.txt"]).toEqual({ path: "a.txt", status: "M", added: 3, removed: 1, binary: false }); + expect(bf["bin.dat"]).toEqual({ path: "bin.dat", status: "D", added: null, removed: null, binary: true }); + expect(bf["new.txt"]).toEqual({ path: "new.txt", oldPath: "old name.txt", status: "R", added: 1, removed: 0, binary: false }); + expect(bf["notes todo.md"]).toEqual({ path: "notes todo.md", status: "?", added: 3, removed: 0, binary: false }); + expect(bf["blob.bin"]).toEqual({ path: "blob.bin", status: "?", added: null, removed: null, binary: true }); + expect(Object.keys(bf).sort()).toEqual(["a.txt", "bin.dat", "blob.bin", "new.txt", "notes todo.md"]); + + const hd = await g.changes("head"); + expect(hd.scope).toBe("head"); + expect(hd.rev).toBe(headOid); + expect(hd.base?.same).toBe(false); // the base is still reported, so the client can offer the switch + expect(hd.commits).toBe(0); + const hf = byPath(hd.files); + expect(hf["a.txt"]).toMatchObject({ status: "M", added: 1, removed: 0 }); + expect(hf["new.txt"]).toMatchObject({ status: "M", added: 1, removed: 0 }); + expect(hf["bin.dat"]).toBeUndefined(); // deleted in a commit: not an uncommitted change + expect(hf["notes todo.md"]).toMatchObject({ status: "?" }); + + // base(rel, max, rev): the old side of a diff, including files that no longer exist on disk. + const td = new TextDecoder(); + expect(td.decode((await g.base("a.txt", 1 << 20, mainOid))!)).toBe("one\ntwo\nthree\n"); + expect(td.decode((await g.base("old name.txt", 1 << 20, mainOid))!)).toStartWith("rename me"); + expect(await g.base("old name.txt", 1 << 20, headOid)).toBeNull(); + expect(await g.base("a.txt", 1 << 20, "main")).toBeNull(); // only object ids, never ref names + expect(await g.base("a.txt", 1 << 20, "--output=/tmp/x")).toBeNull(); +}); + +test("a viewed subfolder lists only its own files, relative to itself", async () => { + await writeFile(path.join(repo, "sub", "s.txt"), "s1\nS2\ns3\n"); + await writeFile(path.join(repo, "sub", "u.txt"), "u\n"); + const g = await git(path.join(repo, "sub")); + const c = await g.changes("branch"); + expect(c.files.map((f) => f.path).sort()).toEqual(["s.txt", "u.txt"]); + expect(byPath(c.files)["s.txt"]).toMatchObject({ status: "M", added: 2, removed: 1 }); + expect(td(await g.base("s.txt", 1 << 20, c.rev!))).toBe("s1\ns2\n"); +}); +const td = (b: Uint8Array | null) => new TextDecoder().decode(b!); + +test("detached HEAD still compares with the base; a repo without remote falls back to local main", async () => { + const g = await git(); + sh(["checkout", "-q", "--detach"]); + try { + const c = await g.changes("branch"); + expect(c.base?.name).toBe("origin/main"); + expect(c.scope).toBe("branch"); + } finally { + sh(["checkout", "-q", "feature"]); + } + + const local = path.join(dir, "local"); + await mkdir(local); + sh(["init", "-q", "-b", "main"], local); + await writeFile(path.join(local, "f.txt"), "1\n"); + sh(["add", "."], local); + sh(["commit", "-qm", "init"], local); + const lg = await git(local); + // On main with no upstream: nothing to compare with. + expect((await lg.changes("branch")).base).toBeNull(); + sh(["checkout", "-qb", "topic"], local); + await writeFile(path.join(local, "f.txt"), "1\n2\n"); + sh(["commit", "-qam", "two"], local); + const c = await lg.changes("branch"); + expect(c.base?.name).toBe("main"); + expect(c.base?.same).toBe(false); + expect(c.commits).toBe(1); + expect(c.files).toEqual([{ path: "f.txt", status: "M", added: 1, removed: 0, binary: false }]); +}); + +test("unborn branch: no rev, untracked files only", async () => { + const empty = path.join(dir, "empty"); + await mkdir(empty); + sh(["init", "-q", "-b", "main"], empty); + await writeFile(path.join(empty, "x.txt"), "x\n"); + const g = await git(empty); + const c = await g.changes("branch"); + expect(c.rev).toBeNull(); + expect(c.base).toBeNull(); + expect(c.files).toEqual([{ path: "x.txt", status: "?", added: 1, removed: 0, binary: false }]); +}); + +test("merge conflicts are listed as U, including modify/delete ones", async () => { + const c = path.join(dir, "conflict"); + await mkdir(c); + sh(["init", "-q", "-b", "main"], c); + await writeFile(path.join(c, "c.txt"), "base\n"); + await writeFile(path.join(c, "md.txt"), "gone on one side\n"); + sh(["add", "."], c); + sh(["commit", "-qm", "init"], c); + sh(["checkout", "-qb", "feat"], c); + await writeFile(path.join(c, "c.txt"), "feat\n"); + await writeFile(path.join(c, "md.txt"), "edited\n"); + sh(["commit", "-qam", "feat"], c); + sh(["checkout", "-q", "main"], c); + await writeFile(path.join(c, "c.txt"), "main\n"); + sh(["rm", "-q", "md.txt"], c); + sh(["commit", "-qam", "main"], c); + expect(() => sh(["merge", "feat"], c)).toThrow(); + const g = await git(c); + const out = byPath((await g.changes("head")).files); + expect(out["c.txt"]).toMatchObject({ status: "U" }); + expect(out["md.txt"]).toMatchObject({ status: "U" }); +}); + +test("a file removed from the index but present on disk is one modified entry", async () => { + const d = path.join(dir, "cached"); + await mkdir(d); + sh(["init", "-q", "-b", "main"], d); + await writeFile(path.join(d, "f.txt"), "1\n"); + sh(["add", "."], d); + sh(["commit", "-qm", "init"], d); + sh(["rm", "-q", "--cached", "f.txt"], d); + const g = await git(d); + const c = await g.changes("head"); + expect(c.files).toEqual([{ path: "f.txt", status: "M", added: null, removed: null, binary: false }]); +}); + +test("an orphan branch with staged files lists them as added against the empty tree", async () => { + const d = path.join(dir, "orphan"); + await mkdir(d); + sh(["init", "-q", "-b", "main"], d); + await writeFile(path.join(d, "s.txt"), "1\n2\n"); + sh(["add", "."], d); + const g = await git(d); + const c = await g.changes("branch"); + expect(c.rev).toBeNull(); + expect(c.files).toEqual([{ path: "s.txt", status: "A", added: 2, removed: 0, binary: false }]); +}); + +test("over the cap, kept entries still carry their counts and truncation is reported", async () => { + const d = path.join(dir, "manytracked"); + await mkdir(d); + sh(["init", "-q", "-b", "main"], d); + const n = MAX_CHANGES + 3; + await Promise.all(Array.from({ length: n }, (_, i) => writeFile(path.join(d, `t${String(i).padStart(4, "0")}.txt`), "a\n"))); + sh(["add", "."], d); + sh(["commit", "-qm", "init"], d); + await Promise.all(Array.from({ length: n }, (_, i) => writeFile(path.join(d, `t${String(i).padStart(4, "0")}.txt`), "a\nb\n"))); + const g = await git(d); + const c = await g.changes("head"); + expect(c.files.length).toBe(MAX_CHANGES); + expect(c.truncated).toBe(true); + expect(c.files.every((f) => f.added === 1 && f.removed === 0)).toBe(true); + await rm(d, { recursive: true, force: true }); +}); + +test("hostile configs: order file, mailmap and relative paths cannot hang or blank the list", async () => { + const d = path.join(dir, "hostile"); + await mkdir(path.join(d, "sub"), { recursive: true }); + sh(["init", "-q", "-b", "main"], d); + await writeFile(path.join(d, "sub", "a.txt"), "1\n"); + sh(["add", "."], d); + sh(["commit", "-qm", "init"], d); + sh(["checkout", "-qb", "topic"], d); + await writeFile(path.join(d, "sub", "a.txt"), "1\n2\n"); + sh(["commit", "-qam", "work"], d); + sh(["config", "diff.orderFile", "/dev/zero"], d); + sh(["config", "mailmap.file", "/dev/zero"], d); + sh(["config", "diff.relative", "true"], d); + sh(["config", "core.bigFileThreshold", "1"], d); + const g = await git(path.join(d, "sub")); + const t0 = performance.now(); + const c = await Promise.race([g.changes("branch"), Bun.sleep(8000).then(() => null)]); + expect(performance.now() - t0).toBeLessThan(8000); + expect(c?.files).toEqual([{ path: "a.txt", status: "M", added: 1, removed: 0, binary: false }]); + expect(c?.subjects).toEqual(["work"]); + expect(c?.commits).toBe(1); +}); + +test("the list is capped and reports truncation; untracked symlinks are not read", async () => { + const many = path.join(dir, "many"); + await mkdir(many); + sh(["init", "-q", "-b", "main"], many); + await writeFile(path.join(many, "keep.txt"), "k\n"); + sh(["add", "."], many); + sh(["commit", "-qm", "init"], many); + await Promise.all(Array.from({ length: MAX_CHANGES + 5 }, (_, i) => writeFile(path.join(many, `u${String(i).padStart(4, "0")}.txt`), "u\n"))); + const g = await git(many); + const c = await g.changes("head"); + expect(c.files.length).toBe(MAX_CHANGES); + expect(c.truncated).toBe(true); + expect((await (await git(many)).changes("head")).files.map((f) => f.path)).toContain("u0999.txt"); + // Only the first 200 untracked files are counted. + expect(c.files[0]!.added).toBe(1); + expect(c.files[c.files.length - 1]!.added).toBeNull(); + await rm(many, { recursive: true, force: true }); + + const links = path.join(dir, "links"); + await mkdir(links); + sh(["init", "-q", "-b", "main"], links); + await writeFile(path.join(dir, "outside.txt"), "secret\nsecret\n"); + await rename(path.join(dir, "outside.txt"), path.join(dir, "outside2.txt")); + const { symlink } = await import("node:fs/promises"); + await symlink(path.join(dir, "outside2.txt"), path.join(links, "escape")); + const lg = await git(links); + const lc = await lg.changes("head"); + expect(lc.files).toEqual([{ path: "escape", status: "?", added: null, removed: null, binary: false }]); +});