diff --git a/AGENTS.md b/AGENTS.md index 53d8cd4..2f9b246 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,12 @@ bun run build:local # binaries + the app for this machine (needs X | `src/client/codeview.ts`, `mdview.ts`, `diffview.ts` | The three file views; each owns select-all and copy while visible | | `src/client/term/` | Terminal emulator (`terminal.ts`), renderer, view (input, links, a11y log) | | `src/client/tree.ts`, `search.ts`, `changes.ts`, `palette.ts` | Sidebar views and the palette (virtualized rows, `aria-activedescendant`); the palette's `pick()` lists anything (File History) and `@` lists symbols | +| `src/client/navhistory.ts`, `navigation.ts` | Back / Forward: the pure jump list (`NavHistory`) and `Navigation` (injected open/announce/notify, `routeNavZoomKey`); app.ts is glue | +| `src/client/hidden.ts` | Hidden-character scanner (zero-width, bidi, tag, variation; pure, also imported by the server) | +| `src/client/unreviewed.ts` | The two unreviewed-file commands and `runUnreviewed`, which announces through the live region; `app.ts` is glue | | `src/client/symbols.ts`, `reference.ts` | Go to Symbol patterns per language (pure); `path:line` references and their parsing | +| `src/client/checkpoint.ts`, `src/client/checkpointctl.ts` | Changes since a checkpoint: pure `takeCheckpoint`/`sinceCheckpoint` math, and the controller behind the three Changes checkpoint commands (`app.ts` is glue); client-only, in memory | +| `src/client/risk.ts` | Risk flags for Changes rows (pure): `riskFlags`, `riskLabel`; hints, never a safety verdict | | `src/client/styles.css` | All styling; colour tokens at the top, dark and light | | `test/helpers/fakedom.ts`, `selfake.ts` | Minimal fake DOM for client tests (see limits below) | | `scripts/shots.ts`, `shots.list.ts`, `demo-repo.ts` | Landing page screenshots: a deterministic demo repo, an isolated server, `packaging/macos/appshot.swift` driving an offscreen WKWebView | diff --git a/DESIGN.md b/DESIGN.md index d337e7d..825ff88 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1082,3 +1082,126 @@ everything under a heavy folder, so a Mac folder could show `main* 1 untracked` `FileIndex.apply` keeps its own test: its watcher paths can be the heavy folder itself, which must not restart a build. A nested repository still counts as one untracked folder with no row in Changes: status cannot tell it from a plain folder, and it does exist. + +## 27. Round 18: back and forward navigation + +After jumping by `path:line`, Go to Symbol, a search result or a terminal link there was no way back. +A panel of four (agent-workflow developer, UX/accessibility designer, code-navigation engineer, +git/security engineer) compared options. B7, a browser-style jump list with Back and Forward, won with +3 votes and no vetoes. Runners-up: risky-change flags in Changes (2 votes) and a checkpoint diff (2 votes). + +The list is `NavHistory` (`navhistory.ts`, pure): a capped list (100) with a current index; jumps within +10 lines of the current entry merge. `Navigation` (`navigation.ts`) records jumps, reopens entries +without recording, and announces "Back to path line N" in the polite live region. Keys: Ctrl+- and +Ctrl+Shift+- on macOS, Ctrl+Alt+- and Ctrl+Shift+- on Linux. The history is never stored or sent. + +| Finding | Fix | +| --- | --- | +| `src/client/app.ts` constructs the app on import, so tests cannot load it | The jump bookkeeping lives in `src/client/navigation.ts` (`Navigation` with injected open/announce/notify) and the key routing in `routeNavZoomKey`; app.ts is glue | +| On Linux Ctrl+Alt+- also matched zoom-out (Ctrl+-) | The navigation keys are routed before zoom | +| The first AC-12 tests matched app.ts source text and broke on the refactor | Replaced by behavioural tests through `routeNavZoomKey` | +| A file in the history may have vanished | `Editor.open` reports whether the file opened; Back/Forward drop the path and move on | + +## 28. Round 19: risk flags in Changes + +A panel of four (agent-workflow developer, git/security engineer, UX/accessibility designer, +code-navigation engineer) picked the next feature. Risk flags in Changes won with 3 votes (agent-workflow +developer #1, git/security engineer #1, UX/accessibility designer #2). Back/Forward fidelity got 4 votes +and follows as a separate part; a checkpoint diff got 3 votes but is size L and deferred. Single votes: +live-change announcements, Go to Definition, a hidden-Unicode detector. + +`ChangeEntry` carries git's `oldMode`/`newMode`. `src/client/risk.ts` (pure) turns an entry into flags +(`exec`, `symlink`, `ci`, `hook`, `lockfile`, `manifest`, `env`); `ChangesView` renders one badge per flag +after the path, counts flagged rows in the summary and exposes `flags(path)`. The flags are hints, not a verdict. + +| Finding | Fix | +| --- | --- | +| Existing Changes tests compared whole entries with `toEqual`, so the new mode fields changed seven expectations | Each now names the exact modes: stricter, not looser | +| The mode of an untracked file must not follow a link or leave the root | It comes from `lstat` after the parent directory's realpath is checked to be inside the root; tests cover a parent link leaving the root and a file that vanishes before `lstat` | +| A mode-only change has no diff lines | The exec badge is the only sign of a `chmod +x` in the list | +| The plan claimed a path matches at most one path category, but a directory rule and a name rule can both match (`.husky/.env` is hook and env) | Flags are a set returned in union order; the plan review caught it before any test was written | +| The Changes summary had no `role="status"` although it is a count that changes | The summary wrap is a status region | +| Back restored the clicked line, which may be scrolled off screen, and a never-clicked file always went back to line 1 | `Editor.location()` reports the active line only while it is on screen, else the topmost visible line (`CodeView.firstVisibleLine`/`lastVisibleLine`, inclusive) | +| A Markdown file shown as the rendered preview came back as source | `NavLoc.view = "preview"` is recorded by `location()` and `Editor.landing()`, kept by `NavHistory` on every update path (set or removed together with the line), and `Editor.open(path, { view: "preview" })` reopens the preview; `app.ts` only forwards it | +| Closing a tab might forget where Back should go | Pinned by tests: history entries survive `Editor.close`, and Back reopens the file; diff and image tabs have no location | + + +## 29. Round 20: hidden characters + +A panel of four (git/security engineer, UX/accessibility designer, agent-workflow developer, +code-navigation engineer) picked the next feature. Hidden characters won with 4 votes (git/security #1, +UX #2, agent-workflow #3, navigation #4). A checkpoint diff got 3 votes but is size L and was deferred a +third time. The Changes summary live-region fix (Q1/Q2) got 3 votes and ships as Part B. Single votes: +Go to Definition, next unreviewed changed file, go to last live change, a repository trust report. + +`src/client/hidden.ts` (pure, also imported by the server) defines the counted set once. The server adds +`hidden` to changed files, Changes rows show a `hidden` badge, the code view marks zero-width, tag and +variation runs, and "Go to Next Hidden Character" jumps between hits. Results are hints, not verdicts. + +| Finding | Fix | +| --- | --- | +| The code view renders a line in token segments, so the joiner and variation-selector rules could not see the character before a segment and a BOM starting a later line was never marked | `markHidden` takes `ctx {prev, atFileStart}` and the code view passes them per segment (caught in plan review) | +| The first tests held raw bidi controls and failed the repo's own invariant against raw bidi in sources | Every hidden character in tests is a `\u` escape | +| `TextDecoder` strips one leading BOM, so a file starting with two BOMs reported none | The server decodes with the BOM kept and `scanHidden` applies its own offset-0 rule | +| Go to Next Hidden Character started from its own last jump and skipped hits after a search or Go to Line | It starts from the editor's current position | +| The marker had `min-width`, which shifted columns | It is zero-width with an outline like the bidi marker | +| Joiners in Arabic, Persian, Indic text and emoji sequences would be false positives | Joiners after those letters and emoji, a lone U+FE0F, and a BOM at offset 0 are exempt | +| The Changes summary wrapper was a live region, so any re-render re-announced the whole line, and the count sat inside `.ch-counts`, whose ellipsis could clip it | The wrapper has no live role; one persistent `.ch-riskcount` element carries `role="status"`, sits beside `.ch-counts` and does not shrink | + + +## 30. Round 21: changes since a checkpoint + +A panel of four picked the next feature. A checkpoint got 3 votes with two first places (agent-workflow +developer #1, git/security engineer #1, navigation #2); it had been deferred three times before as size L and +is scoped here to a client-only, in-memory version. Next/Previous Unreviewed Changed File got 3 votes (UX #1) +and is next in line. Go to Definition got a first place for the fourth time. + +The design is client-only and in memory. `src/client/checkpoint.ts` (pure) has `takeCheckpoint` (bounded: +500 files, 16 million characters) and `sinceCheckpoint` (statuses and line counts from `diffLines`). +`src/client/checkpointctl.ts` is the controller behind "Changes: Set Checkpoint", "Changes: Since +Checkpoint…" and "Changes: Clear Checkpoint"; `app.ts` only wires it to the existing endpoints and the +palette's `pick()`. Enter opens a read-only diff tab (`Editor.openSinceDiff`) labelled "since HH:MM". + +| Finding | Fix | +| --- | --- | +| A server checkpoint would have needed the first state-setting route and blobs git cannot keep without writing | The checkpoint is client-only, in memory, read through existing GET endpoints; it is lost on reload | +| A path in neither `texts` nor `skipped` has no stored text | It means unchanged from the checkpoint's HEAD; its text is read at the checkpoint's `rev` | +| A path that is no longer changed would look deleted | It reverts to its base text (the text at the checkpoint's `rev`) | +| Binary and unreadable files cannot be compared | They are skipped and counted in the toast, never put in the comparison | +| The line counts depend on how a trailing newline splits | The rule drops one final empty element, pinned with examples; `old === now` is compared on strings, so a trailing-newline-only change is kept with 0/0 counts | +| A rename whose new path was binary or unreadable lost the old path's deletion | A rename is a delete plus an add: the old path is recorded as deleted before the new one is read | +| Clear Checkpoint during an in-flight Set was undone when the listing resolved | Clear bumps a generation and the late result is dropped | +| A failed base read left an unhandled rejection and no message | Since Checkpoint resolves and says it could not compare | +| Since tabs could be re-pointed by git events, stepping or reloads | The since tab is not tied to Changes: no re-pointing, no stepping, no reloads, no position | +| The Changes summary was rebuilt with innerHTML, replacing the `ch-riskcount` live region so screen readers might not announce the new count | The summary elements are created once; the render updates only their text and the `ch-riskcount` element persists | + + +## 31. Round 22: next unreviewed file + +A panel of four picked the next feature. Next/Previous Unreviewed Changed File got 4 of 4 votes this time +(agent-workflow #1, UX #1, navigation #2, git/security #3) and 3 in panel 4; S1 got 3 votes. +A repository trust report got a first place (git/security). Go to Definition got a first place for the fifth time +(navigation). Votes for content-based risk hints: 2. + +The search is pure: `nextUnreviewed` in `changes.ts` walks the paths from the current file, wrapping, and +checks the current file last. `ChangesView.stepUnreviewed` and `unreviewedCount` use the same `seenKey` rule +as the dimmed rows. `src/client/unreviewed.ts` holds the two palette commands and `runUnreviewed`, which +announces through the live region (no toast); `unreviewedKey` in `keys.ts` routes Alt+F6 and Alt+Shift+F6 +and is `null` in the terminal. `app.ts` is glue. The checkpoint fix (S1) is `checkpointText` in +`checkpointctl.ts`: `api.ts` throws an `ApiError` with the HTTP status, and a 404 while reading a file for a +checkpoint means it vanished (recorded as deleted); every other failure stays skipped. The server answers a +uniform 404 for a symlink that leaves the root too, so a listing entry whose newMode is 120000 (a symlink) +is never recorded as deleted: its 404 is skipped. + +| Finding | Fix | +| --- | --- | +| Stepping with the diff's buttons visited reviewed rows | `stepUnreviewed` goes only to rows that are not reviewed, and returns `null` when none is left | +| Reviewed was only visual (a dimmed row) | A reviewed row carries a visually hidden ", reviewed" text, so its accessible name says so | +| The unreviewed search could be wrong at the ends or when everything is reviewed | It is pure (`nextUnreviewed`), wraps, checks the current file last and returns -1 when all are reviewed | +| The stepping start was first tied to the private keyboard selection, which tests and other callers could not set | The current file is the one whose diff is open (`setCurrent`) | +| The hidden ", reviewed" text first sat before the directory, so nested rows read "name, reviewed dir" | It follows the directory span: "name dir, reviewed" | +| One scenario test reused a view where the expected file was already reviewed | The test uses a fresh view and records the step again | +| A key could fire inside the terminal, which keeps Alt combinations | Routing goes through `unreviewedKey`, which is `null` when the focus is in the terminal | +| A toast would be missed by screen readers and cover the diff | The result is announced through the polite live region as "path, N unreviewed left"; no toast | +| A file that vanished while a checkpoint was taken was recorded as skipped instead of deleted | `api.ts` throws `ApiError` with the status; `checkpointText` maps a 404 to deleted (null) and other failures to skipped | +| The server's uniform 404 for a symlink leaving the root made `checkpointText` record such a symlink as deleted | The controller skips a symlink entry (newMode 120000) whose read gives null; only a non-symlink 404 means vanished, and the server's 404 stays uniform | diff --git a/README.md b/README.md index 031eebb..774527b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Every feature below has a screenshot in the [feature tour](https://codeviewer.21 preview scrolls to the heading itself. Covers TypeScript/JavaScript, Python, Rust, Go, C/C++, Java/Kotlin/C#, Swift, Ruby, PHP, Lua, shell, CSS, Zig, Haskell, Makefiles, YAML/TOML keys and Dockerfile stages, with patterns, not a parser. +- **Back and forward:** ⌃- (Ctrl+Alt+- on Linux) returns to where you jumped from, ⌃⇧- (Ctrl+Shift+-) goes + forward again, like a browser. Jumps nearby (within 10 lines) merge; files that were deleted are skipped. + The history lives in memory only and is announced to screen readers. - **References for your agent:** ⌥⌘C copies `path:line` (or `path:12-40` for a selection) for the cursor or the selected lines. The palette also has **Copy Reference with Code** (the reference plus the lines in a fenced block) and **Insert Reference in Terminal**, which types it at the @@ -49,11 +52,29 @@ Every feature below has a screenshot in the [feature tour](https://codeviewer.21 - **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. + the changes and moves on to the next file. Reviewed files dim until they change again, and ⌥F6 / ⌥⇧F6 (or the palette commands "Changes: Next Unreviewed File" / "Changes: Previous Unreviewed File") step through only the files not yet reviewed, announcing how many are left. Screen readers read reviewed rows as "reviewed". Right-click a file in the Explorer and pick "View Changes" to jump to its diff. + - **Risk flags:** rows in Changes carry a small badge, and the summary counts them ("· 2 flagged"), + for an added executable bit, a new symbolic link, CI configuration, git hooks, dependency + lockfiles, package manifests and build files, and `.env`/registry configuration. They are hints + for where to look first, not a safety verdict: they match file names, directories and git modes + only, never contents, and they can miss things (a malicious change in an ordinary file is not flagged). + The one exception is `hidden`: files up to 1 MiB are scanned for zero-width, tag and variation + characters and bidi controls, which render as nothing but can hide text. That is a hint too, not + a verdict: legitimate text (emoji, Arabic, Indic scripts) is skipped, but other uses can still match. + In the code view such characters get a visible outlined marker (the text is unchanged, so copy + works as before), and the palette command "Go to Next Hidden Character" jumps to the next one + and announces its name and position. - **File History:** "Git: File History" in the palette (or "File History" in the Explorer's file menu) lists the commits that changed the file, renames followed, newest first; type to filter by subject, Enter to open what that commit changed as a read-only diff tab labelled with its short id. + - **Changes since a checkpoint:** "Changes: Set Checkpoint" remembers the text of every uncommitted + file; later, "Changes: Since Checkpoint…" lists what changed after that moment (path, `+added + −removed`, type to filter) and Enter opens a read-only diff tab labelled "since HH:MM". + "Changes: Clear Checkpoint" forgets it. The checkpoint is kept in memory only and is lost on reload; + nothing is written or sent anywhere. Binary files, unreadable files and files over the caps + (500 files, 16 million characters) are skipped, and the toast counts them ("Checkpoint set (4 files, 2 + skipped)"). The since tab is fixed content: it does not follow the Changes list. - 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 @@ -71,7 +92,7 @@ Every feature below has a screenshot in the [feature tour](https://codeviewer.21 tabs coloured by git status, breadcrumbs, several terminals as tabs, and a terminal panel at the bottom or on the right that can be maximized ("Move Terminal Panel", "Maximize/Restore Terminal Panel"). The title bar button cycles the System, Dark and Light themes. The palette also has - Copy Path, Copy Absolute Path, Close All Tabs and Clear Terminal. In a diff tab, ‹ › step to the + Changes: Set Checkpoint, Changes: Since Checkpoint… and Changes: Clear Checkpoint, Copy Path, Copy Absolute Path, Close All Tabs and Clear Terminal. In a diff tab, ‹ › step to the previous or next changed file and "Open file" opens the file itself; the Changes view switches between "Uncommitted" and "vs main". - **Keyboard and screen-reader friendly:** ⌘/ lists every shortcut in your platform's key names. @@ -129,8 +150,11 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ | ⌘W · ⌘⇧[ ⌘⇧] | Close tab · switch tabs (in the terminal: switch terminals) | | ⌘W in the terminal | Close an exited terminal; a running one asks for a second press (Linux: Ctrl+W stays the shell's) | | Shift+F10 / Menu | Context menu for the selected explorer entry ("Search in Folder…", "View Changes", "File History") | +| ⌃- / Ctrl+Alt+- | Go back to the previous location (after a jump: `path:line`, Go to Line or Symbol, a search result, a terminal link, `codeviewer open`) | +| ⌃⇧- / Ctrl+Shift+- | Go forward again | | ⌘= ⌘- ⌘0 | Font size (code, terminal and the UI chrome) | | ⌘⇧G · ⌥F5 / ⌥⇧F5 | Changes · next / previous change (then next file) | +| ⌥F6 / ⌥⇧F6 | Next / previous unreviewed changed file (Alt+F6 / Alt+Shift+F6 elsewhere) | | ⌘/ | Every shortcut and the terminal tips, in your platform's key names (⌘⇧7 on layouts where "/" is Shift+7, such as German) | | ⌘-click in terminal | Open `path:line` or URL | | Left ⌥ | Meta in the terminal (right ⌥ still types special characters) | @@ -176,6 +200,9 @@ The server binds to localhost and serves a shell. It was hardened through an adv - A throwaway profile, locked to the window's own server port. - External links open in your default browser. - Terminal clipboard writes always show a notice. +- **Hidden characters:** the code view marks zero-width, tag and variation characters and bidi + controls in place, and Changes counts them per changed file (regular files up to 1 MiB, 32 MiB per + listing). Like the other risk flags, a hint, not a verdict. - **Markdown:** repository files are untrusted. The preview never uses `innerHTML`, shows no raw HTML, filters link schemes and loads no remote images (the CSP is unchanged). - **Terminals:** the shell gets a narrow token that only lets `codeviewer open` ask the viewer to show diff --git a/SECURITY.md b/SECURITY.md index 9b484d9..97e5e94 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,6 +50,15 @@ programs the repository's config names for it (`remote.*.uploadpack`, `core.sshC exist inside the served root, at most 500 commits and 4 MB of output; each side of a commit's diff is read with `cat-file` by full object id, never from a revision expression the client chose. +The Changes listing reads the working-tree text of changed files to count hidden characters +(zero-width, tag and variation characters and bidi controls): regular files only, opened like every +other file (`O_NOFOLLOW` after containment in the root), at most 1 MiB each and 32 MiB per listing, +cached by size and modification time; binary and larger files are not scanned. File modes come from +the diff git already runs, or `lstat` for untracked files. The resulting risk flags, and the markers +the code view draws on hidden characters, are hints for review, not a verdict: a harmful change +in an ordinary file carries no flag. Checkpoints ("Changes: Set Checkpoint") live in the page's +memory only; nothing is written to disk or sent anywhere. + Commands that type into a terminal ("Insert Reference in Terminal") send one line as a paste and never press Enter, and refuse file names with control characters or command substitution. After a reload the server restores the terminal's input modes (bracketed paste, mouse) with a parser diff --git a/docs/assets/features/checkpoint-dark-half.jpg b/docs/assets/features/checkpoint-dark-half.jpg new file mode 100644 index 0000000..b8ebd4e Binary files /dev/null and b/docs/assets/features/checkpoint-dark-half.jpg differ diff --git a/docs/assets/features/checkpoint-dark.jpg b/docs/assets/features/checkpoint-dark.jpg new file mode 100644 index 0000000..bb8b5bf Binary files /dev/null and b/docs/assets/features/checkpoint-dark.jpg differ diff --git a/docs/assets/features/checkpoint-light-half.jpg b/docs/assets/features/checkpoint-light-half.jpg new file mode 100644 index 0000000..18d90a8 Binary files /dev/null and b/docs/assets/features/checkpoint-light-half.jpg differ diff --git a/docs/assets/features/checkpoint-light.jpg b/docs/assets/features/checkpoint-light.jpg new file mode 100644 index 0000000..f45ba5c Binary files /dev/null and b/docs/assets/features/checkpoint-light.jpg differ diff --git a/docs/assets/features/commands-dark-half.jpg b/docs/assets/features/commands-dark-half.jpg index c9862dc..becb9b0 100644 Binary files a/docs/assets/features/commands-dark-half.jpg and b/docs/assets/features/commands-dark-half.jpg differ diff --git a/docs/assets/features/commands-dark.jpg b/docs/assets/features/commands-dark.jpg index 63cd979..54d6b5f 100644 Binary files a/docs/assets/features/commands-dark.jpg and b/docs/assets/features/commands-dark.jpg differ diff --git a/docs/assets/features/commands-light-half.jpg b/docs/assets/features/commands-light-half.jpg index 047167b..d653778 100644 Binary files a/docs/assets/features/commands-light-half.jpg and b/docs/assets/features/commands-light-half.jpg differ diff --git a/docs/assets/features/commands-light.jpg b/docs/assets/features/commands-light.jpg index 136842e..974a03a 100644 Binary files a/docs/assets/features/commands-light.jpg and b/docs/assets/features/commands-light.jpg differ diff --git a/docs/assets/features/flags-dark-half.jpg b/docs/assets/features/flags-dark-half.jpg new file mode 100644 index 0000000..c5ae116 Binary files /dev/null and b/docs/assets/features/flags-dark-half.jpg differ diff --git a/docs/assets/features/flags-dark.jpg b/docs/assets/features/flags-dark.jpg new file mode 100644 index 0000000..5c8b171 Binary files /dev/null and b/docs/assets/features/flags-dark.jpg differ diff --git a/docs/assets/features/flags-light-half.jpg b/docs/assets/features/flags-light-half.jpg new file mode 100644 index 0000000..b7ff72b Binary files /dev/null and b/docs/assets/features/flags-light-half.jpg differ diff --git a/docs/assets/features/flags-light.jpg b/docs/assets/features/flags-light.jpg new file mode 100644 index 0000000..7898d23 Binary files /dev/null and b/docs/assets/features/flags-light.jpg differ diff --git a/docs/assets/features/help-dark-half.jpg b/docs/assets/features/help-dark-half.jpg index 0e53b92..02c6aa7 100644 Binary files a/docs/assets/features/help-dark-half.jpg and b/docs/assets/features/help-dark-half.jpg differ diff --git a/docs/assets/features/help-dark.jpg b/docs/assets/features/help-dark.jpg index 8b33708..832bd3f 100644 Binary files a/docs/assets/features/help-dark.jpg and b/docs/assets/features/help-dark.jpg differ diff --git a/docs/assets/features/help-light-half.jpg b/docs/assets/features/help-light-half.jpg index 46ffb3d..c82ca2b 100644 Binary files a/docs/assets/features/help-light-half.jpg and b/docs/assets/features/help-light-half.jpg differ diff --git a/docs/assets/features/help-light.jpg b/docs/assets/features/help-light.jpg index 4190e4f..0cd63f3 100644 Binary files a/docs/assets/features/help-light.jpg and b/docs/assets/features/help-light.jpg differ diff --git a/docs/index.html b/docs/index.html index 4eeff4c..d2d2c1c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -82,7 +82,7 @@

Live while agents work

Git, read-only

-

Status letters in the explorer, gutter markers against HEAD with a peek at the old lines, inline blame, branch, ahead/behind and merge state, a Changes view that lists every file the branch touched as read-only diffs, and the history of any file, one commit at a time. It never writes to your repository.

+

Status letters in the explorer, gutter markers against HEAD with a peek at the old lines, inline blame, branch, ahead/behind and merge state, a Changes view that lists every file the branch touched as read-only diffs, flags risky edits and hidden characters, steps through what you have not reviewed yet and shows what changed since a checkpoint, and the history of any file, one commit at a time. It never writes to your repository.

@@ -120,7 +120,7 @@

Search in files

Go to file

Go to file with “ca” typed, listing cache.ts, cache.test.ts and other matches with the matched letters highlighted.
-

Fuzzy-matches file names first, then whole paths. Type path:line to land on a line; an empty box lists recent files.

⌘P

+

Fuzzy-matches file names first, then whole paths. Type path:line to land on a line; an empty box lists recent files. After any jump, ⌃- takes you back to where you were and ⌃⇧- forward again (Ctrl+Alt+- and Ctrl+Shift+- on Linux).

⌘P ⌃- / ⌃⇧-

Go to Symbol

@@ -129,7 +129,7 @@

Go to Symbol

Command palette

-
The command palette filtered by “git”: Next Change, Show Changes, File History, Previous Change, Peek HEAD on Current Line, Compare With Base Branch / HEAD and Toggle Automatic Update Checks, with their shortcuts.
+
The command palette filtered by “changes”: Set Checkpoint, Since Checkpoint…, Show Changes, Clear Checkpoint, Next and Previous Unreviewed File and Clear Change Markers, with their shortcuts.

Every command with its shortcut: terminals, panels, theme, font size, git, references, updates. The same box takes > for commands, : for a line and @ for symbols.

⌘⇧P

@@ -144,7 +144,7 @@

Explorer with git status

Every shortcut, one key away

-
The Keyboard shortcuts sheet, showing its Files and In a file groups.
+
The Keyboard shortcuts sheet, showing its Files and In a file groups.

The cheat sheet uses your platform's key names. The whole app works from the keyboard, and lists, the palette and the terminal expose what they show to screen readers.

⌘/

@@ -191,10 +191,20 @@

Review with git

The Changes view comparing the branch with main: six files with their status letters and line counts, and cache.ts open as a unified diff with added and removed lines.

Review the branch

-

Lists every file the branch touched since it left main, or only what is uncommitted, with +/− counts. Each file opens as a read-only diff. ⌥F5 walks hunk by hunk and on to the next file, and reviewed files dim until they change again.

⌘⇧G ⌥F5 / ⌥⇧F5

+

Lists every file the branch touched since it left main, or only what is uncommitted, with +/− counts. Each file opens as a read-only diff. ⌥F5 walks hunk by hunk and on to the next file. Reviewed files dim until they change again, and ⌥F6 skips straight to the next file you have not reviewed.

⌘⇧G ⌥F5 / ⌥⇧F5 ⌥F6 / ⌥⇧F6

+
+

Risk flags and hidden characters

+
The Changes view with 4 flagged files: deploy.yml in .github/workflows marked CI, package.json manifest, router.ts hidden and server.ts exec. router.ts is open on a comment that ends in a red marker where invisible tag characters hide text.
+

Badges mark the edits worth a second look: an executable bit or symlink, CI workflows, git hooks, lockfiles, manifests, .env and registry files, and invisible Unicode (zero-width, tag and variation characters, bidi controls). The code view outlines each hidden character in place, and Go to Next Hidden Character walks them. Hints, never a verdict.

+
+
+

Changes since a checkpoint

+
The list of changes since 06:33: src/cache.ts and src/router.ts modified and src/version.ts added, with their added and removed line counts.
+

Changes: Set Checkpoint remembers every uncommitted file. After the agent's next turn, Since Checkpoint… lists only what changed after that moment, each file opening as a diff from the checkpoint to now. Kept in memory only, never on disk.

+

Peek at HEAD

A peek popup under a changed block in router.ts, showing the single line HEAD had where there are now three.
diff --git a/scripts/shots.list.ts b/scripts/shots.list.ts index 2171692..a7130a2 100644 --- a/scripts/shots.list.ts +++ b/scripts/shots.list.ts @@ -49,7 +49,7 @@ export const SHOTS: Shot[] = [ { name: "commands", tabs: ["src/router.ts"], - steps: [s(`mod('p', { shiftKey: true }); const i = await until(() => visible('.palette-input')); typeInto(i, '>git');`, 500)], + steps: [s(`mod('p', { shiftKey: true }); const i = await until(() => visible('.palette-input')); typeInto(i, '>changes');`, 500)], crop: `return rect('.palette', 0);`, }, { @@ -83,6 +83,40 @@ export const SHOTS: Shot[] = [ ], crop: `return box(0, 0, 1280, 640);`, }, + { + // Risky edits made from the terminal: a new CI workflow, chmod +x, a manifest change, and a + // comment carrying invisible Unicode tag characters (escaped here: no raw ones in the source). + name: "flags", + tabs: ["src/router.ts"], + layout: { panel: true, panelSize: { bottom: 250, right: 560 } }, + term: [ + `mkdir -p .github/workflows && printf 'name: deploy\\non: push\\n' > .github/workflows/deploy.yml`, + `chmod +x src/server.ts && perl -pi -e 's/"0.4.0"/"0.5.0"/' package.json`, + String.raw`perl -CSD -pi -e 's{^(\s*)(routes\.set\("DELETE)}{$1 . "// Clears finished jobs." . join("", map { chr(0xE0000 + ord) } split //, "skip review") . "\n" . $1 . $2}e' src/router.ts`, + ], + steps: [ + s(`mod('j');`, 400), + s(`mod('g', { shiftKey: true }); await until(() => visible('.ch-row .risk'));`, 900), + s(`await cmd('Go to Next Hidden Character');`, 900), + ], + crop: `return box(0, 0, 1280, Math.round(lineEl(24).getBoundingClientRect().bottom + 1));`, + }, + { + // Set a checkpoint, let the terminal change two files and add one, then list what changed since. + name: "checkpoint", + tabs: ["src/router.ts"], + layout: { panel: true, panelSize: { bottom: 250, right: 560 } }, + steps: [ + s(`await until(() => $$('.term-input').length); await sleep(1200); await cmd('Changes: Set Checkpoint');`, 1500), + s(`const t = $$('.term-input').at(-1); t.focus(); const dt = new DataTransfer(); + dt.setData('text/plain', ${JSON.stringify( + String.raw`perl -pi -e 's/1-64/1-128/; s/> 64/> 128/' src/router.ts && perl -pi -e 's/Removes every entry\./Removes every entry; the next get misses./' src/cache.ts && printf 'export const VERSION = "0.5.0";\n' > src/version.ts`, + )}); + t.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); key('Enter', {}, t);`, 1800), + s(`await cmd('Since Checkpoint'); await until(() => visible('.pal-item'));`, 700), + ], + crop: `return rect('.palette', 0);`, + }, { name: "peek", tabs: ["src/router.ts"], diff --git a/scripts/site.ts b/scripts/site.ts index 28f9d68..2bd5b47 100644 --- a/scripts/site.ts +++ b/scripts/site.ts @@ -110,7 +110,7 @@ const fileName = (id: string) => `${esc(placeholder(id)) const FEATURES: [string, string, string][] = [ ["terminal", "A real terminal, built in", "A native PTY and a from-scratch xterm-grade emulator: truecolor, mouse, alternate screen, bracketed paste and crisp box drawing. Run Claude Code, Codex, aider, vim or htop right next to the code, and hand the agent a path:12-40 reference: ⌥⌘C copies it, the palette types it at the prompt."], ["live", "Live while agents work", "Files reload in place as they change on disk. Changed lines flash, the explorer marks them, and nothing you are reading jumps."], - ["git", "Git, read-only", "Status letters in the explorer, gutter markers against HEAD with a peek at the old lines, inline blame, branch, ahead/behind and merge state, a Changes view that lists every file the branch touched as read-only diffs, and the history of any file, one commit at a time. It never writes to your repository."], + ["git", "Git, read-only", "Status letters in the explorer, gutter markers against HEAD with a peek at the old lines, inline blame, branch, ahead/behind and merge state, a Changes view that lists every file the branch touched as read-only diffs, flags risky edits and hidden characters, steps through what you have not reviewed yet and shows what changed since a checkpoint, and the history of any file, one commit at a time. It never writes to your repository."], ["fast", "Fast at any size", "Virtualized everything: 100k-line files open instantly, quick-open searches 200k paths as you type, and the terminal streams megabytes per second."], ["lock", "Locked down", "The window can only reach its own local server. Header-token auth, DNS-rebinding and CSRF guards, read-only file access with symlink containment, git hardened against hostile repositories and signed updates. Reviewed adversarially, round after round."], ["native", "Native and small", "A small native Mac app on the system WebKit, with real menus, window tabs and a folder picker. Built with Bun and TypeScript and zero third-party dependencies, so there is no supply chain to audit but this repository."], @@ -159,8 +159,8 @@ const TOUR: Chapter[] = [ { shot: "quick-open", title: "Go to file", - body: "Fuzzy-matches file names first, then whole paths. Type path:line to land on a line; an empty box lists recent files.", - keys: ["⌘P"], + body: "Fuzzy-matches file names first, then whole paths. Type path:line to land on a line; an empty box lists recent files. After any jump, ⌃- takes you back to where you were and ⌃⇧- forward again (Ctrl+Alt+- and Ctrl+Shift+- on Linux).", + keys: ["⌘P", "⌃- / ⌃⇧-"], alt: "Go to file with “ca” typed, listing cache.ts, cache.test.ts and other matches with the matched letters highlighted.", }, { @@ -175,7 +175,7 @@ const TOUR: Chapter[] = [ title: "Command palette", body: "Every command with its shortcut: terminals, panels, theme, font size, git, references, updates. The same box takes > for commands, : for a line and @ for symbols.", keys: ["⌘⇧P"], - alt: "The command palette filtered by “git”: Next Change, Show Changes, File History, Previous Change, Peek HEAD on Current Line, Compare With Base Branch / HEAD and Toggle Automatic Update Checks, with their shortcuts.", + alt: "The command palette filtered by “changes”: Set Checkpoint, Since Checkpoint…, Show Changes, Clear Checkpoint, Next and Previous Unreviewed File and Clear Change Markers, with their shortcuts.", }, { shot: "find", @@ -244,11 +244,23 @@ const TOUR: Chapter[] = [ lead: { shot: "changes", title: "Review the branch", - body: "Lists every file the branch touched since it left main, or only what is uncommitted, with +/− counts. Each file opens as a read-only diff. ⌥F5 walks hunk by hunk and on to the next file, and reviewed files dim until they change again.", - keys: ["⌘⇧G", "⌥F5 / ⌥⇧F5"], + body: "Lists every file the branch touched since it left main, or only what is uncommitted, with +/− counts. Each file opens as a read-only diff. ⌥F5 walks hunk by hunk and on to the next file. Reviewed files dim until they change again, and ⌥F6 skips straight to the next file you have not reviewed.", + keys: ["⌘⇧G", "⌥F5 / ⌥⇧F5", "⌥F6 / ⌥⇧F6"], alt: "The Changes view comparing the branch with main: six files with their status letters and line counts, and cache.ts open as a unified diff with added and removed lines.", }, items: [ + { + shot: "flags", + title: "Risk flags and hidden characters", + body: "Badges mark the edits worth a second look: an executable bit or symlink, CI workflows, git hooks, lockfiles, manifests, .env and registry files, and invisible Unicode (zero-width, tag and variation characters, bidi controls). The code view outlines each hidden character in place, and Go to Next Hidden Character walks them. Hints, never a verdict.", + alt: "The Changes view with 4 flagged files: deploy.yml in .github/workflows marked CI, package.json manifest, router.ts hidden and server.ts exec. router.ts is open on a comment that ends in a red marker where invisible tag characters hide text.", + }, + { + shot: "checkpoint", + title: "Changes since a checkpoint", + body: "Changes: Set Checkpoint remembers every uncommitted file. After the agent's next turn, Since Checkpoint… lists only what changed after that moment, each file opening as a diff from the checkpoint to now. Kept in memory only, never on disk.", + alt: "The list of changes since 06:33: src/cache.ts and src/router.ts modified and src/version.ts added, with their added and removed line counts.", + }, { shot: "peek", title: "Peek at HEAD", diff --git a/src/client/api.ts b/src/client/api.ts index 604663e..dc9e9d2 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -77,6 +77,11 @@ export interface ChangeFile { added: number | null; removed: number | null; binary: boolean; + /** Git octal modes ("000000" when absent on that side; untracked entries carry only newMode). */ + oldMode?: string; + newMode?: string; + /** Count of hidden or invisible characters in the current content; absent when none. */ + hidden?: number; } /** One commit of a file's history (see src/server/git.ts HistoryEntry). */ @@ -114,10 +119,20 @@ export interface UpdateInfo { checkedAt: number; } +/** A failed request: the server's message and its HTTP status. */ +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + async function check(res: Response): Promise { if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(body.error ?? res.statusText); + throw new ApiError(body.error ?? res.statusText, res.status); } return res; } diff --git a/src/client/app.ts b/src/client/app.ts index c53b198..c7da05e 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,5 +1,6 @@ import { wsUrl } from "./auth.ts"; // first: installs the auth header on fetch import { api, type Info } from "./api.ts"; +import { CHECKPOINT_COMMANDS, CheckpointController, checkpointText } from "./checkpointctl.ts"; import { Editor, type StatusInfo } from "./editor.ts"; import { FileTree } from "./tree.ts"; import { Palette, type Command, type PickItem } from "./palette.ts"; @@ -16,9 +17,12 @@ import { isContextMenuOpen } from "./menu.ts"; import { SearchView } from "./search.ts"; import { ChangesView } from "./changes.ts"; import type { ChangeFile } from "./api.ts"; -import { helpKey, normSideView, referenceKey, searchKey, symbolKey, type SideView } from "./keys.ts"; +import { Navigation, routeNavZoomKey } from "./navigation.ts"; +import { helpKey, normSideView, referenceKey, searchKey, symbolKey, unreviewedKey, type SideView } from "./keys.ts"; +import { liveAnnouncer, runUnreviewed, UNREVIEWED_COMMANDS } from "./unreviewed.ts"; import { reference, referenceWithCode, unsafePath } from "./reference.ts"; -import { headingText } from "./symbols.ts"; +import { headingText, type Sym } from "./symbols.ts"; +import { createHiddenNav } from "./hiddennav.ts"; import { slugify } from "./markdown.ts"; @@ -92,6 +96,28 @@ class App { private statusRight!: HTMLElement; private themeBtn!: HTMLButtonElement; private live!: HTMLElement; + private checkpoints = new CheckpointController({ + changes: () => api.gitChanges("head"), + readText: (p) => checkpointText(() => api.file(p)), + baseText: (p, rev) => api.gitBase(p, rev), + now: () => Date.now(), + notify: (m) => this.notify(m), + pick: (items, title) => this.palette.pick(items, title), + openDiff: (d) => void this.editor.openSinceDiff(d, { focus: true }), + }); + private nav = new Navigation({ + open: (loc) => this.editor.open(loc.path, loc.view ? { pin: true, view: loc.view, focus: false } : { pin: true, line: loc.line, focus: false }).then((ok) => (void this.tree.reveal(loc.path), ok)), + current: () => this.editor.location(), + announce: (m) => (this.live.textContent = m), + notify: (m) => this.notify(m), + }); + private hiddenNav = createHiddenNav({ + lines: () => this.editor.active?.code?.lineTexts ?? null, + position: () => ({ line: this.editor.location()?.line ?? 1 }), + reveal: (l) => this.editor.active?.code?.revealLine(l), + announce: (m) => (this.live.textContent = m), + toast: (m) => this.notify(m), + }); private killArmed = 0; private help = new Help(); private sideSplit!: HTMLElement; @@ -149,7 +175,7 @@ class App { if (tabs.active) await this.editor.open(tabs.active, { focus: false }); this.editor.onChange = () => { store.set(`tabs:${this.info.root}`, this.editor.serialize()); - this.changes.watching = this.editor.tabs.some((t) => t.isDiff && !t.commit); + this.changes.watching = this.editor.tabs.some((t) => t.isDiff && !t.commit && !t.since); }; } @@ -190,13 +216,13 @@ class App { // Sidebar this.tree = new FileTree(this.info.name); - this.tree.onOpen = (p, pin) => void this.openFile(p, { pin, focus: pin }); + this.tree.onOpen = (p, pin) => void this.jump(p, { pin, focus: pin }); this.search = new SearchView(); this.tree.onSearchIn = (dir, ignored) => this.search.searchIn(dir, ignored); this.tree.onViewChanges = (p) => void this.viewChanges(p); this.tree.onHistory = (p) => void this.fileHistory(p); this.search.onReveal = () => this.showSearch(); - this.search.onOpen = (path, line, col, len) => void this.openFile(path, { line, col, len, focus: false }); + this.search.onOpen = (path, line, col, len) => void this.jump(path, { line, col, len, focus: false }); this.changes = new ChangesView(); this.changes.onOpen = (f, pin) => void this.openDiff(f, pin); this.changes.onData = (d) => { @@ -363,8 +389,8 @@ class App { }); this.palette.recent = this.recent; - this.palette.onOpenFile = (p, line) => void this.openFile(p, { pin: true, line }); - this.palette.onGoToLine = (n) => this.editor.goToLine(n); + this.palette.onOpenFile = (p, line) => void this.jump(p, { pin: true, line }); + this.palette.onGoToLine = (n) => this.jumpToLine(n); this.palette.symbols = () => this.symbolItems(); this.palette.commands = this.commands(); @@ -389,6 +415,8 @@ class App { c("view.theme", "Cycle Theme (System/Dark/Light)", () => this.cycleTheme()), c("view.zoomIn", "Increase Font Size", () => this.zoom(1), `${MOD}=`), c("view.zoomOut", "Decrease Font Size", () => this.zoom(-1), `${MOD}-`), + c("nav.back", "Go Back", () => this.navigate("back"), key("⌃-", "Ctrl+Alt+-")), + c("nav.forward", "Go Forward", () => this.navigate("forward"), key("⌃⇧-", "Ctrl+Shift+-")), c("view.zoomReset", "Reset Font Size", () => this.zoom(0), `${MOD}0`), c("search.files", "Search in Files", () => this.showSearch(), IS_MAC ? "⌘⇧F" : "Ctrl+Shift+F"), c("search.next", "Next Search Result", () => this.search.step(1), "F4"), @@ -423,6 +451,7 @@ class App { if (!t) this.notify("Open a file first"); else void this.fileHistory(t.path); }), + ...CHECKPOINT_COMMANDS.map((k) => c(k.id, k.title, () => void (k.id === "changes.checkpoint" ? this.checkpoints.set() : k.id === "changes.sinceCheckpoint" ? this.checkpoints.since() : this.checkpoints.clear()))), 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", () => { @@ -430,8 +459,10 @@ class App { store.set("updates", { auto }); this.notify(`Automatic update checks ${auto ? "on" : "off"} (from the next launch)`); }), + c("file.nextHidden", "Go to Next Hidden Character", () => this.hiddenNav.next()), c("git.next", "Git: Next Change", () => this.editor.nextChange(1), key("⌥F5", "Alt+F5")), c("git.prev", "Git: Previous Change", () => this.editor.nextChange(-1), key("⌥⇧F5", "Alt+Shift+F5")), + ...UNREVIEWED_COMMANDS.map((u) => c(u.id, u.title, () => this.stepUnreviewed(u.dir), u.dir === 1 ? key("⌥F6", "Alt+F6") : key("⌥⇧F6", "Alt+Shift+F6"))), ]; } @@ -442,6 +473,26 @@ class App { void this.tree.reveal(path); } + /** Opens a file as a jump the Back / Forward history remembers. */ + private async jump(path: string, opts: { pin?: boolean; line?: number; col?: number; len?: number; focus?: boolean } = {}) { + const from = this.editor.location(); + await this.openFile(path, opts); + if (from?.path === path && !opts.line) return; // re-showing the open file is not a jump + if (this.editor.location()?.path === path) this.nav.record(from, this.editor.landing(path, opts.line)); + } + + private jumpToLine(n: number) { + const from = this.editor.location(); + this.editor.goToLine(n); + if (from) this.nav.record(from, { path: from.path, line: n }); + } + + private jumpToSymbol(s: Sym, slug?: string) { + const from = this.editor.location(); + this.editor.revealSymbol(s, slug); + if (from) this.nav.record(from, { path: from.path, line: s.line + 1 }); + } + private async openFromTerminal(p: string, line: number) { let rel = p; if (rel.startsWith(this.info.root + "/")) rel = rel.slice(this.info.root.length + 1); @@ -452,7 +503,7 @@ class App { if (c.startsWith("/") || c.startsWith("~")) break; const type = await api.stat(c); if (type === "file") { - await this.openFile(c, { pin: true, line, focus: false }); + await this.jump(c, { pin: true, line, focus: false }); return; } if (type === "dir") { @@ -472,6 +523,13 @@ class App { this.changes.focus(); } + /** ⌥F6 / ⌥⇧F6: step to the next / previous unreviewed changed file and announce it. */ + private stepUnreviewed(dir: 1 | -1) { + if (!this.git.repo) return this.notify("Not a git repository"); + this.showChanges(); + runUnreviewed(dir, { view: this.changes, announce: liveAnnouncer(this.live) }); + } + private async openDiff(f: ChangeFile, pin: boolean, focus = false) { const d = this.changes.data; await this.editor.openDiff(f, { rev: d?.rev ?? null, vs: this.changes.vs }, { pin, focus }); @@ -508,7 +566,7 @@ class App { detail: `${s.kind} · line ${s.line + 1}`, icon: ``, indent: depths.indexOf(s.depth), - run: () => this.editor.revealSymbol(s, slug), + run: () => this.jumpToSymbol(s, slug), }; }); } @@ -614,6 +672,10 @@ class App { this.applyLayout(); } + private navigate(dir: "back" | "forward") { + void this.nav.go(dir); + } + private zoom(d: number) { const L = this.L; if (d === 0) { @@ -769,7 +831,9 @@ class App { const appKey = IS_MAC || !inTerm; let handled = true; const sk = searchKey(e, { isMac: IS_MAC, inTerm: !!inTerm }); - if (sk === "search") this.showSearch(); + if (routeNavZoomKey(e, { isMac: IS_MAC, inTerm: !!inTerm }, { navigate: (d) => this.navigate(d), zoom: (d) => this.zoom(d) })) { + // Go Back / Go Forward and the zoom keys + } else if (sk === "search") this.showSearch(); else if (sk === "next" || sk === "prev") this.search.step(sk === "next" ? 1 : -1); else if (e.ctrlKey && (e.code === "Backquote" || k === "`")) { if (e.shiftKey) this.newTerminal(); @@ -779,6 +843,7 @@ class App { else if (symbolKey(e, { isMac: IS_MAC }) && appKey) this.palette.open("@"); else if (referenceKey(e, { isMac: IS_MAC }) && !inTerm) this.copyReference(false); else if (e.altKey && e.key === "F5" && !inTerm) this.editor.nextChange(e.shiftKey ? -1 : 1); + else if (unreviewedKey(e, { inTerm: !!inTerm }) !== null) this.stepUnreviewed(unreviewedKey(e, { inTerm: !!inTerm })!); else if (mod && e.shiftKey && k === "p") this.palette.open(">"); else if (mod && !e.shiftKey && k === "p" && appKey && (IS_MAC || !inPalette)) this.palette.open(); // Linux: Ctrl+P is the palette's own "up" else if (mod && e.shiftKey && k === "e") { @@ -806,9 +871,6 @@ class App { else this.editor.cycle(dir); } else if (e.ctrlKey && e.key === "Tab" && !inTerm) this.editor.cycle(e.shiftKey ? -1 : 1); - else if (mod && (k === "=" || k === "+") && appKey) this.zoom(1); - else if (mod && k === "-" && appKey) this.zoom(-1); - else if (mod && k === "0" && appKey) this.zoom(0); else if (mod && k === "j" && appKey) this.togglePanel(); else if (helpKey(e, { isMac: IS_MAC }) && appKey) this.help.open(); else if (e.key === "Escape" && this.help.isOpen) this.help.close(); @@ -864,7 +926,7 @@ class App { } else if (msg.t === "git" && msg.status) { this.git.set(msg.status as never); } else if (msg.t === "open" && msg.path) { - void this.openFile(msg.path, { pin: true, line: msg.line, focus: false }); + void this.jump(msg.path, { pin: true, line: msg.line, focus: false }); window.focus(); } }; diff --git a/src/client/changes.ts b/src/client/changes.ts index 1460d11..d0aed0f 100644 --- a/src/client/changes.ts +++ b/src/client/changes.ts @@ -1,5 +1,6 @@ import { api, type ChangeFile, type ChangesData } from "./api.ts"; import type { Letter } from "./git.ts"; +import { riskFlags, riskLabel, type RiskFlag } from "./risk.ts"; import { basename, dirname, esc, h, showHidden } from "./util.ts"; export type Scope = "head" | "branch"; @@ -41,6 +42,30 @@ export function summarize(files: readonly ChangeFile[]): { added: number; remove /** 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 ?? ""}`; +/** Index of the next path that is not reviewed, searching from `current` in `dir` with wrap-around; -1 when none. */ +export function nextUnreviewed( + paths: readonly string[], current: string | null, dir: 1 | -1, + isReviewed: (path: string) => boolean, +): number { + const n = paths.length; + if (!n) return -1; + const c = current === null ? -1 : paths.indexOf(current); + if (c < 0) { + for (let k = 0; k < n; k++) { + const i = dir === 1 ? k : n - 1 - k; + if (!isReviewed(paths[i]!)) return i; + } + return -1; + } + for (let k = 1; k <= n; k++) { + const i = (((c + dir * k) % n) + n) % n; // k = n lands on c itself: checked last + if (!isReviewed(paths[i]!)) return i; + } + return -1; +} + +const BADGE: Record = { exec: "exec", symlink: "symlink", ci: "CI", hook: "hook", lockfile: "lock", manifest: "manifest", env: "env", hidden: "hidden" }; + const safe = (s: string) => esc(showHidden(s)); const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; @@ -74,12 +99,18 @@ export class ChangesView { private waiters: (() => void)[] = []; private scopeEl: HTMLElement; private summary: HTMLElement; + // Built once: the live region must persist so screen readers announce a changed count. + private countsEl = h("span", { class: "ch-counts" }); + private riskEl = h("span", { class: "ch-riskcount", role: "status" }); + private vsEl = h("span", { class: "ch-vs" }); + private barEl = h("div", { class: "ch-bar empty", style: "--p:50%" }); 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.summary.append(h("div", { class: "ch-summary" }, this.countsEl, this.riskEl, this.vsEl), this.barEl); 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) => { @@ -108,6 +139,12 @@ export class ChangesView { this.render(); } + /** Risk flags of the listed row with this path ([] for an unknown path or an unflagged row). */ + flags(path: string): RiskFlag[] { + const f = this.files.find((x) => x.path === path); + return f ? riskFlags(f) : []; + } + get scopeName(): Scope { return this.scope; } @@ -251,6 +288,26 @@ export class ChangesView { return f; } + /** Opens the next/previous file that is not reviewed (wrapping); null when every file is reviewed. */ + stepUnreviewed(dir: 1 | -1): ChangeFile | null { + const next = nextUnreviewed(this.files.map((x) => x.path), this.current, dir, (p) => this.isSeen(p)); + if (next < 0) return null; + const f = this.files[next]!; + this.select(next); + this.onOpen(f, false); + return f; + } + + private isSeen(path: string): boolean { + const f = this.files.find((x) => x.path === path); + return !!f && this.seen.get(path) === seenKey(f); + } + + /** Rows in the current list that are not reviewed. */ + unreviewedCount(): number { + return this.files.filter((f) => this.seen.get(f.path) !== seenKey(f)).length; + } + focus() { if (this.sel < 0) this.sel = Math.max(0, this.files.findIndex((f) => f.path === this.current)); this.render(); @@ -318,12 +375,15 @@ export class ChangesView { const commits = d.scope === "branch" && d.commits ? `·${plural(d.commits, "commit", "commits")}` : ""; + const flagged = files.filter((f) => riskFlags(f).length).length; 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)}
` + - `
`; + this.countsEl.innerHTML = + `${files.length} ${files.length === 1 ? "file" : "files"}` + + `·+${added.toLocaleString()} −${removed.toLocaleString()}${commits}`; + this.riskEl.textContent = flagged ? `· ${flagged} flagged` : ""; + this.vsEl.innerHTML = safe(this.vs); + this.barEl.setAttribute("class", `ch-bar${added + removed ? "" : " empty"}`); + this.barEl.setAttribute("style", `--p:${p}%`); 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)}.` @@ -352,9 +412,12 @@ export class ChangesView { : f.added === null ? `—` : `${f.added ? `+${f.added}` : ""}${f.removed ? `−${f.removed}` : ""}`; + const risks = riskFlags(f) + .map((r) => `${BADGE[r]}`) + .join(""); html += `
` + - `${letter}${name}${dir}${counts}
`; + `${letter}${name}${dir}${cls.includes("seen") ? `, reviewed` : ""}${risks}${counts}
`; }); if (d.truncated) html += `
Showing the first ${files.length.toLocaleString()} files
`; this.list.innerHTML = html; diff --git a/src/client/checkpoint.ts b/src/client/checkpoint.ts new file mode 100644 index 0000000..5352efd --- /dev/null +++ b/src/client/checkpoint.ts @@ -0,0 +1,72 @@ +// Changes since a checkpoint: pure helpers, no DOM and no network. +import { diffLines } from "./diff.ts"; + +export interface Checkpoint { + at: number; + rev: string | null; + texts: Map; + skipped: string[]; +} +export interface SinceEntry { + path: string; + status: "A" | "M" | "D"; + added: number; + removed: number; +} + +const MAX_FILES = 500; +const MAX_CHARS = 16_000_000; + +/** Remembers the text of every file that differs from HEAD now, within the file and character caps. */ +export function takeCheckpoint( + files: readonly { path: string; text: string | null }[], + at: number, + rev: string | null, + limits?: { files?: number; chars?: number }, +): Checkpoint { + const maxFiles = limits?.files ?? MAX_FILES; + const maxChars = limits?.chars ?? MAX_CHARS; + const texts = new Map(); + const skipped: string[] = []; + let chars = 0; + for (const f of files) { + const n = f.text === null ? 0 : f.text.length; + if (texts.size < maxFiles && chars + n <= maxChars) { + texts.set(f.path, f.text); + chars += n; + } else skipped.push(f.path); + } + return { at, rev, texts, skipped }; +} + +function linesOf(text: string | null): string[] { + if (text === null) return []; + const l = text.split("\n"); + if (l[l.length - 1] === "") l.pop(); + return l; +} + +/** What changed in each file between the checkpoint and now. */ +export function sinceCheckpoint( + cp: Checkpoint, + current: ReadonlyMap, + base: (path: string) => string | null | undefined, +): SinceEntry[] { + const skipped = new Set(cp.skipped); + const paths = new Set([...current.keys(), ...cp.texts.keys()]); + const out: SinceEntry[] = []; + for (const path of [...paths].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) { + if (skipped.has(path)) continue; + const old = cp.texts.has(path) ? cp.texts.get(path) : base(path); + const now = current.has(path) ? current.get(path) : base(path); + if (old === undefined || now === undefined || old === now) continue; + let added = 0; + let removed = 0; + for (const h of diffLines(linesOf(old), linesOf(now))) { + added += h.b1 - h.b0; + removed += h.a1 - h.a0; + } + out.push({ path, status: old === null ? "A" : now === null ? "D" : "M", added, removed }); + } + return out; +} diff --git a/src/client/checkpointctl.ts b/src/client/checkpointctl.ts new file mode 100644 index 0000000..df03398 --- /dev/null +++ b/src/client/checkpointctl.ts @@ -0,0 +1,165 @@ +// The controller behind the three "Changes: ... Checkpoint" commands. app.ts only wires it up. +import type { ChangeFile } from "./api.ts"; +import type { PickItem } from "./palette.ts"; +import { sinceCheckpoint, takeCheckpoint, type Checkpoint } from "./checkpoint.ts"; +import { showHidden } from "./util.ts"; + +export const CHECKPOINT_COMMANDS = [ + { id: "changes.checkpoint", title: "Changes: Set Checkpoint" }, + { id: "changes.sinceCheckpoint", title: "Changes: Since Checkpoint\u2026" }, + { id: "changes.clearCheckpoint", title: "Changes: Clear Checkpoint" }, +] as const; + +/** + * Text for a checkpoint: the string; null = the file vanished (a 404); undefined = binary, truncated, + * too large or otherwise unreadable. + */ +export async function checkpointText( + read: () => Promise<{ kind: string; truncated: boolean; text: string }>, +): Promise { + try { + const f = await read(); + return f.kind === "text" && !f.truncated ? f.text : undefined; + } catch (e) { + return typeof e === "object" && e !== null && (e as { status?: unknown }).status === 404 ? null : undefined; + } +} + +export interface SinceDiff { + path: string; + status: "A" | "M" | "D"; + /** null = the file does not exist on that side. */ + oldText: string | null; + newText: string | null; + /** "HH:MM" of the checkpoint. */ + since: string; +} + +export interface CheckpointDeps { + changes(): Promise<{ repo: boolean; rev: string | null; files: ChangeFile[] }>; + /** Current text; null = missing; undefined = binary, too large or unreadable. */ + readText(path: string): Promise; + baseText(path: string, rev: string): Promise; + now(): number; + notify(message: string): void; + pick(items: PickItem[], title: string): void; + openDiff(d: SinceDiff): void; +} + +type Listing = { repo: boolean; rev: string | null; files: ChangeFile[] }; + +export class CheckpointController { + private cp: Checkpoint | null = null; + private gen = 0; + constructor(private deps: CheckpointDeps) {} + get checkpoint(): Checkpoint | null { + return this.cp; + } + + private async list(): Promise { + let l: Listing; + try { + l = await this.deps.changes(); + } catch { + this.deps.notify("Could not read the changes"); + return null; + } + if (!l.repo) { + this.deps.notify("Not a git repository"); + return null; + } + return l; + } + + /** Current texts of the listed files; binary and unreadable ones are returned apart. */ + private async read(files: ChangeFile[]) { + const listed = new Set(files.map((f) => f.path)); + const out: { path: string; text: string | null }[] = []; + const unreadable: string[] = []; + for (const f of files) { + // A rename is a delete plus an add: the old path is gone whatever the new file turns out to be. + if (f.status === "R" && f.oldPath !== undefined && f.oldPath !== f.path && !listed.has(f.oldPath)) out.push({ path: f.oldPath, text: null }); + if (f.binary) { + unreadable.push(f.path); + continue; + } + let text = f.status === "D" ? null : await this.deps.readText(f.path); + // The server answers 404 for a symlink that leaves the root as well as for a missing file, so a + // symlink's 404 does not mean it vanished: it is skipped. + if (text === null && f.status !== "D" && f.newMode === "120000") text = undefined; + if (text === undefined) { + unreadable.push(f.path); + continue; + } + out.push({ path: f.path, text }); + } + return { out, unreadable }; + } + + async set(): Promise { + const gen = ++this.gen; + const l = await this.list(); + if (!l || gen !== this.gen) return; + const at = this.deps.now(); + const { out, unreadable } = await this.read(l.files); + if (gen !== this.gen) return; + const t = takeCheckpoint(out, at, l.rev); + const cp = { ...t, skipped: [...t.skipped, ...unreadable] }; + this.cp = cp; + const n = cp.texts.size; + const m = cp.skipped.length; + this.deps.notify(m ? `Checkpoint set (${n} files, ${m} skipped)` : `Checkpoint set (${n} files)`); + } + + async since(): Promise { + const cp = this.cp; + if (!cp) return this.deps.notify("No checkpoint set"); + const l = await this.list(); + if (!l) return; + const { out, unreadable } = await this.read(l.files); + const current = new Map(out.map((o) => [o.path, o.text])); + const derived: Checkpoint = { ...cp, skipped: [...cp.skipped, ...unreadable] }; + const skip = new Set(derived.skipped); + const base = new Map(); + try { + for (const p of new Set([...current.keys(), ...cp.texts.keys()])) { + if (skip.has(p) || (cp.texts.has(p) && current.has(p))) continue; + base.set(p, cp.rev === null ? null : await this.deps.baseText(p, cp.rev)); + } + } catch { + if (this.cp === cp) this.deps.notify("Could not compare with the checkpoint"); + return; + } + if (this.cp !== cp) return; + const baseOf = (p: string) => base.get(p); + const entries = sinceCheckpoint(derived, current, baseOf); + const since = hhmm(cp.at); + if (!entries.length) return this.deps.notify(`Nothing changed since the checkpoint (${since})`); + const items: PickItem[] = entries.map((e) => ({ + label: showHidden(e.path), + detail: `+${e.added} \u2212${e.removed}`, + icon: ``, + run: () => + this.deps.openDiff({ + path: e.path, + status: e.status, + since, + oldText: cp.texts.has(e.path) ? cp.texts.get(e.path)! : baseOf(e.path)!, + newText: current.has(e.path) ? current.get(e.path)! : baseOf(e.path)!, + }), + })); + this.deps.pick(items, `Changes since ${since}: ${items.length} ${items.length === 1 ? "file" : "files"}, type to filter`); + } + + clear(): void { + this.gen++; // a set() still reading must not bring the checkpoint back + this.cp = null; + this.deps.notify("Checkpoint cleared"); + } +} + +/** Local time of day, 24-hour, zero padded. */ +export function hhmm(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} diff --git a/src/client/codeview.ts b/src/client/codeview.ts index ec0d763..749f29c 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -1,3 +1,4 @@ +import { classify, hiddenName } from "./hidden.ts"; import { Highlighter, type Tok } from "./highlight.ts"; import { dismissable, esc, h, isEditable } from "./util.ts"; import { icons } from "./icons.ts"; @@ -55,6 +56,47 @@ export function markBidi(html: string, budget: { left: number } = { left: MAX_BI }); } +/** + * Wraps each run of counted zero-width, tag and variation characters in `.hidden-char` (the same + * rules as scanHidden; bidi controls stay with markBidi). `ctx.prev` is the code point just before + * index 0, `ctx.atFileStart` says index 0 is offset 0 of the file (the only place a BOM is exempt). + */ +export function markHidden(html: string, budget: { left: number } = { left: MAX_BIDI_MARKS }, ctx: { prev?: number; atFileStart?: boolean } = {}): string { + if (!/[\u200b-\u2064\ufe00-\ufeff\ud800-\udbff]/.test(html)) return html; + let out = ""; + let prev = ctx.prev ?? -1; + let run: number[] = []; + const flush = () => { + if (!run.length) return; + const raw = String.fromCodePoint(...run); + if (budget.left <= 0) out += run.map((c) => (c > 0xffff ? `\\u{${c.toString(16)}}` : `\\u${c.toString(16).padStart(4, "0")}`)).join(""); + else { + budget.left--; + out += `${raw}`; + } + run = []; + }; + for (let i = 0, from = 0; i < html.length; ) { + const cp = html.codePointAt(i)!; + const w = cp > 0xffff ? 2 : 1; + const kind = classify(cp, prev, ctx.atFileStart && i === 0 ? 0 : 1); + if (kind && kind !== "bidi") { + if (!run.length) out += html.slice(from, i); + run.push(cp); + from = i + w; + } else { + flush(); + } + prev = cp; + i += w; + if (i >= html.length) { + flush(); + out += html.slice(from); + } + } + return out; +} + /** Bidi markers per rendered line (see markBidi). */ export const MAX_BIDI_MARKS = 32; /** Rows and columns the HEAD peek shows at most (it is not virtualized). */ @@ -675,7 +717,12 @@ export class CodeView { const bidi = { left: MAX_BIDI_MARKS }; for (let p = 1; p <= n; p++) { if (p === n || cls[p] !== cls[s] || (mark && mark[p] !== mark[s])) { - const seg = markBidi(esc(text.slice(s, p)), bidi); + let prev = i > 0 ? 0x0a : -1; + if (s > 0) { + const lo = text.charCodeAt(s - 1); + prev = lo >= 0xdc00 && lo <= 0xdfff && s > 1 ? text.codePointAt(s - 2)! : lo; + } + const seg = markBidi(markHidden(esc(text.slice(s, p)), bidi, { prev: prev < 0 ? undefined : prev, atFileStart: i === 0 && s === 0 }), bidi); const t = cls[s] ? TOKS[cls[s]! - 1] : ""; const m = mark?.[s]; let piece = t ? `${seg}` : seg; @@ -699,6 +746,17 @@ export class CodeView { } } + /** Topmost visible line (0-based). */ + firstVisibleLine(): number { + return Math.max(0, Math.floor(this.scroller.scrollTop / this.lh)); + } + + /** Last visible line (0-based, inclusive). */ + lastVisibleLine(): number { + const s = this.scroller; + return Math.max(this.firstVisibleLine(), Math.ceil((s.scrollTop + (s.clientHeight || 800)) / this.lh) - 1); + } + /** Scrolls so that 0-based `line` is visible (centered when far away). */ revealLine(line: number, select = true) { line = Math.max(0, Math.min(this.lines.length - 1, line)); diff --git a/src/client/editor.ts b/src/client/editor.ts index 3bdc224..1d18117 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -6,6 +6,8 @@ import { languageFor, languageName } from "./highlight.ts"; import { fileIcon, icons } from "./icons.ts"; import { isMarkdown } from "./markdown.ts"; import { MarkdownView } from "./mdview.ts"; +import type { NavLoc } from "./navhistory.ts"; +import type { SinceDiff } from "./checkpointctl.ts"; import { decidePreview, tooLargeNote } from "./mdgate.ts"; import { planView, toggleLabel, toggleMode, viewMode } from "./viewstate.ts"; import { basename, debounce, dirname, esc, formatBytes, h, MOD, showHidden } from "./util.ts"; @@ -30,6 +32,8 @@ export interface Tab { isDiff?: boolean; /** A diff of one commit from File History: fixed content, never tied to the Changes view. */ commit?: HistoryEntry; + /** "HH:MM" of the checkpoint a since-checkpoint diff compares against: fixed content, never tied to Changes. */ + since?: string; 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 }; @@ -118,7 +122,7 @@ export class Editor { this.render(); } - async open(path: string, opts: { pin?: boolean; line?: number; col?: number; len?: number; focus?: boolean } = {}) { + async open(path: string, opts: { pin?: boolean; line?: number; col?: number; len?: number; focus?: boolean; view?: "preview" } = {}): Promise { let tab = this.tabs.find((t) => t.path === path && !t.isDiff); if (!tab) { // A single unpinned "preview" tab is reused while browsing. @@ -141,6 +145,11 @@ export class Editor { } else { if (opts.pin) tab.pinned = true; this.activate(tab); + if (opts.view === "preview" && !opts.line && isMarkdown(path) && tab.asSource && tab.code) { + tab.asSource = false; + this.dropCode(tab); + await this.load(tab); + } if (opts.line && tab.md) await this.toSource(tab); if (opts.line && tab.code) { if (opts.col !== undefined && opts.len) tab.code.showRange(opts.line - 1, opts.col, opts.col + opts.len); @@ -149,6 +158,25 @@ export class Editor { } if (opts.focus !== false && this.active === tab) tab.code?.focus(); this.render(); + return tab.kind !== "error"; + } + + /** Where the user is: the active text or Markdown file and its current line (1-based). */ + location(): NavLoc | undefined { + const t = this.active; + if (!t || t.isDiff || t.kind === "error" || t.kind === "image" || t.kind === "dir") return undefined; + if (t.md && !t.code) return { path: t.path, line: 1, view: "preview" }; + const c = t.code; + if (!c) return { path: t.path, line: 1 }; + const inView = c.activeLine >= c.firstVisibleLine() && c.activeLine <= c.lastVisibleLine(); + return { path: t.path, line: c.activeLine >= 0 && inView ? c.activeLine + 1 : c.firstVisibleLine() + 1 }; + } + + /** The NavLoc for what `open(path, { line })` now shows. */ + landing(path: string, line?: number): NavLoc { + const t = this.tabs.find((x) => x.path === path && !x.isDiff); + if (!line && t?.md && !t.code) return { path, line: 1, view: "preview" }; + return { path, line: line ?? 1 }; } /** @@ -157,7 +185,7 @@ export class Editor { */ async openDiff(file: ChangeFile, ctx: DiffContext, opts: { pin?: boolean; focus?: boolean } = {}) { this.diffContext = ctx; - let tab = this.tabs.find((t) => t.isDiff && !t.commit && t.path === file.path); + let tab = this.tabs.find((t) => t.isDiff && !t.commit && !t.since && 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); @@ -190,7 +218,7 @@ export class Editor { refreshDiffs(ctx: DiffContext, files: readonly ChangeFile[]) { this.diffContext = ctx; for (const t of this.tabs) { - if (!t.isDiff || !t.file || t.commit) continue; + if (!t.isDiff || !t.file || t.commit || t.since) 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; @@ -202,7 +230,7 @@ export class Editor { /** 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.commit) t.diff?.setPosition(pos(t.path)); + for (const t of this.tabs) if (t.isDiff && !t.commit && !t.since) t.diff?.setPosition(pos(t.path)); } /** @@ -235,6 +263,39 @@ export class Editor { this.render(); } + /** Opens a read-only diff of two texts the caller already holds (changes since a checkpoint). */ + async openSinceDiff(d: SinceDiff, opts: { pin?: boolean; focus?: boolean } = {}): Promise { + let tab = this.tabs.find((t) => t.since === d.since && t.path === d.path); + if (!tab) { + const preview = !opts.pin ? this.tabs.find((t) => !t.pinned) : undefined; + tab = this.createTab(d.path, !!opts.pin, true); + tab.since = d.since; + tab.file = { path: d.path, status: d.status, added: null, removed: null, binary: false }; + 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); + } + } else if (opts.pin) tab.pinned = true; + this.activate(tab); + if (!tab.diff) { + const info: DiffInfo = { ...this.diffInfo(tab.file!), vs: `since ${d.since}` }; + const open = tab; + tab.diff = new DiffView(info, tab.lang, this.fontSize); + tab.diff.onOpenFile = () => void this.open(open.path, { pin: true }); + tab.diff.setPosition(null); // not in the Changes list: no "n/N" or next-file arrows + tab.el.replaceChildren(tab.diff.el); + } + tab.kind = "text"; + tab.diff.setTexts(d.oldText ?? "", d.newText ?? ""); + if (tab === this.active) this.status(); + if (opts.focus !== false) tab.diff.focus(); + this.render(); + } + /** "in 1a2b3c4" for a commit tab, else the Changes comparison ("vs main"). */ private vsOf(t: Tab): string { return t.commit ? `in ${t.commit.oid.slice(0, 7)}` : this.diffContext.vs; @@ -334,6 +395,7 @@ export class Editor { } private async load(tab: Tab, reload = false) { + if (tab.since) return undefined; // fixed content if (tab.commit) return reload ? undefined : this.loadCommit(tab); // a commit never changes if (tab.isDiff) return this.loadDiff(tab); const gen = ++tab.gen; @@ -630,7 +692,7 @@ export class Editor { const t = this.active; if (t?.diff) { // Past the last change a Changes diff moves on to the next file; a commit diff stays put. - if (!t.diff.nextChange(dir) && !t.commit) this.onDiffStep(dir); + if (!t.diff.nextChange(dir) && !t.commit && !t.since) this.onDiffStep(dir); return; } t?.code?.nextChange(dir); @@ -683,7 +745,7 @@ export class Editor { 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.vsOf(t)}`; + s.git = `${labelOf(t.file)} ${t.since ? `since ${t.since}` : this.vsOf(t)}`; s.diff = t.diff.stats; s.encoding = t.encoding; if (t.commit) s.size = undefined; // both sides come from git; the working file's size is beside the point @@ -734,9 +796,10 @@ export class Editor { t.tabEl.className = cls.join(" "); t.tabEl.title = showHidden(t.commit ? `${t.path} · ${t.commit.oid.slice(0, 7)} ${t.commit.subject} (${t.commit.author}, ${relativeTime(t.commit.time)})` + : t.since ? `${t.path} · ${g?.label} · changed since ${t.since}` : 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(showHidden(basename(t.path)))}${hint(t.path) ? `${esc(showHidden(hint(t.path)))}` : ""}${t.commit ? `${t.commit.oid.slice(0, 7)}` : t.isDiff ? 'diff' : ""}`; + t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(showHidden(basename(t.path)))}${hint(t.path) ? `${esc(showHidden(hint(t.path)))}` : ""}${t.since ? `since ${esc(showHidden(t.since))}` : t.commit ? `${t.commit.oid.slice(0, 7)}` : t.isDiff ? 'diff' : ""}`; t.tabEl.querySelector(".tab-close")!.addEventListener("click", (e) => { e.stopPropagation(); this.close(t); @@ -757,6 +820,7 @@ export class Editor { }); const c = this.active.commit; if (c) this.crumbs.append(h("span", { class: "pill", title: `${c.oid}\n${showHidden(c.author)}, ${relativeTime(c.time)}` }, `${c.oid.slice(0, 7)} · ${showHidden(c.subject)}`)); + else if (this.active.since) this.crumbs.append(h("span", { class: "pill" }, `since ${showHidden(this.active.since)}`)); else 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")); diff --git a/src/client/help.ts b/src/client/help.ts index 8b28f07..35f9ffb 100644 --- a/src/client/help.ts +++ b/src/client/help.ts @@ -26,6 +26,8 @@ export class Help { ...group("In a file", [ row(key("⌘F", "Ctrl+F"), "Find in file"), row(key("⌃G", "Ctrl+G"), "Go to line"), + row(key("⌃-", "Ctrl+Alt+-"), "Go back to the previous location (after a jump)"), + row(key("⌃⇧-", "Ctrl+Shift+-"), "Go forward again"), row(key("⌘⇧O", "Ctrl+Shift+O"), "Go to symbol (functions, classes, headings); or type @ in Go to file"), row(key("⌥⌘C", "Ctrl+Alt+C"), "Copy a path:line reference to the selected lines (palette: with code, or into the terminal)"), row(key("⌘⇧F", "Ctrl+Shift+F"), "Search in files"), @@ -35,6 +37,7 @@ export class Help { ...group("Git", [ row(key("⌘⇧G", "Ctrl+Shift+G"), "Changes: every file the branch touched, as diffs"), row(key("⌥F5 · ⌥⇧F5", "Alt+F5 · Alt+Shift+F5"), "Next / previous change (then the next file)"), + row(key("⌥F6 · ⌥⇧F6", "Alt+F6 · Alt+Shift+F6"), "Next / previous unreviewed changed file"), row("Git: File History", "Commits that changed the active file; pick one to see its diff (also in the explorer menu)"), row("click a gutter mark", "Peek at what HEAD had (or run \u201cGit: Peek HEAD\u201d from the palette)"), ]), diff --git a/src/client/hidden.ts b/src/client/hidden.ts new file mode 100644 index 0000000..b9dc25f --- /dev/null +++ b/src/client/hidden.ts @@ -0,0 +1,78 @@ +// Hidden-character scanner: zero-width, bidi, tag and variation characters that render as nothing +// (or reorder text) and are used to smuggle content. Pure, no DOM, one linear pass; the server +// imports it too. Results are hints, never verdicts. +export type HiddenKind = "zero-width" | "bidi" | "tag" | "variation"; +export interface HiddenHit { line: number; col: number; cp: number; kind: HiddenKind } + +const isVariation = (cp: number) => (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0xe0100 && cp <= 0xe01ef); + +/** True when a joiner after `prev` is ordinary (scripts that need it, emoji sequences). */ +function joinerExempt(prev: number): boolean { + return ( + (prev >= 0x0600 && prev <= 0x06ff) || + (prev >= 0x0750 && prev <= 0x077f) || + (prev >= 0x0900 && prev <= 0x0dff) || + prev === 0xfe0f || + prev >= 0x1f000 + ); +} + +/** The kind of a counted character, or undefined. `prev` is the code point just before (-1: none). */ +export function classify(cp: number, prev: number, offset: number): HiddenKind | undefined { + if (cp < 0x200b) return undefined; + if (cp === 0x200b || (cp >= 0x2060 && cp <= 0x2064)) return "zero-width"; + if (cp === 0x200c || cp === 0x200d) return prev >= 0 && joinerExempt(prev) ? undefined : "zero-width"; + if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069)) return "bidi"; + if (cp === 0xfeff) return offset === 0 ? undefined : "zero-width"; + if (cp >= 0xfe00 && cp <= 0xfe0f) return prev >= 0 && isVariation(prev) ? "variation" : undefined; + if (cp >= 0xe0000 && cp <= 0xe007f) return "tag"; + if (cp >= 0xe0100 && cp <= 0xe01ef) return "variation"; + return undefined; +} + +export function scanHidden(text: string, cap = 200): { count: number; hits: HiddenHit[] } { + const hits: HiddenHit[] = []; + let count = 0; + let line = 1; + let lineStart = 0; + let prev = -1; + for (let i = 0; i < text.length; ) { + const cp = text.codePointAt(i)!; + const width = cp > 0xffff ? 2 : 1; + if (cp === 0x0a) { + line++; + lineStart = i + 1; + } else { + const kind = classify(cp, prev, i); + if (kind) { + count++; + if (hits.length < cap) hits.push({ line, col: i - lineStart + 1, cp, kind }); + } + } + prev = cp; + i += width; + } + return { count, hits }; +} + +const hex = (cp: number) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"); + +export function hiddenName(cp: number): string { + let name = ""; + if (cp === 0x200b) name = "ZERO WIDTH SPACE"; + else if (cp === 0x200c) name = "ZERO WIDTH NON-JOINER"; + else if (cp === 0x200d) name = "ZERO WIDTH JOINER"; + else if (cp === 0x2060) name = "WORD JOINER"; + else if (cp >= 0x2061 && cp <= 0x2064) name = "INVISIBLE OPERATOR"; + else if (cp === 0xfeff) name = "BYTE ORDER MARK"; + else if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069)) name = "BIDI CONTROL"; + else if (cp >= 0xe0000 && cp <= 0xe007f) name = "TAG CHARACTER"; + else if (isVariation(cp)) name = "VARIATION SELECTOR"; + return name ? hex(cp) + " " + name : hex(cp); +} + +/** The first hit strictly after (line, col) in text order, wrapping to the first; undefined when none. */ +export function nextHidden(hits: readonly HiddenHit[], line: number, col: number): HiddenHit | undefined { + for (const h of hits) if (h.line > line || (h.line === line && h.col > col)) return h; + return hits[0]; +} diff --git a/src/client/hiddennav.ts b/src/client/hiddennav.ts new file mode 100644 index 0000000..86d9b4e --- /dev/null +++ b/src/client/hiddennav.ts @@ -0,0 +1,60 @@ +import { hiddenName, nextHidden, scanHidden, type HiddenHit } from "./hidden.ts"; + +// "Go to Next Hidden Character": walks the scanHidden hits of the active text file. The behaviour +// lives here (not in app.ts) so it can be tested without the app shell. +export interface HiddenNavHost { + /** The active text file's lines, or null when no text file is shown. */ + lines(): readonly string[] | null; + /** The editor's current position: 1-based line (as Editor.location() reports it), 1-based col when known. */ + position?(): { line: number; col?: number }; + /** Reveals a 0-based line. */ + reveal(line: number): void; + /** Polite live-region announcement. */ + announce(msg: string): void; + /** Visible toast. */ + toast(msg: string): void; +} + +export interface HiddenNav { + /** Moves to the next hidden character after the remembered position (wrapping) and announces it. */ + next(): void; + /** Forgets the remembered position (a freshly opened file starts at 0,0). */ + reset(): void; + /** Notes that the view revealed a 0-based line by other means (search, Go to Line). */ + seen(line: number): void; +} + +export function createHiddenNav(host: HiddenNavHost): HiddenNav { + let line = 0; // 1-based position of the last jump (0: none yet) + let col = 0; + let file: readonly string[] | null = null; + const reset = () => { + line = 0; + col = 0; + }; + return { + reset, + seen(l) { + line = l + 1; + col = 0; + }, + next() { + const lines = host.lines(); + if (file && lines !== file) reset(); + file = lines; + const p = host.position?.(); + if (p) { + // The editor owns the position (search, Go to Line, a click move it); the column of our own + // last jump is kept only while the editor is still on that line. + col = p.col ?? (p.line === line ? col : 0); + line = p.line; + } + const hit: HiddenHit | undefined = lines ? nextHidden(scanHidden(lines.join("\n")).hits, line, col) : undefined; + if (!hit) return void host.toast("No hidden characters in this file"); + line = hit.line; + col = hit.col; + host.reveal(hit.line - 1); + host.announce(`${hiddenName(hit.cp)} at line ${hit.line}, column ${hit.col}`); + }, + }; +} diff --git a/src/client/keys.ts b/src/client/keys.ts index dd303eb..8c73557 100644 --- a/src/client/keys.ts +++ b/src/client/keys.ts @@ -60,3 +60,21 @@ export function referenceKey(e: KeyLike, o: { isMac: boolean }): boolean { if (!mod || !e.altKey || e.shiftKey) return false; return e.key.toLowerCase() === "c" || (e.code === "KeyC" && !/^[a-z]$/i.test(e.key)); } + +/** + * Back / Forward through the jump list. macOS: Ctrl+- goes back, Ctrl+Shift+- forward (Cmd and + * Option stay free: Cmd+- is zoom out). Elsewhere: Ctrl+Alt+- back, Ctrl+Shift+- forward (plain + * Ctrl+- is zoom out). Never from a focused terminal. "_" is the shifted "-" on US layouts. + */ +export function navKey(e: KeyLike, o: { isMac: boolean; inTerm: boolean }): "back" | "forward" | null { + if (o.inTerm || !e.ctrlKey || e.metaKey || (e.key !== "-" && e.key !== "_")) return null; + if (e.shiftKey) return e.altKey ? null : "forward"; + if (e.key !== "-") return null; + return o.isMac ? (e.altKey ? null : "back") : e.altKey ? "back" : null; +} + +/** Alt+F6 / Alt+Shift+F6: next / previous unreviewed changed file (1 / -1). Never from a focused terminal. */ +export function unreviewedKey(e: KeyLike, o: { inTerm: boolean }): 1 | -1 | null { + if (o.inTerm || e.key !== "F6" || !e.altKey || e.ctrlKey || e.metaKey) return null; + return e.shiftKey ? -1 : 1; +} diff --git a/src/client/navhistory.ts b/src/client/navhistory.ts new file mode 100644 index 0000000..daf92a8 --- /dev/null +++ b/src/client/navhistory.ts @@ -0,0 +1,103 @@ +export interface NavLoc { + path: string; + line: number; // 1-based + /** Set when the location was a rendered Markdown preview. */ + view?: "preview"; +} + +const copy = (l: NavLoc): NavLoc => (l.view ? { path: l.path, line: l.line, view: l.view } : { path: l.path, line: l.line }); + +/** Gives `e` the line and view of `l` (the view key is removed when `l` has none). */ +const take = (e: NavLoc, l: NavLoc): void => { + e.line = l.line; + if (l.view) e.view = l.view; + else delete e.view; +}; + +/** Browser-style back/forward list: entries plus the index of the current one. */ +export class NavHistory { + private list: NavLoc[] = []; + private idx = -1; + private readonly cap: number; + private readonly near: number; + + constructor(opts?: { cap?: number; near?: number }) { + this.cap = opts?.cap ?? 100; + this.near = opts?.near ?? 10; + } + + record(from: NavLoc | undefined, to: NavLoc): void { + this.list.length = this.idx + 1; + if (from) { + const cur = this.list[this.idx]; + if (cur && cur.path === from.path) take(cur, from); + else this.push(from); + } + const cur = this.list[this.idx]; + if (cur && cur.path === to.path && Math.abs(cur.line - to.line) <= this.near) take(cur, to); + else this.push(to); + } + + back(current?: NavLoc): NavLoc | undefined { + if (!this.canBack()) return undefined; + return this.move(-1, current); + } + + forward(current?: NavLoc): NavLoc | undefined { + if (!this.canForward()) return undefined; + return this.move(1, current); + } + + canBack(): boolean { + return this.idx > 0; + } + + canForward(): boolean { + return this.idx < this.list.length - 1; + } + + drop(path: string): void { + const kept: NavLoc[] = []; + let newIdx = -1; + let firstSurvivorSeen = false; + this.list.forEach((e, i) => { + if (e.path === path) return; + const prev = kept[kept.length - 1]; + if (prev && prev.path === e.path && prev.line === e.line) { + // merged into the previous survivor + } else { + kept.push(e); + } + firstSurvivorSeen = true; + // survivors at or before the current index set the fallback; the current one wins + if (i <= this.idx) newIdx = kept.length - 1; + }); + if (kept.length && newIdx < 0 && firstSurvivorSeen) newIdx = 0; + if (!kept.length) newIdx = -1; + this.list = kept; + this.idx = newIdx; + } + + entries(): NavLoc[] { + return this.list.map(copy); + } + + index(): number { + return this.idx; + } + + private push(l: NavLoc): void { + this.list.push(copy(l)); + this.idx = this.list.length - 1; + while (this.list.length > this.cap) { + this.list.shift(); + this.idx--; + } + } + + private move(d: number, current?: NavLoc): NavLoc { + if (current) this.list[this.idx] = copy(current); + this.idx += d; + return copy(this.list[this.idx]!); + } +} diff --git a/src/client/navigation.ts b/src/client/navigation.ts new file mode 100644 index 0000000..834fa5a --- /dev/null +++ b/src/client/navigation.ts @@ -0,0 +1,95 @@ +import { type KeyLike, navKey } from "./keys.ts"; +import { NavHistory, type NavLoc } from "./navhistory.ts"; + +/** What the app shell supplies; injected so the behaviour is testable without the DOM. */ +export interface NavDeps { + /** Opens a location; resolves false when the file no longer opens. */ + open(loc: NavLoc): Promise; + /** The location the user is at now (active text file and line), if any. */ + current(): NavLoc | undefined; + /** Polite live-region announcement. */ + announce(msg: string): void; + /** Toast. */ + notify(msg: string): void; +} + +/** Back / Forward over a NavHistory, with recording of jumps and dropping of vanished files. */ +export class Navigation { + readonly history: NavHistory; + private navigating = false; + + constructor(private readonly deps: NavDeps, history: NavHistory = new NavHistory()) { + this.history = history; + } + + /** Records a jump; ignored while Back / Forward is reopening a location. */ + record(from: NavLoc | undefined, to: NavLoc): void { + if (!this.navigating) this.history.record(from, to); + } + + /** Goes back or forward, skipping files that no longer open. */ + async go(dir: "back" | "forward"): Promise { + if (this.navigating) return; + const h = this.history; + let cur = this.deps.current(); + let stay = false; + for (;;) { + let loc: NavLoc | undefined; + if (stay) loc = h.entries()[h.index()]; + else loc = dir === "back" ? h.back(cur) : h.forward(cur); + cur = undefined; + stay = false; + if (!loc) { + this.deps.notify(dir === "back" ? "No previous location" : "No next location"); + return; + } + this.navigating = true; + let ok = false; + try { + ok = await this.deps.open(loc); + } catch { + ok = false; + } finally { + this.navigating = false; + } + if (ok) { + this.deps.announce(`${dir === "back" ? "Back" : "Forward"} to ${loc.path} line ${loc.line}`); + return; + } + // The file is gone: forget it and carry on in the same direction. When the index fell back + // onto the neighbour that comes next in that direction, that neighbour is the target. + const k = h.index(); + const m = h.entries().slice(0, k).filter((e) => e.path !== loc.path).length; + h.drop(loc.path); + stay = h.entries().length > 0 && (dir === "back" ? m > 0 : m === 0); + } + } + + /** Forgets every entry of a file (deleted on disk). */ + drop(path: string): void { + this.history.drop(path); + } +} + +/** Routes the zoom / navigation keys; true when the event was handled. */ +export function routeNavZoomKey( + e: KeyLike, + o: { isMac: boolean; inTerm: boolean }, + h: { navigate(dir: "back" | "forward"): void; zoom(d: number): void }, +): boolean { + const nk = navKey(e, o); + if (nk) { + h.navigate(nk); + return true; + } + // On Linux the modifier is Ctrl, which terminal programs use: zoom stays out of a focused terminal. + const appKey = o.isMac || !o.inTerm; + const mod = o.isMac ? !!e.metaKey : !!e.ctrlKey; + if (!mod || !appKey) return false; + const k = e.key.toLowerCase(); + if (k === "=" || k === "+") h.zoom(1); + else if (k === "-") h.zoom(-1); + else if (k === "0") h.zoom(0); + else return false; + return true; +} diff --git a/src/client/risk.ts b/src/client/risk.ts new file mode 100644 index 0000000..17483ea --- /dev/null +++ b/src/client/risk.ts @@ -0,0 +1,46 @@ +// Risk flags for Changes rows: hints for attention, never a safety verdict. +export type RiskFlag = "exec" | "symlink" | "ci" | "hook" | "lockfile" | "manifest" | "env" | "hidden"; + +const DIRS: Array<[RiskFlag, string[]]> = [ + ["ci", [".github/workflows/", ".github/actions/", ".circleci/", ".buildkite/"]], + ["hook", [".husky/", ".githooks/"]], +]; +const NAMES: Array<[RiskFlag, string[]]> = [ + ["ci", [".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml"]], + ["hook", [".pre-commit-config.yaml", "lefthook.yml", ".lefthook.yml"]], + ["lockfile", ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "Cargo.lock", "Gemfile.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "composer.lock", "go.sum"]], + ["manifest", ["package.json", "Cargo.toml", "pyproject.toml", "setup.py", "Gemfile", "go.mod", "composer.json", "requirements.txt", "Makefile", "Dockerfile"]], + ["env", [".env", ".npmrc", ".yarnrc", ".yarnrc.yml", ".pypirc"]], +]; +const PREFIXES: Array<[RiskFlag, string]> = [["manifest", "Dockerfile."], ["env", ".env."]]; +const ORDER: RiskFlag[] = ["exec", "symlink", "ci", "hook", "lockfile", "manifest", "env", "hidden"]; + +export function riskFlags(f: { path: string; status: string; oldMode?: string; newMode?: string; hidden?: number }): RiskFlag[] { + const hit = new Set(); + if (f.newMode === "100755" && f.oldMode !== "100755") hit.add("exec"); + if (f.newMode === "120000" && f.oldMode !== "120000") hit.add("symlink"); + const p = f.path; + const name = p.slice(p.lastIndexOf("/") + 1); + for (const [flag, dirs] of DIRS) { + if (dirs.some((d) => p.startsWith(d) || p.includes("/" + d))) hit.add(flag); + } + for (const [flag, names] of NAMES) if (names.includes(name)) hit.add(flag); + for (const [flag, pre] of PREFIXES) if (name.startsWith(pre)) hit.add(flag); + if (f.hidden !== undefined && f.hidden > 0) hit.add("hidden"); + return ORDER.filter((x) => hit.has(x)); +} + +const LABELS: Record = { + exec: "executable bit added", + symlink: "new symbolic link", + ci: "CI configuration", + hook: "git hook", + lockfile: "dependency lockfile", + manifest: "package manifest or build file", + env: "environment or registry configuration", + hidden: "hidden or invisible characters", +}; + +export function riskLabel(flag: RiskFlag): string { + return LABELS[flag]; +} diff --git a/src/client/styles.css b/src/client/styles.css index 7eab8ab..7dd0de9 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1243,6 +1243,15 @@ svg { overflow: visible; box-shadow: 0 0 0 1px var(--danger); } +/* A zero-width, tag or variation character in code: zero width like .bidi, so columns stay put; the outline makes it visible. */ +.hidden-char { + display: inline-block; + width: 0; + height: 1.1em; + vertical-align: text-bottom; + overflow: visible; + box-shadow: 0 0 0 1px var(--danger); +} .trunc { -webkit-user-select: none; user-select: none; @@ -2250,6 +2259,9 @@ li[data-state="done"] .upd-mark { .ch-add { color: var(--git-add); } .ch-del { color: var(--git-del); } .ch-bin { color: var(--fg-faint); font-family: var(--font-mono); } +/* Risk flags: a word in a bordered pill, so the meaning never rests on colour alone. */ +.risk { flex: none; margin-left: 4px; padding: 0 4px; border: 1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius: 3px; font: 600 9.5px/14px var(--font-ui); letter-spacing: 0.04em; color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); } +.ch-riskcount { flex: none; flex-shrink: 0; color: var(--warn); } /* 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); } diff --git a/src/client/unreviewed.ts b/src/client/unreviewed.ts new file mode 100644 index 0000000..e7ef6cf --- /dev/null +++ b/src/client/unreviewed.ts @@ -0,0 +1,29 @@ +/** Next / previous unreviewed changed file: palette commands and the announcement they make. */ + +export const UNREVIEWED_COMMANDS = [ + { id: "changes.nextUnreviewed", title: "Changes: Next Unreviewed File", dir: 1 }, + { id: "changes.prevUnreviewed", title: "Changes: Previous Unreviewed File", dir: -1 }, +] as const; + +export interface UnreviewedDeps { + view: { files: readonly { path: string }[]; stepUnreviewed(dir: 1 | -1): { path: string } | null; unreviewedCount(): number; setCurrent?(path: string): void }; + announce(message: string): void; +} + +/** Opens the next / previous unreviewed file and says where it landed through the live region (no toast). */ +export function runUnreviewed(dir: 1 | -1, deps: UnreviewedDeps): void { + const { view, announce } = deps; + if (view.files.length === 0) return announce("No changes"); + const f = view.stepUnreviewed(dir); + if (!f) return announce(`All ${view.files.length} changed files reviewed`); + view.setCurrent?.(f.path); // the diff loads later: count the file as reviewed now so a quick repeat moves on + announce(`${f.path}, ${view.unreviewedCount()} unreviewed left`); +} + +/** Announcer for a live region element: clears it first so an identical message is read again. */ +export function liveAnnouncer(el: { textContent: string | null }): (message: string) => void { + return (message) => { + el.textContent = ""; + el.textContent = message; + }; +} diff --git a/src/server/git.ts b/src/server/git.ts index c15abc5..0b32779 100644 --- a/src/server/git.ts +++ b/src/server/git.ts @@ -1,8 +1,9 @@ import { watch, type FSWatcher } from "node:fs"; import { HEAVY_DIRS, isHeavyFile } from "./index.ts"; -import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { looksBinary, openRegular } from "./files.ts"; +import { scanHidden } from "../client/hidden.ts"; /** * Read-only git integration via the system `git` CLI. @@ -66,8 +67,17 @@ export interface ChangeEntry { added: number | null; removed: number | null; binary: boolean; + /** Git octal modes ("100644", "100755", "120000"; "000000" when absent on that side). */ + oldMode?: string; + newMode?: string; + /** Count of hidden or invisible characters in the current content; absent when none. */ + hidden?: number; } +/** Most file content scanned for hidden characters per changes() call. */ +export const HIDDEN_SCAN_BUDGET = 32 * 1024 * 1024; +const HIDDEN_MAX_FILE = 1024 * 1024; + export interface Changes { scope: "head" | "branch"; /** The base branch comparison, or null when no base branch could be found. */ @@ -440,6 +450,51 @@ export class Git { } private inflight: Partial>> = {}; + /** Files read for the hidden-character scan so far (cache hits do not count). */ + hiddenReads = 0; + /** Entries in the hidden-character cache. */ + hiddenCacheSize(): number { + return this.hiddenCache.size; + } + private hiddenCache = new Map(); + + /** Sets `hidden` on entries whose current content has hidden characters (bounded, cached by size and mtime). */ + private async markHidden(files: ChangeEntry[]): Promise { + let left = HIDDEN_SCAN_BUDGET; + const keep = new Set(); + for (const e of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) { + keep.add(e.path); + if (e.status === "D" || e.binary) continue; + try { + const { fh, st } = await openRegular(path.join(this.root, ...e.path.split("/")), this.root); + try { + if (st.size > HIDDEN_MAX_FILE) continue; + const c = this.hiddenCache.get(e.path); + let count: number; + if (c && c.size === st.size && c.mtime === st.mtimeMs) count = c.count; + else { + if (st.size > left) continue; + left -= st.size; + this.hiddenReads++; + const buf = new Uint8Array(st.size); + let n = 0; + while (n < buf.length) { + const { bytesRead } = await fh.read(buf, n, buf.length - n, n); + if (!bytesRead) break; + n += bytesRead; + } + const part = buf.subarray(0, n); + count = looksBinary(part.subarray(0, 64 * 1024)) ? 0 : scanHidden(new TextDecoder("utf-8", { ignoreBOM: true }).decode(part), 0).count; + this.hiddenCache.set(e.path, { size: st.size, mtime: st.mtimeMs, count }); + } + if (count > 0) e.hidden = count; + } finally { + await fh.close(); + } + } catch {} + } + for (const k of this.hiddenCache.keys()) if (!keep.has(k)) this.hiddenCache.delete(k); + } /** * Files changed in the working tree against HEAD ("head") or against the merge-base with the @@ -473,6 +528,7 @@ export class Git { else byPath.set(p, { path: p, status: "U", added: null, removed: null, binary: false }); } const files = [...byPath.values()]; + await this.markHidden(files); let commits = 0; let subjects: string[] = []; if (branch) { @@ -519,7 +575,7 @@ export class Git { 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 }; + const e: ChangeEntry = { path: rel, status, added: null, removed: null, binary: false, oldMode: parts[0], newMode: parts[1] }; // Kept for the stat-only check below (not part of the entry sent to the client). if (status === "M" && parts[0] === parts[1]) statOnly.set(e, parts[2] ?? ""); if (two) { @@ -592,6 +648,7 @@ export class Git { 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. + for (const e of files) e.newMode = this.untrackedMode(e.path); 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); @@ -599,6 +656,20 @@ export class Git { return { files, truncated: all.length > MAX_CHANGES }; } + /** Mode of an untracked file from lstat (a link is never followed); the parent directory must resolve inside the root. */ + private untrackedMode(rel: string): string | undefined { + try { + const abs = path.join(this.root, ...rel.split("/")); + const parent = realpathSync(path.dirname(abs)); + if (parent !== this.root && !parent.startsWith(this.root + path.sep)) return undefined; + const st = lstatSync(abs); + if (st.isSymbolicLink()) return "120000"; + return st.mode & 0o111 ? "100755" : "100644"; + } catch { + return undefined; + } + } + private async countLines(e: ChangeEntry) { try { const { fh, st } = await openRegular(path.join(this.root, ...e.path.split("/")), this.root); diff --git a/test/client.changes.riskcount.test.ts b/test/client.changes.riskcount.test.ts new file mode 100644 index 0000000..4ec784e --- /dev/null +++ b/test/client.changes.riskcount.test.ts @@ -0,0 +1,105 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { 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, extra: Partial = {}): ChangeFile => ({ + path, status: "M", added: 1, removed: 0, binary: false, ...extra, +}); +const flagged = (path: string) => f(path, { oldMode: "100644", newMode: "100755" }); +const data = (files: ChangeFile[]): 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"], +}); + +describe("Part B: ch-riskcount keeps its identity across renders", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + + // The server answer is swapped between refreshes; a number is an HTTP error. + function make() { + let answer: ChangesData | number = data([]); + const m = mockFetch(() => (typeof answer === "number" ? new Response("{}", { status: answer }) : Response.json(answer))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + const el = v.el as unknown as FakeElement; + const summary = el.querySelector(".ch-summary-wrap")!; + v.setRepo(true); + return { + v, summary, + show: async (a: ChangesData | number) => { + answer = a; + await v.refresh(); + }, + risk: () => summary.querySelector(".ch-riskcount"), + }; + } + + test("the same element object survives a different flagged count, zero and back", async () => { + const t = make(); + await t.show(data([flagged("a.sh"), flagged("b.sh"), f("plain.txt")])); + const ref = t.risk()!; + expect(ref).not.toBeNull(); + expect(ref.textContent).toBe("· 2 flagged"); + expect(ref.getAttribute("role")).toBe("status"); + expect(ref.classList.contains("ch-riskcount")).toBe(true); + + await t.show(data([flagged("a.sh"), f("plain.txt")])); + expect(t.risk()).toBe(ref); + expect(t.summary.contains(ref)).toBe(true); + expect(ref.textContent).toBe("· 1 flagged"); + + await t.show(data([f("plain.txt")])); + expect(t.risk()).toBe(ref); + expect(ref.textContent).toBe(""); + + await t.show(data([flagged("a.sh"), flagged("b.sh")])); + expect(t.risk()).toBe(ref); + expect(t.summary.contains(ref)).toBe(true); + expect(ref.textContent).toBe("· 2 flagged"); + expect(ref.getAttribute("role")).toBe("status"); + expect(ref.classList.contains("ch-riskcount")).toBe(true); + }); + + test("the same element object survives the error, not-a-repository and loading states", async () => { + const t = make(); + await t.show(data([flagged("a.sh")])); + const ref = t.risk()!; + expect(ref).not.toBeNull(); + + await t.show(500); + expect(t.summary.hidden).toBe(true); + await t.show(data([flagged("a.sh"), flagged("b.sh")])); + expect(t.risk()).toBe(ref); + expect(ref.textContent).toBe("· 2 flagged"); + + t.v.setRepo(false); + expect(t.summary.hidden).toBe(true); + t.v.setRepo(true); + await t.v.refresh(); + expect(t.risk()).toBe(ref); + expect(ref.textContent).toBe("· 2 flagged"); + }); + + test("a scope switch re-render keeps the same element", async () => { + const t = make(); + await t.show(data([flagged("a.sh"), f("plain.txt")])); + const ref = t.risk()!; + expect(ref).not.toBeNull(); + expect(ref.textContent).toBe("· 1 flagged"); + t.v.setScope("head"); + await t.v.refresh(); + expect(t.risk()).toBe(ref); + expect(t.summary.contains(ref)).toBe(true); + expect(ref.textContent).toBe("· 1 flagged"); + }); +}); diff --git a/test/client.changes.test.ts b/test/client.changes.test.ts index d3d999f..37c80cb 100644 --- a/test/client.changes.test.ts +++ b/test/client.changes.test.ts @@ -351,3 +351,205 @@ describe("ChangesView.ready", () => { expect(t.v.files).toEqual([]); }); }); + +describe("risk flags in the Changes view", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + + async function load(files: ChangeFile[]) { + const m = mockFetch(() => Response.json(data(files))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + const el = v.el as unknown as FakeElement; + v.setRepo(true); + await v.refresh(); + return { v, list: el.querySelector(".ch-list")!, summary: el.querySelector(".ch-summary-wrap")! }; + } + + const attrs = (tag: string): Record => { + const out: Record = {}; + for (const m of tag.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]!] = m[2]!; + return out; + }; + /** The rows of the rendered list as HTML strings (rows contain no nested divs). */ + const rowsOf = (html: string) => html.split('
")[0] + "
"); + /** Badge elements of a row: attributes, text and the offset in the row. */ + const badges = (row: string) => + [...row.matchAll(/<(\w+)([^>]*)>([^<]*)<\/\1>/g)] + .map((m) => ({ attrs: attrs(m[2]!), text: m[3]!, at: m.index! })) + .filter((b) => (b.attrs.class ?? "").split(/\s+/).includes("risk")); + const rowFor = (html: string, path: string) => rowsOf(html).find((r) => attrs(r.match(/^]*>/)![0]).title?.startsWith(path + " ·")); + + test("flags(path) reports the computed flags of a listed row", async () => { + const t = await load([ + f("a.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" }), + f("package-lock.json", "M", 2, 1), + f("plain.txt", "M"), + ]); + expect(t.v.flags("a.sh")).toEqual(["exec"]); + expect(t.v.flags("package-lock.json")).toEqual(["lockfile"]); + expect(t.v.flags("plain.txt")).toEqual([]); + expect(t.v.flags("nope")).toEqual([]); + }); + + test("a flagged row has a badge after the path with text, title and aria-label", async () => { + const t = await load([f("a.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" })]); + const row = rowFor(t.list.innerHTML, "a.sh")!; + expect(row).toBeDefined(); + const all = badges(row); + expect(all.length).toBe(1); + const [b, ...rest] = all; + expect(rest).toEqual([]); + expect(b!.text).toBe("exec"); + expect(b!.attrs.title).toBe("executable bit added"); + expect(b!.attrs["aria-label"]).toBe("executable bit added"); + expect(b!.at).toBeGreaterThan(row.indexOf('class="ch-name"')); + }); + + test("every flag has its visible text, and multiple flags keep union order", async () => { + const cases: Array<[ChangeFile, string[], string[]]> = [ + [f("l", "A", 1, 0, { oldMode: "000000", newMode: "120000" }), ["symlink"], ["new symbolic link"]], + [f(".github/workflows/ci.yml", "M"), ["CI"], ["CI configuration"]], + [f(".husky/pre-commit", "M"), ["hook"], ["git hook"]], + [f("yarn.lock", "M"), ["lock"], ["dependency lockfile"]], + [f("Dockerfile", "M"), ["manifest"], ["package manifest or build file"]], + [f(".env.local", "M"), ["env"], ["environment or registry configuration"]], + [f(".github/workflows/deploy.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" }), ["exec", "CI"], ["executable bit added", "CI configuration"]], + [f(".husky/.env", "M"), ["hook", "env"], ["git hook", "environment or registry configuration"]], + ]; + for (const [file, texts, labels] of cases) { + const t = await load([file]); + restore?.(); + restore = null; + const bs = badges(rowFor(t.list.innerHTML, file.path)!); + expect(bs.map((b) => b.text)).toEqual(texts); + expect(bs.map((b) => b.attrs.title)).toEqual(labels); + expect(bs.map((b) => b.attrs["aria-label"])).toEqual(labels); + } + }); + + test("hostile paths are escaped, badges included", async () => { + const evil = '.husky/"q".sh'; + const t = await load([f(evil, "A", 1, 0, { oldMode: "000000", newMode: "100755" }), f('Dockerfile."x"', "M")]); + const html = t.list.innerHTML; + expect(html).not.toContain(""); + expect(html).toContain("<img src=x onerror=1>"); + const row = rowsOf(html).find((r) => r.includes("<img"))!; + expect(badges(row).map((b) => b.text)).toEqual(["exec", "hook"]); + // Attribute values are closed strings (the path's quotes are entities) and no attribute is named onerror. + for (const r of rowsOf(html)) { + const tag = r.match(/]*>/)![0]; + expect(Object.keys(attrs(tag))).not.toContain("onerror"); + expect(tag.replace(/$/g, "").replace(/\s[\w-]+="[^"]*"/g, "").trim()).toBe(""); + } + expect(attrs(rowsOf(html).find((r) => r.includes("<img"))!.match(/]*>/)![0]).title).toContain(""q""); + }); + + test("summary appends the flagged count only when rows are flagged", async () => { + const t = await load([ + f("a.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" }), + f("package.json", "M"), + f("plain.txt", "M"), + ]); + expect(t.summary.innerHTML).toContain("· 2 flagged"); + expect(t.summary.innerHTML.match(/flagged/g)!.length).toBe(1); + // The count is announced by the persistent .ch-riskcount element, not by the wrapper. + expect(t.summary.getAttribute("role")).toBeNull(); + expect(t.summary.innerHTML.match(/role="status"/g)!.length).toBe(1); + const none = await load([f("plain.txt", "M"), f("b.ts", "M", 1, 0, { oldMode: "100644", newMode: "100644" })]); + expect(none.summary.innerHTML).not.toContain("flagged"); + expect(none.summary.getAttribute("role")).toBeNull(); + expect(none.summary.innerHTML.match(/role="status"/g)!.length).toBe(1); + }); + + test("Part B: the live region is the persistent ch-riskcount element, outside ch-counts", async () => { + const flaggedT = await load([f("a.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" }), f("package.json", "M"), f("plain.txt", "M")]); + const none = await load([f("plain.txt", "M")]); + for (const t of [flaggedT, none]) { + const html = t.summary.innerHTML; + expect(t.summary.getAttribute("role")).toBeNull(); + expect(t.summary.getAttribute("aria-live")).toBeNull(); + expect(html).not.toContain("aria-live"); + expect(html.match(/role="status"/g)!.length).toBe(1); + const tag = html.match(/<[^>]*role="status"[^>]*>/)![0]; + expect(tag).toContain('class="ch-riskcount"'); + // A sibling of .ch-counts inside .ch-summary: not within the .ch-counts element. + const start = html.indexOf('".length; break; } + } + expect(end).toBeGreaterThan(start); + expect(riskAt).toBeGreaterThanOrEqual(end); + expect(html.slice(start, end)).not.toContain("ch-riskcount"); + } + expect(flaggedT.summary.innerHTML).toContain('· 2 flagged'); + expect(none.summary.innerHTML).toContain(''); + }); + + test("Part B: ch-riskcount stays at the same markup position when the flagged count changes", async () => { + const a = await load([f("a.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755" })]); + const b = await load([f("plain.txt", "M")]); + // Same neighbours in both renders: right after .ch-counts and right before .ch-vs. + for (const t of [a, b]) { + expect(t.summary.innerHTML).toMatch(/<\/span>[^<]*<\/span>/); + } + expect(a.summary.innerHTML).toContain("· 1 flagged"); + expect(b.summary.innerHTML).not.toContain("flagged"); + }); + + test("Part B: styles keep ch-riskcount from shrinking and ch-counts ellipsised; DESIGN.md has the round", () => { + const css = require("node:fs").readFileSync(`${import.meta.dir}/../src/client/styles.css`, "utf8") as string; + const rule = (sel: string) => [...css.matchAll(new RegExp(`(?:^|\\n)${sel.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g"))].map((m) => m[1]).join(" "); + expect(rule(".ch-riskcount")).toMatch(/flex-shrink:\s*0/); + expect(rule(".ch-riskcount")).not.toContain("ellipsis"); + expect(rule(".ch-counts")).toContain("text-overflow: ellipsis"); + const design = require("node:fs").readFileSync(`${import.meta.dir}/../DESIGN.md`, "utf8") as string; + expect(design).toContain("## 29. Round 20: hidden characters"); + const sec = design.slice(design.indexOf("## 29. Round 20")); + expect(sec).toMatch(/hidden characters[^\n]*4 votes|4 votes[^\n]*hidden/i); + expect(sec).toContain("| Finding | Fix |"); + }); + + test("a row with hidden characters has the hidden badge, and the summary counts it once", async () => { + const t = await load([ + f("notes.md", "M", 1, 0, { hidden: 3 }), + f("plain.txt", "M"), + f("run.sh", "A", 1, 0, { oldMode: "000000", newMode: "100755", hidden: 1 }), + ]); + const row = rowFor(t.list.innerHTML, "notes.md")!; + expect(row).toBeDefined(); + expect(row).toContain( + 'hidden', + ); + expect(badges(row).map((b) => b.text)).toEqual(["hidden"]); + expect(badges(row)[0]!.at).toBeGreaterThan(row.indexOf('class="ch-name"')); + expect(badges(rowFor(t.list.innerHTML, "run.sh")!).map((b) => b.text)).toEqual(["exec", "hidden"]); + expect(badges(rowFor(t.list.innerHTML, "plain.txt")!)).toEqual([]); + expect(t.summary.innerHTML).toContain("· 2 flagged"); + const one = await load([f("notes.md", "M", 1, 0, { hidden: 1 }), f("plain.txt", "M")]); + expect(one.summary.innerHTML).toContain("· 1 flagged"); + expect(t.v.flags("notes.md")).toEqual(["hidden"]); + }); + + test("flags do not reorder rows, and an unflagged row renders as before", async () => { + const files = [f("z.ts", "M", 10, 2), f("package.json", "M"), f("a.ts", "M", 1, 0, { oldMode: "100644", newMode: "100644" })]; + const t = await load(files); + expect(t.v.files.map((x) => x.path)).toEqual(["a.ts", "package.json", "z.ts"]); + const titles = rowsOf(t.list.innerHTML).map((r) => attrs(r.match(/]*>/)![0]).title); + expect(titles).toEqual(["a.ts · Modified", "package.json · Modified", "z.ts · Modified"]); + expect(rowsOf(t.list.innerHTML)[0]).toBe( + '
' + + 'Ma.ts' + + '+1
', + ); + expect(badges(rowsOf(t.list.innerHTML)[0]!)).toEqual([]); + }); +}); diff --git a/test/client.changes.unreviewed.async.test.ts b/test/client.changes.unreviewed.async.test.ts new file mode 100644 index 0000000..563ce00 --- /dev/null +++ b/test/client.changes.unreviewed.async.test.ts @@ -0,0 +1,62 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { installDom, mockFetch, uninstallDom } from "./helpers/fakedom.ts"; +import type { ChangeFile, ChangesData } from "../src/client/api.ts"; + +let C: typeof import("../src/client/changes.ts"); +let U: typeof import("../src/client/unreviewed.ts"); +beforeAll(async () => { + installDom(); + C = await import("../src/client/changes.ts"); + U = await import("../src/client/unreviewed.ts"); +}); +afterAll(() => uninstallDom()); + +const f = (path: string): ChangeFile => ({ path, status: "M", added: 1, removed: 0, binary: false }); +const data = (files: ChangeFile[]): 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"], +}); + +describe("runUnreviewed when the diff opens later (onOpen never calls setCurrent)", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + async function make() { + const m = mockFetch(() => Response.json(data([f("a.ts"), f("b.ts"), f("c.ts")]))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + await v.refresh(); + v.onOpen = () => {}; + return v; + } + + test("the command announces how many are left", async () => { + const v = await make(); + const said: string[] = []; + const announce = (m: string) => said.push(m); + U.runUnreviewed(1, { view: v, announce }); + U.runUnreviewed(1, { view: v, announce }); + U.runUnreviewed(1, { view: v, announce }); + U.runUnreviewed(1, { view: v, announce }); + expect(said).toEqual(["a.ts, 2 unreviewed left", "b.ts, 1 unreviewed left", "c.ts, 0 unreviewed left", "All 3 changed files reviewed"]); + }); +}); + +describe("liveAnnouncer", () => { + test("clears the region before setting the text, so a repeated message is announced again", () => { + const writes: (string | null)[] = []; + let text: string | null = ""; + const el = { + get textContent() { return text; }, + set textContent(v: string | null) { writes.push(v); text = v; }, + }; + const say = U.liveAnnouncer(el); + say("a.ts, 1 unreviewed left"); + say("a.ts, 1 unreviewed left"); + expect(text).toBe("a.ts, 1 unreviewed left"); + expect(writes).toEqual(["", "a.ts, 1 unreviewed left", "", "a.ts, 1 unreviewed left"]); + }); +}); diff --git a/test/client.changes.unreviewed.spec.test.ts b/test/client.changes.unreviewed.spec.test.ts new file mode 100644 index 0000000..35a5ec7 --- /dev/null +++ b/test/client.changes.unreviewed.spec.test.ts @@ -0,0 +1,41 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { installDom, mockFetch, uninstallDom } 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): ChangeFile => ({ path, status: "M", added: 1, removed: 0, binary: false }); +const data = (files: ChangeFile[]): 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"], +}); + +let restore: (() => void) | null = null; +afterEach(() => { + restore?.(); + restore = null; +}); + +test("stepUnreviewed leaves reviewing to opening: with a no-op onOpen nothing becomes reviewed or current", async () => { + const m = mockFetch(() => Response.json(data([f("a.ts"), f("b.ts"), f("c.ts")]))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + await v.refresh(); + const opened: string[] = []; + v.onOpen = (x) => void opened.push(x.path); + expect(v.stepUnreviewed(1)?.path).toBe("a.ts"); + expect(opened).toEqual(["a.ts"]); + expect(v.unreviewedCount()).toBe(3); + expect(v.position()).toBeNull(); // no file is current + expect(v.stepUnreviewed(1)?.path).toBe("a.ts"); // still the first unreviewed one + v.setCurrent("a.ts"); // what the app does once the diff has loaded + expect(v.unreviewedCount()).toBe(2); + expect(v.stepUnreviewed(1)?.path).toBe("b.ts"); + expect(v.unreviewedCount()).toBe(2); +}); diff --git a/test/client.changes.unreviewed.test.ts b/test/client.changes.unreviewed.test.ts new file mode 100644 index 0000000..58693d8 --- /dev/null +++ b/test/client.changes.unreviewed.test.ts @@ -0,0 +1,200 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { 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"] = "M", added: number | null = 1, removed: number | null = 0): ChangeFile => ({ + path, status, added, removed, binary: false, +}); +const data = (files: ChangeFile[]): 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"], +}); + +const paths = ["a", "b", "c", "d"]; +const rev = (...r: string[]) => (p: string) => r.includes(p); + +describe("nextUnreviewed", () => { + test("worked examples (b and d reviewed)", () => { + const r = rev("b", "d"); + expect(C.nextUnreviewed(paths, "a", 1, r)).toBe(2); + expect(C.nextUnreviewed(paths, "c", 1, r)).toBe(0); + expect(C.nextUnreviewed(paths, "a", -1, r)).toBe(2); + expect(C.nextUnreviewed(paths, null, -1, r)).toBe(2); + expect(C.nextUnreviewed(paths, "zzz", 1, r)).toBe(0); + expect(C.nextUnreviewed(paths, null, 1, r)).toBe(0); + expect(C.nextUnreviewed(paths, "c", -1, r)).toBe(0); + }); + + test("everything reviewed gives -1", () => { + const r = rev("a", "b", "c", "d"); + for (const dir of [1, -1] as const) for (const cur of ["a", null, "zzz"]) expect(C.nextUnreviewed(paths, cur, dir, r)).toBe(-1); + expect(C.nextUnreviewed([], null, 1, r)).toBe(-1); + expect(C.nextUnreviewed([], "a", -1, r)).toBe(-1); + }); + + test("the current file is checked last", () => { + const r = rev("b", "c", "d"); + expect(C.nextUnreviewed(paths, "a", 1, r)).toBe(0); + expect(C.nextUnreviewed(paths, "a", -1, r)).toBe(0); + expect(C.nextUnreviewed(["a"], "a", 1, () => false)).toBe(0); + expect(C.nextUnreviewed(["a"], "a", -1, () => false)).toBe(0); + expect(C.nextUnreviewed(["a"], "a", 1, () => true)).toBe(-1); + expect(C.nextUnreviewed(["a"], null, -1, () => false)).toBe(0); + }); + + test("the predicate only sees members of paths", () => { + const seen: string[] = []; + C.nextUnreviewed(paths, "zzz", 1, (p) => (seen.push(p), true)); + C.nextUnreviewed(paths, "b", -1, (p) => (seen.push(p), true)); + expect(seen.length).toBeGreaterThan(0); + for (const p of seen) expect(paths).toContain(p); + }); +}); + +describe("ChangesView unreviewed", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + + async function make(files: ChangeFile[]) { + const m = mockFetch(() => Response.json(data(files))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + await v.refresh(); + const list = (v.el as unknown as FakeElement).querySelector(".ch-list")!; + return { v, list, files }; + } + const three = () => [f("c.ts"), f("a.ts"), f("b.ts")]; + + test("unreviewedCount follows setCurrent and later edits", async () => { + const fs = three(); + const t = await make(fs); + expect(t.v.unreviewedCount()).toBe(3); + t.v.setCurrent("b.ts"); + expect(t.v.unreviewedCount()).toBe(2); + fs[2] = f("b.ts", "M", 9, 0); + await t.v.refresh(); + expect(t.v.unreviewedCount()).toBe(3); + }); + + test("unreviewedCount is 0 for an empty list", async () => { + const t = await make([]); + expect(t.v.unreviewedCount()).toBe(0); + }); + + test("stepUnreviewed walks the unreviewed files once, then returns null", async () => { + const t = await make(three()); + const opened: [string, boolean][] = []; + t.v.onOpen = (x, pin) => { + opened.push([x.path, pin]); + t.v.setCurrent(x.path); + }; + expect(t.v.stepUnreviewed(1)?.path).toBe("a.ts"); + expect(opened).toEqual([["a.ts", false]]); + expect(t.v.stepUnreviewed(1)?.path).toBe("b.ts"); + expect(t.v.stepUnreviewed(1)?.path).toBe("c.ts"); + expect(t.v.stepUnreviewed(1)).toBeNull(); + expect(opened.length).toBe(3); + }); + + test("a fresh view steps backwards from the last file", async () => { + const t = await make(three()); + t.v.onOpen = () => {}; + expect(t.v.stepUnreviewed(-1)?.path).toBe("c.ts"); + }); + + test("the search starts at the open file and skips reviewed ones", async () => { + const t = await make(three()); + t.v.onOpen = () => {}; + t.v.setCurrent("b.ts"); + expect(t.v.stepUnreviewed(1)?.path).toBe("c.ts"); + expect(t.v.stepUnreviewed(-1)?.path).toBe("a.ts"); + restore?.(); + const u = await make(three()); + u.v.onOpen = () => {}; + u.v.setCurrent("a.ts"); + u.v.setCurrent("c.ts"); + expect(u.v.stepUnreviewed(1)?.path).toBe("b.ts"); + }); + + test("the chosen row becomes the list selection", async () => { + const t = await make(three()); + t.v.onOpen = () => {}; + t.v.stepUnreviewed(1); + expect(t.list.innerHTML).toContain('aria-selected="true"'); + expect(t.list.innerHTML).toMatch(/data-i="0" id="ch-0" role="option" aria-selected="true"/); + t.v.stepUnreviewed(-1); + expect(t.list.innerHTML).toMatch(/data-i="2" id="ch-2" role="option" aria-selected="true"/); + }); + + test("an empty list returns null and opens nothing", async () => { + const t = await make([]); + let n = 0; + t.v.onOpen = () => n++; + expect(t.v.stepUnreviewed(1)).toBeNull(); + expect(t.v.stepUnreviewed(-1)).toBeNull(); + expect(n).toBe(0); + }); + + test("a file changed since review is returned again", async () => { + const fs = three(); + const t = await make(fs); + t.v.onOpen = () => {}; + t.v.setCurrent("a.ts"); + t.v.setCurrent("b.ts"); + t.v.setCurrent("c.ts"); + expect(t.v.stepUnreviewed(1)).toBeNull(); + fs[1] = f("a.ts", "M", 7, 2); + await t.v.refresh(); + expect(t.v.stepUnreviewed(1)?.path).toBe("a.ts"); + }); +}); + +describe("reviewed rows are read as reviewed", () => { + let restore: (() => void) | null = null; + afterEach(() => { + restore?.(); + restore = null; + }); + const SR = ', reviewed'; + + test("sr-only text follows the path, only on reviewed rows", async () => { + const m = mockFetch(() => Response.json(data([f("a.ts"), f("src/b.ts"), f("z.ts")]))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + await v.refresh(); + const list = (v.el as unknown as FakeElement).querySelector(".ch-list")!; + expect(list.innerHTML).not.toContain("sr-only"); + v.setCurrent("a.ts"); + expect(list.innerHTML).toContain(`a.ts${SR}`); + expect(list.innerHTML).toContain('class="ch-row git-M cur seen" data-i="0"'); + expect(list.innerHTML).toContain(" · reviewed (dims until the file changes again)"); + expect(list.innerHTML.split("sr-only").length - 1).toBe(1); + v.setCurrent("src/b.ts"); + expect(list.innerHTML).toContain(`b.tssrc${SR}`); + expect(list.innerHTML.split("sr-only").length - 1).toBe(2); + }); + + test("an unreviewed row keeps its exact markup", async () => { + const m = mockFetch(() => Response.json(data([f("a.ts")]))); + restore = m.restore; + const v = new C.ChangesView({ debounce: 0 }); + v.setRepo(true); + await v.refresh(); + const list = (v.el as unknown as FakeElement).querySelector(".ch-list")!; + expect(list.innerHTML).toBe( + '
Ma.ts+1
', + ); + }); +}); diff --git a/test/client.checkpoint.test.ts b/test/client.checkpoint.test.ts new file mode 100644 index 0000000..01ee9d9 --- /dev/null +++ b/test/client.checkpoint.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test"; +import { sinceCheckpoint, takeCheckpoint, type Checkpoint } from "../src/client/checkpoint.ts"; + +type F = { path: string; text: string | null }; +const f = (path: string, text: string | null): F => ({ path, text }); +const keys = (cp: Checkpoint) => [...cp.texts.keys()]; +/** A base stub: undefined for any path not named. */ +const baseOf = (m: Record) => (p: string) => (p in m ? m[p] : undefined); +const cur = (o: Record) => new Map(Object.entries(o)); +const since = (files: F[], current: Record, base: Record = {}) => + sinceCheckpoint(takeCheckpoint(files, 1, "R"), cur(current), baseOf(base)); + +describe("takeCheckpoint", () => { + test("AC-1 stores texts in order and keeps at and rev", () => { + const cp = takeCheckpoint([f("a", "x"), f("b", null)], 1700000000000, "R"); + expect(cp.at).toBe(1700000000000); + expect(cp.rev).toBe("R"); + expect(cp.skipped).toEqual([]); + expect([...cp.texts.entries()]).toEqual([["a", "x"], ["b", null]]); + expect(cp.texts.size).toBe(2); + expect(cp.texts.get("b")).toBeNull(); + expect(cp.texts.has("b")).toBe(true); + expect(takeCheckpoint([f("a", "x")], 1, null).rev).toBeNull(); + }); + + test("AC-1 the result is a copy of the input", () => { + const files = [f("a", "x"), f("b", "y")]; + const cp = takeCheckpoint(files, 1, "R", { files: 2 }); + files.push(f("c", "z")); + files[0]!.text = "changed"; + expect(cp.texts.get("a")).toBe("x"); + expect(keys(cp)).toEqual(["a", "b"]); + expect(cp.skipped).toEqual([]); + expect(cp as unknown).not.toBe(files); + }); + + test("AC-2 chars limit skips a big file but a later small one still fits", () => { + const cp = takeCheckpoint([f("a", "12345"), f("b", "1234567890"), f("c", "123")], 0, null, { chars: 8 }); + expect(keys(cp)).toEqual(["a", "c"]); + expect(cp.skipped).toEqual(["b"]); + }); + + test("AC-2 files limit", () => { + const files = [f("a", "x"), f("b", "y"), f("c", "z")]; + const two = takeCheckpoint(files, 0, null, { files: 2 }); + expect(keys(two)).toEqual(["a", "b"]); + expect(two.skipped).toEqual(["c"]); + const none = takeCheckpoint(files, 0, null, { files: 0 }); + expect(keys(none)).toEqual([]); + expect(none.skipped).toEqual(["a", "b", "c"]); + }); + + test("AC-2 null and empty texts count as zero characters", () => { + const files = [f("a", "123456"), f("b", null), f("c", ""), f("d", "12")]; + const six = takeCheckpoint(files, 0, null, { chars: 6 }); + expect(keys(six)).toEqual(["a", "b", "c"]); + expect(six.skipped).toEqual(["d"]); + const zero = takeCheckpoint(files, 0, null, { chars: 0 }); + expect(keys(zero)).toEqual(["b", "c"]); + expect(zero.skipped).toEqual(["a", "d"]); + }); + + test("AC-2 each limit keeps the other's default", () => { + expect(takeCheckpoint([f("a", "x".repeat(20))], 0, null, { files: 5 }).texts.has("a")).toBe(true); + const many = Array.from({ length: 501 }, (_, i) => f("f" + i, "x")); + const cp = takeCheckpoint(many, 0, null); + expect(cp.texts.size).toBe(500); + expect(cp.skipped).toEqual(["f500"]); + expect(cp.texts.has("f500")).toBe(false); + }); + + test("AC-2 length is the UTF-16 length", () => { + const cp = takeCheckpoint([f("a", "\u{1F600}"), f("b", "x")], 0, null, { chars: 2 }); + expect(keys(cp)).toEqual(["a"]); + expect(cp.skipped).toEqual(["b"]); + }); + + test("AC-3 empty input and the empty string", () => { + const cp = takeCheckpoint([], 5, null); + expect(cp.at).toBe(5); + expect(cp.rev).toBeNull(); + expect(cp.texts.size).toBe(0); + expect(cp.skipped).toEqual([]); + expect(takeCheckpoint([f("e", "")], 0, null).texts.get("e")).toBe(""); + }); +}); + +describe("sinceCheckpoint", () => { + test("AC-4.1 the worked example", () => { + const files = [f("a.ts", "x\n")]; + const current = { "a.ts": "x\ny\n", "b.ts": "new\n" }; + expect(since(files, current, { "b.ts": null })).toEqual([ + { path: "a.ts", status: "M", added: 1, removed: 0 }, + { path: "b.ts", status: "A", added: 1, removed: 0 }, + ]); + expect(since(files, { "b.ts": "new\n" }, { "a.ts": "x\n", "b.ts": null })).toEqual([ + { path: "b.ts", status: "A", added: 1, removed: 0 }, + ]); + }); + + test("AC-4.2 delete", () => { + expect(since([f("a", "x\ny\n")], { a: null })).toEqual([{ path: "a", status: "D", added: 0, removed: 2 }]); + }); + + test("AC-4.3 add of a file that was deleted at checkpoint time", () => { + expect(since([f("c", null)], { c: "q\nr\n" })).toEqual([{ path: "c", status: "A", added: 2, removed: 0 }]); + }); + + test("AC-4.4 modify with both counts", () => { + expect(since([f("p", "a\nb\nc\n")], { p: "a\nB\nc\nd\n" })).toEqual([ + { path: "p", status: "M", added: 2, removed: 1 }, + ]); + }); + + test("AC-4.5 untracked at checkpoint, changed later", () => { + expect(since([f("n", "1\n")], { n: "1\n2\n3\n" })).toEqual([{ path: "n", status: "M", added: 2, removed: 0 }]); + }); + + test("AC-4.6 unchanged at checkpoint, changed now", () => { + expect(since([], { m: "two\n" }, { m: "one\n" })).toEqual([{ path: "m", status: "M", added: 1, removed: 1 }]); + expect(since([], { m: "two\n" }, { m: null })).toEqual([{ path: "m", status: "A", added: 1, removed: 0 }]); + expect(since([], { m: null }, { m: "one\ntwo\n" })).toEqual([{ path: "m", status: "D", added: 0, removed: 2 }]); + }); + + test("AC-4.7 a path no longer changed goes back to the base text", () => { + expect(since([f("k", "edited\n")], {}, { k: "orig\n" })).toEqual([ + { path: "k", status: "M", added: 1, removed: 1 }, + ]); + expect(since([f("k", "edited\n")], {}, { k: null })).toEqual([{ path: "k", status: "D", added: 0, removed: 1 }]); + }); + + test("AC-4.8 differing only by the final newline is kept with zero counts", () => { + expect(since([f("t", "x")], { t: "x\n" })).toEqual([{ path: "t", status: "M", added: 0, removed: 0 }]); + }); + + test("AC-4.9 the empty string is not null", () => { + expect(since([f("e", "")], { e: "x" })).toEqual([{ path: "e", status: "M", added: 1, removed: 0 }]); + expect(since([f("e", null)], { e: "" })).toEqual([{ path: "e", status: "A", added: 0, removed: 0 }]); + }); + + test("AC-5 leaves out identical, unknown, skipped and reverted paths", () => { + expect(since([f("x", "x\n")], { x: "x\n" })).toEqual([]); + expect(since([f("n", null)], { n: null })).toEqual([]); + expect(since([], { u: "x" }, { u: undefined as unknown as null })).toEqual([]); + expect(since([f("u", "x")], {}, { u: undefined as unknown as null })).toEqual([]); + const cp = takeCheckpoint([f("s", "x")], 0, "R", { files: 0 }); + expect(cp.skipped).toEqual(["s"]); + const never = () => { + throw new Error("base must not be asked"); + }; + expect(sinceCheckpoint(cp, cur({ s: "anything" }), never)).toEqual([]); + expect(since([], {}, { z: "whatever" })).toEqual([]); + expect(since([f("k", "edited\n")], {}, { k: "edited\n" })).toEqual([]); + }); + + test("AC-5 skipping one path never affects the others", () => { + const cp = takeCheckpoint([f("a", "1"), f("b", "2")], 0, "R", { files: 1 }); + expect(cp.skipped).toEqual(["b"]); + expect(sinceCheckpoint(cp, cur({ a: "1\n2", b: "q" }), baseOf({})).map((e) => e.path)).toEqual(["a"]); + expect(since([f("u", "x"), f("v", "1")], { v: "2" }, { u: undefined as unknown as null })).toEqual([ + { path: "v", status: "M", added: 1, removed: 1 }, + ]); + }); + + test("AC-6.1 sorted by plain string comparison", () => { + const current = { "a/b.ts": "1", "a.ts": "1", "B.ts": "1", "b.ts": "1" }; + const base = { "a/b.ts": null, "a.ts": null, "B.ts": null, "b.ts": null }; + expect(since([], current, base).map((e) => e.path)).toEqual(["B.ts", "a.ts", "a/b.ts", "b.ts"]); + }); + + test("AC-6.2 line splitting", () => { + expect(since([f("l", "a\n\n")], { l: "a\n" })).toEqual([{ path: "l", status: "M", added: 0, removed: 1 }]); + expect(since([f("l", "x\n")], { l: "x\nx\n" })).toEqual([{ path: "l", status: "M", added: 1, removed: 0 }]); + expect(since([f("l", "a\r\n")], { l: "a\n" })).toEqual([{ path: "l", status: "M", added: 1, removed: 1 }]); + }); + + test("AC-6.3 empty inputs", () => { + expect(sinceCheckpoint(takeCheckpoint([], 0, null), new Map(), () => undefined)).toEqual([]); + }); + + test("AC-6.4 does not mutate its inputs", () => { + const cp = takeCheckpoint([f("a", "1"), f("b", "2")], 0, "R", { files: 1 }); + const current = cur({ a: "2", c: "3" }); + sinceCheckpoint(cp, current, baseOf({ c: null })); + expect([...cp.texts.entries()]).toEqual([["a", "1"]]); + expect(cp.skipped).toEqual(["b"]); + expect([...current.entries()]).toEqual([["a", "2"], ["c", "3"]]); + }); + + test("AC-6.5 counts add up across hunks", () => { + expect(since([f("h", "1\n2\n3\n4\n5\n")], { h: "1\nX\n3\n4\nY\nZ\n" })).toEqual([ + { path: "h", status: "M", added: 3, removed: 2 }, + ]); + }); +}); diff --git a/test/client.checkpointctl.fixes.test.ts b/test/client.checkpointctl.fixes.test.ts new file mode 100644 index 0000000..4fdf9d4 --- /dev/null +++ b/test/client.checkpointctl.fixes.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import type { ChangeFile } from "../src/client/api.ts"; +import { CheckpointController, type CheckpointDeps } from "../src/client/checkpointctl.ts"; + +const file = (path: string, status: ChangeFile["status"] = "M", extra: Partial = {}): ChangeFile => ({ + path, + status, + added: null, + removed: null, + binary: false, + ...extra, +}); + +type Listing = { repo: boolean; rev: string | null; files: ChangeFile[] }; + +function make(over: Partial = {}, listing: () => Promise = async () => ({ repo: true, rev: "R", files: [] })) { + const toasts: string[] = []; + const texts: Record = {}; + const deps: CheckpointDeps = { + changes: () => listing(), + readText: async (p) => texts[p], + baseText: async () => undefined, + now: () => 1, + notify: (m) => void toasts.push(m), + pick: () => {}, + openDiff: () => {}, + ...over, + }; + return { ctl: new CheckpointController(deps), toasts, texts }; +} + +describe("R-01 a rename always records the old path as deleted", () => { + test("binary new path: old path stored as null, new path skipped", async () => { + const { ctl, toasts } = make({}, async () => ({ repo: true, rev: "R", files: [file("new.png", "R", { oldPath: "old.png", binary: true })] })); + await expect(ctl.set()).resolves.toBeUndefined(); + expect([...(ctl.checkpoint?.texts.entries() ?? [])]).toEqual([["old.png", null]]); + expect(ctl.checkpoint?.skipped).toEqual(["new.png"]); + expect(toasts).toEqual(["Checkpoint set (1 files, 1 skipped)"]); + }); + + test("unreadable new path: old path stored as null, new path skipped", async () => { + const { ctl } = make({}, async () => ({ repo: true, rev: "R", files: [file("new.ts", "R", { oldPath: "old.ts" })] })); + await expect(ctl.set()).resolves.toBeUndefined(); + expect([...(ctl.checkpoint?.texts.entries() ?? [])]).toEqual([["old.ts", null]]); + expect(ctl.checkpoint?.skipped).toEqual(["new.ts"]); + }); +}); + +describe("R-02 clear during an in-flight set wins", () => { + test("no checkpoint and no 'Checkpoint set' toast after the listing resolves", async () => { + let release: (l: Listing) => void = () => {}; + const { ctl, toasts, texts } = make({}, () => new Promise((r) => (release = r))); + texts["a.ts"] = "x"; + const pending = ctl.set(); + ctl.clear(); + release({ repo: true, rev: "R", files: [file("a.ts")] }); + await expect(pending).resolves.toBeUndefined(); + expect(ctl.checkpoint).toBeNull(); + expect(toasts).toEqual(["Checkpoint cleared"]); + }); +}); + +describe("R-04 a failing base read", () => { + test("since resolves and says it could not compare", async () => { + let first = true; + const { ctl, toasts, texts } = make( + { + baseText: async () => { + throw new Error("boom"); + }, + }, + async () => ({ repo: true, rev: "R", files: [file(first ? "a.ts" : "b.ts", first ? "M" : "?")] }), + ); + texts["a.ts"] = "x\n"; + texts["b.ts"] = "y\n"; + await ctl.set(); + expect(ctl.checkpoint).not.toBeNull(); + first = false; // a.ts is no longer listed and b.ts is new: both need a base, which rejects + await expect(ctl.since()).resolves.toBeUndefined(); + expect(toasts.at(-1)).toBe("Could not compare with the checkpoint"); + expect(ctl.checkpoint).not.toBeNull(); + }); +}); diff --git a/test/client.checkpointctl.test.ts b/test/client.checkpointctl.test.ts new file mode 100644 index 0000000..52f9576 --- /dev/null +++ b/test/client.checkpointctl.test.ts @@ -0,0 +1,451 @@ +import { describe, expect, test } from "bun:test"; +import type { ChangeFile } from "../src/client/api.ts"; +import type { PickItem } from "../src/client/palette.ts"; +import { CHECKPOINT_COMMANDS, CheckpointController, hhmm, type CheckpointDeps, type SinceDiff } from "../src/client/checkpointctl.ts"; + +const T0705 = new Date(2026, 10, 9, 7, 5).getTime(); + +const file = (path: string, status: ChangeFile["status"] = "M", extra: Partial = {}): ChangeFile => ({ + path, + status, + added: null, + removed: null, + binary: false, + ...extra, +}); + +interface World { + rev: string | null; + repo: boolean; + files: ChangeFile[]; + /** Current texts; a missing key reads as undefined (unreadable). */ + texts: Record; + /** Texts at a revision, keyed `${rev}:${path}`; a missing key reads as undefined. */ + base: Record; + clock: number; +} + +function setup(init: Partial = {}) { + const w: World = { rev: "R", repo: true, files: [], texts: {}, base: {}, clock: T0705, ...init }; + const log = { + changes: 0, + now: 0, + reads: [] as string[], + bases: [] as [string, string][], + toasts: [] as string[], + picks: [] as { items: PickItem[]; title: string }[], + opened: [] as SinceDiff[], + }; + let changesImpl: () => Promise<{ repo: boolean; rev: string | null; files: ChangeFile[] }> = async () => ({ repo: w.repo, rev: w.rev, files: w.files }); + const deps: CheckpointDeps = { + changes: () => { + log.changes++; + return changesImpl(); + }, + readText: async (p) => { + log.reads.push(p); + return p in w.texts ? w.texts[p] : undefined; + }, + baseText: async (p, rev) => { + log.bases.push([p, rev]); + const k = `${rev}:${p}`; + return k in w.base ? w.base[k] : undefined; + }, + now: () => { + log.now++; + return w.clock; + }, + notify: (m) => void log.toasts.push(m), + pick: (items, title) => void log.picks.push({ items, title }), + openDiff: (d) => void log.opened.push(d), + }; + const ctl = new CheckpointController(deps); + return { ctl, w, log, setChanges: (f: typeof changesImpl) => (changesImpl = f) }; +} + +const entries = (ctl: CheckpointController) => [...(ctl.checkpoint?.texts.entries() ?? [])]; + +describe("AC-7 command table and hhmm", () => { + test("the three commands, in order", () => { + expect(CHECKPOINT_COMMANDS.map((c) => ({ ...c }))).toEqual([ + { id: "changes.checkpoint", title: "Changes: Set Checkpoint" }, + { id: "changes.sinceCheckpoint", title: "Changes: Since Checkpoint\u2026" }, + { id: "changes.clearCheckpoint", title: "Changes: Clear Checkpoint" }, + ]); + }); + test("hhmm is local, 24-hour and zero padded", () => { + expect(hhmm(new Date(2026, 10, 9, 7, 5).getTime())).toBe("07:05"); + expect(hhmm(new Date(2026, 10, 9, 23, 59).getTime())).toBe("23:59"); + expect(hhmm(new Date(2026, 10, 9, 0, 0).getTime())).toBe("00:00"); + }); +}); + +describe("AC-8 set", () => { + test("worked example: renames, deletes, binary and unreadable files", async () => { + const { ctl, log } = setup({ + files: [ + file("a.ts"), + file("img.png", "M", { binary: true }), + file("gone.ts", "D"), + file("new.ts", "R", { oldPath: "old.ts" }), + file("big.ts"), + ], + texts: { "a.ts": "x\n", "new.ts": "n\n" }, + }); + await ctl.set(); + expect(entries(ctl)).toEqual([["a.ts", "x\n"], ["gone.ts", null], ["old.ts", null], ["new.ts", "n\n"]]); + expect(ctl.checkpoint?.skipped).toEqual(["img.png", "big.ts"]); + expect(ctl.checkpoint?.rev).toBe("R"); + expect(ctl.checkpoint?.at).toBe(T0705); + expect(log.toasts).toEqual(["Checkpoint set (4 files, 2 skipped)"]); + }); + + test("calls changes and now once; never reads a binary or deleted file", async () => { + const { ctl, log } = setup({ + files: [file("a.ts"), file("b.bin", "M", { binary: true }), file("c.ts", "D"), file("u.ts", "?")], + texts: { "a.ts": "a", "u.ts": "u" }, + }); + await ctl.set(); + expect(log.changes).toBe(1); + expect(log.now).toBe(1); + expect(log.reads.sort()).toEqual(["a.ts", "u.ts"]); + }); + + test("a rename whose old path is itself listed does not add the old path twice", async () => { + const { ctl } = setup({ + files: [file("old.ts", "D"), file("new.ts", "R", { oldPath: "old.ts" })], + texts: { "new.ts": "n" }, + }); + await ctl.set(); + expect(entries(ctl)).toEqual([["old.ts", null], ["new.ts", "n"]]); + }); + + test("toast forms: no plural, empty listing, nothing skipped", async () => { + const one = setup({ files: [file("a.ts")], texts: { "a.ts": "x" } }); + await one.ctl.set(); + expect(one.log.toasts).toEqual(["Checkpoint set (1 files)"]); + const none = setup({ files: [] }); + await none.ctl.set(); + expect(none.log.toasts).toEqual(["Checkpoint set (0 files)"]); + expect(none.ctl.checkpoint?.texts.size).toBe(0); + }); + + test("a second set replaces the first", async () => { + const { ctl, w } = setup({ files: [file("a.ts")], texts: { "a.ts": "1", "b.ts": "2" } }); + await ctl.set(); + const first = ctl.checkpoint; + expect(first).not.toBeNull(); + w.files = [file("b.ts")]; + await ctl.set(); + expect(ctl.checkpoint).not.toBe(first); + expect(entries(ctl)).toEqual([["b.ts", "2"]]); + }); + + test("a failing listing keeps the old checkpoint and says so", async () => { + const { ctl, log, setChanges } = setup({ files: [file("a.ts")], texts: { "a.ts": "1" } }); + await ctl.set(); + const cp = ctl.checkpoint; + expect(cp).not.toBeNull(); + setChanges(async () => { + throw new Error("boom"); + }); + await ctl.set(); + expect(ctl.checkpoint).toBe(cp); + expect(log.toasts.at(-1)).toBe("Could not read the changes"); + }); + + test("not a repository keeps the old checkpoint and says so", async () => { + const { ctl, log, w } = setup({ files: [file("a.ts")], texts: { "a.ts": "1" } }); + await ctl.set(); + const cp = ctl.checkpoint; + w.repo = false; + await ctl.set(); + expect(ctl.checkpoint).toBe(cp); + expect(log.toasts.at(-1)).toBe("Not a git repository"); + }); + + test("a set superseded by a later set drops its result silently", async () => { + const { ctl, log, setChanges, w } = setup({ texts: { "slow.ts": "s", "fast.ts": "f" } }); + let release: (v: { repo: boolean; rev: string | null; files: ChangeFile[] }) => void = () => {}; + setChanges(() => new Promise((r) => (release = r))); + const first = ctl.set(); + setChanges(async () => ({ repo: true, rev: w.rev, files: [file("fast.ts")] })); + await ctl.set(); + release({ repo: true, rev: "R", files: [file("slow.ts")] }); + await first; + expect(entries(ctl)).toEqual([["fast.ts", "f"]]); + expect(log.toasts).toEqual(["Checkpoint set (1 files)"]); + }); + + test("the cap-skip path works end to end: 501 files give 500 stored and 1 skipped", async () => { + const files: ChangeFile[] = []; + const texts: Record = {}; + for (let i = 0; i <= 500; i++) { + files.push(file(`f${i}`)); + texts[`f${i}`] = "x"; + } + const { ctl, log } = setup({ files, texts }); + await ctl.set(); + expect(ctl.checkpoint?.texts.size).toBe(500); + expect(ctl.checkpoint?.skipped).toEqual(["f500"]); + expect(log.toasts).toEqual(["Checkpoint set (500 files, 1 skipped)"]); + }); + + test("cap-skipped paths come before unreadable ones", async () => { + const files: ChangeFile[] = [file("unreadable.ts")]; + const texts: Record = {}; + for (let i = 0; i <= 500; i++) { + files.push(file(`f${i}`)); + texts[`f${i}`] = "x"; + } + const { ctl } = setup({ files, texts }); + await ctl.set(); + expect(ctl.checkpoint?.skipped).toEqual(["f500", "unreadable.ts"]); + }); +}); + +describe("AC-9 no checkpoint and clear", () => { + test("since with no checkpoint only toasts", async () => { + const { ctl, log } = setup({ files: [file("a.ts")], texts: { "a.ts": "x" } }); + await ctl.since(); + expect(log.toasts).toEqual(["No checkpoint set"]); + expect(log.changes).toBe(0); + expect(log.reads).toEqual([]); + expect(log.bases).toEqual([]); + expect(log.picks).toEqual([]); + expect(log.opened).toEqual([]); + }); + + test("clear forgets the checkpoint and toasts, also when none was set", async () => { + const { ctl, log } = setup({ files: [file("a.ts")], texts: { "a.ts": "x" } }); + ctl.clear(); + expect(log.toasts).toEqual(["Checkpoint cleared"]); + await ctl.set(); + expect(ctl.checkpoint).not.toBeNull(); + ctl.clear(); + expect(ctl.checkpoint).toBeNull(); + expect(log.toasts.at(-1)).toBe("Checkpoint cleared"); + await ctl.since(); + expect(log.toasts.at(-1)).toBe("No checkpoint set"); + }); +}); + +describe("AC-10 since with nothing", () => { + test("nothing changed since the checkpoint", async () => { + const { ctl, log } = setup({ files: [file("a.ts")], texts: { "a.ts": "x\n" } }); + await ctl.set(); + await ctl.since(); + expect(log.toasts.at(-1)).toBe("Nothing changed since the checkpoint (07:05)"); + expect(log.picks).toEqual([]); + }); + + test("a failing listing or a non repository keeps the checkpoint", async () => { + const { ctl, log, setChanges, w } = setup({ files: [file("a.ts")], texts: { "a.ts": "x\n" } }); + await ctl.set(); + const cp = ctl.checkpoint; + expect(cp).not.toBeNull(); + setChanges(async () => { + throw new Error("boom"); + }); + await ctl.since(); + expect(log.toasts.at(-1)).toBe("Could not read the changes"); + expect(ctl.checkpoint).toBe(cp); + setChanges(async () => ({ repo: false, rev: w.rev, files: [] })); + await ctl.since(); + expect(log.toasts.at(-1)).toBe("Not a git repository"); + expect(ctl.checkpoint).toBe(cp); + expect(log.picks).toEqual([]); + }); +}); + +/** The worked example of AC-12: a checkpoint at 07:05 holding a.ts, then a.ts edited and b.ts added. */ +async function worked() { + const s = setup({ files: [file("a.ts")], texts: { "a.ts": "x\n" } }); + await s.ctl.set(); + s.w.rev = "R2"; + s.w.files = [file("a.ts"), file("b.ts", "?")]; + s.w.texts = { "a.ts": "x\ny\n", "b.ts": "new\n" }; + s.w.base = { "R:b.ts": null }; + s.log.toasts.length = 0; + s.log.reads.length = 0; + s.log.bases.length = 0; + return s; +} + +describe("AC-11 pick list", () => { + test("title, labels, details and badges", async () => { + const { ctl, log } = await worked(); + await ctl.since(); + expect(log.picks.length).toBe(1); + const { items, title } = log.picks[0]!; + expect(title).toBe("Changes since 07:05: 2 files, type to filter"); + expect(items.map((i) => i.label)).toEqual(["a.ts", "b.ts"]); + expect(items.map((i) => i.detail)).toEqual(["+1 \u22120", "+1 \u22120"]); + expect(items.map((i) => i.icon)).toEqual([ + '', + '', + ]); + }); + + test("one entry says file, not files; +0 and -0 are shown", async () => { + const w2 = setup({ files: [file("t.ts")], texts: { "t.ts": "x" } }); + await w2.ctl.set(); + w2.w.texts = { "t.ts": "x\n" }; + await w2.ctl.since(); + const pick = w2.log.picks[0]; + expect(pick?.title).toBe("Changes since 07:05: 1 file, type to filter"); + expect(pick?.items[0]?.detail).toBe("+0 \u22120"); + }); + + test("raw path text only reaches the label, with hidden characters made visible", async () => { + const evil = ".ts"; + const bidi = "a\u202eb.ts"; + const s = setup({ files: [file(evil, "?"), file(bidi, "?")], texts: { [evil]: "1\n", [bidi]: "2\n" } }); + await s.ctl.set(); + s.w.texts = { [evil]: "1\n2\n", [bidi]: "2\n3\n" }; + await s.ctl.since(); + const items = s.log.picks[0]?.items ?? []; + expect(items.map((i) => i.label).sort()).toEqual([evil, "a\\u202eb.ts"].sort()); + for (const i of items) { + expect(i.icon).toMatch(/^