diff --git a/DESIGN.md b/DESIGN.md index 1fe6216..0ac7994 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -128,7 +128,9 @@ hijacking). Required: defeats DNS rebinding. Writes must be `application/json`, which no cross-site form can send. - Never put the token in a process's arguments (`ps` and `/proc` are readable by every user). Browsers are launched through a 0600 bootstrap page in a 0700 directory; macOS opens the - default browser via `osascript` reading from stdin. + default browser via `osascript` reading from stdin. Terminals get a separate token + (`CODEVIEWER_TOKEN`) that only `/api/open` honours, so no process in a shell holds the + session token. - Check the **Origin** on WebSocket upgrades: it must match the (validated) server origin. - Contain file access to the root. Resolve every path with `realpath` and check that it stays under the root's realpath, so symlinks can't escape. Reject `..`, NUL bytes and @@ -722,7 +724,8 @@ parsed as NUL-separated records, never lines) plus `git ls-files --others --excl untracked files, whose lines are counted by the server for the first 200 files under 1 MiB, opened with `O_NOFOLLOW` so an untracked symlink to `~/.ssh` is never read. The list is capped at 1,000 entries. The old side of a diff reuses `/api/git/base` with a `rev` parameter that must be a full -hex object id the server itself issued (never a ref name, never `-`-prefixed); with a `rev` the path +hex object id (any object reachable in the repository is accepted, since a token holder has a shell +anyway; never a ref name, never `-`-prefixed); with a `rev` the path need not exist on disk (deleted and renamed files) but is checked syntactically. Everything runs through the hardened runner of §10. Client: paths and subjects are escaped and control characters made visible (Trojan Source), rows are addressed by index, never by path in an attribute. @@ -764,3 +767,31 @@ Measured and left alone: a 100k-line file with every other line changed builds 2 2.3 ms and renders a window in 0.6 ms; one change at line 99,990 costs 9.5 ms including lexing both sides; a diff tab of an 8 MB file holds 11.6 MB. +## 16. Round 7: holistic panel (security, server, client, UX/accessibility) + +Four reviewers worked independently from the code and backed each finding with a probe or a +test; the two highest UX findings were raised by two reviewers each. Every finding below was +fixed and, where a harness exists, locked in with a test. + +| Area | Finding | Fix | +| --- | --- | --- | +| Security (critical) | `git -c key=value` splits at the *first* `=`, so a hostile repo naming a driver `a=b` kept its filter/textconv armed despite the round-3 blanking | Overrides travel as `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` (key and value separate); regression test with an `a=b` driver bound from `.git/info/attributes` | +| Security (low) | Updater fetched any asset URL the API named, with no size limits; a multibyte token header crashed the comparison; PTY upgrade sizes were unbounded | Assets only over https from GitHub's hosts, 1 MB cap on sums/signature and 1 GB on the download; byte-length token compare; upgrade sizes clamped like resizes; CSP from the request origin | +| Security (low) | Markdown external links navigated the locked-down app window itself | Handed to the default browser through `/api/external`, like terminal links | +| Server | Linked worktrees showed the main checkout's merge/rebase; nested repos listed as untracked files; deleted/submodule candidates counted as "binary/large files skipped"; `*.ts` matched only the root; `codeviewer open` failed from a symlinked cwd; a lagging client lost the final output before exit | Per-worktree op markers; editor-style globs; honest skip counts; realpath of the parent; replay before the exit notice | +| Client | DCH/ICH on the second half of a wide character orphaned the WIDE flag (a column vanished); a ZWJ joined the glyph printed after a cursor move; DECSTR kept DEC graphics; C0 bytes landed in OSC titles; closed tabs still built views for loads in flight; terminal views leaked window listeners; a change during an in-flight listing was dropped; tab-indented widths; an empty file at an empty HEAD showed "+1"; an empty list item ended a Markdown list | Fixed, with emulator, tree, code-view and parser tests | +| UX (high) | Linux: Ctrl+Shift+C with no selection sent ^C to the agent; Ctrl+P, Ctrl+=/−/0 never reached the terminal; ⌘W killed a live terminal with no guard; with "Reduce motion" every toast (incl. the OSC 52 notice) and the changed-lines flash were invisible; a lost connection was a 7 px dot | Copy chord is a no-op without a selection; Ctrl shortcuts stay out of a focused terminal on Linux; ⌘W closes exited terminals and asks for a second press otherwise; reduced-motion styles show instead of fade; "disconnected, reconnecting…" in the status bar plus a toast | +| Accessibility | No live region, invisible focus stops (tab close buttons, search results), dialog did not take focus, focus lost after closing a tab, faint text at 2.1–2.9:1 | `aria-live` region fed by every toast; focus-visible styles; dialog focus in/out; next tab focused; secondary text ≥ 3.4:1 and comments ≥ 4.2:1 in both themes; light "bright white" darker than normal text | +| Keyboard | Terminal tabs, the explorer context menu and reattaching a session taken by another window were mouse-only | ⌘⇧[ / ⌘⇧] switch terminals when the terminal is focused; Shift+F10 / Menu key in the explorer; a keystroke reattaches | + +A second pass took every remaining item, including the ones first judged not worth the change: + +| Area | Finding | Fix | +| --- | --- | --- | +| Security (info) | Every terminal process held the full session token; updater prerelease ordering compared as strings | Terminals get a second token that only `/api/open` honours; semver prerelease identifiers compared per the spec | +| Server | A search superseded by a newer one ended its stream with no summary; `/api/raw` could send a short body after a shrink and could not seek; `detectBase` picked a stale local `master` over `origin/main`; an ignored subfolder viewed as root looked clean; symlinked folders were indexed as files and forced full rebuilds; a shell that exited while no page was attached lost its exit notice | Flagged final `done` line; `Range` support with 206/416 and a failed body on a short read; upstream before local fallbacks on a base branch; `rootIgnored` in the status (the client dims everything); symlinked folders inside the root walked once, outside ones dropped; exited sessions kept for a reattach or 60 s | +| Markdown | Lazy continuation accepted ordered markers and setext underlines inside containers; link reference definitions were single-line only; any open `[` disabled every later autolink | CommonMark rules for all three, with tests | +| Terminal | No screen-reader output; OSC 8 links hid their target | A hidden `role="log"` live region receives each completed line (not in the alternate screen, never during replay); hovering a link shows its target, prefixed with "Link target:" when the label looks like a different host | +| Client | Edits inside a hunk reset opened gaps and the current hunk on live reload; a line requested for a tab still loading was dropped; YAML values such as `url: http://…` were coloured as keys; DCH/ICH probes | Same-shape reloads keep the reader's place; the line is applied when the load lands; a YAML key's colon must be followed by space or end of line | +| UX | Chrome could not be scaled; splitters, terminal tabs, diff gaps and the HEAD peek were mouse-only; same-named tabs were indistinguishable; unreadable folders looked empty; long paths clipped without an ellipsis; shortcut labels mixed macOS glyphs into Linux; ⌘-click and `codeviewer open` were taught once; closing the window could kill a running agent silently | ⌘= / ⌘- also scale the chrome (`zoom` on the chrome containers); focusable splitters with arrow keys and `aria-valuenow`; ⌘⇧[ ] switch terminals from the terminal; gaps open with Enter; "Git: Peek HEAD on Current Line"; a muted folder hint on duplicate names; "Can't read folder: …" and "Loading…" rows; ellipsized status items; platform key labels everywhere; a ⌘/ help sheet with every shortcut and both tips; `beforeunload` asks while a program (title changed) runs in a terminal | +| Accessibility | Lists lacked `aria-activedescendant`; the palette was not a combobox; the find count was silent | Stable row ids and active-descendant wiring on the explorer, Changes list, search results and palette; combobox semantics; the find count is a live region | diff --git a/README.md b/README.md index 6e2b2b8..de6ad87 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,12 @@ Closing the app window quits codeviewer. Settings live in `~/.config/codeviewer/ | ⌘⇧F / Ctrl+Shift+F | Search in files | | F4 / ⇧F4 | Next / previous search result | | ⌘B · ⌘⇧E | Toggle sidebar · focus explorer | -| ⌘W · ⌘⇧[ ⌘⇧] | Close tab · switch tabs | -| ⌘= ⌘- ⌘0 | Font size | -| ⌘⇧G · ⌥F5 / ⌥⇧F5 | Review changes · next / previous change (then next file) | +| ⌘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…") | +| ⌘= ⌘- ⌘0 | Font size (code, terminal and the UI chrome) | +| ⌘⇧G · ⌥F5 / ⌥⇧F5 | Changes · next / previous change (then next file) | +| ⌘/ | Every shortcut and the terminal tips, in your platform's key names | | ⌘-click in terminal | Open `path:line` or URL | | Left ⌥ | Meta in the terminal (right ⌥ still types special characters) | diff --git a/src/client/app.ts b/src/client/app.ts index c92ae5d..f735723 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -9,7 +9,8 @@ import type { Theme } from "./term/renderer.ts"; import { icons } from "./icons.ts"; import { GitState, relativeTime } from "./git.ts"; import { Updates } from "./updates.ts"; -import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, MOD, store } from "./util.ts"; +import { debounce, dragSplitter, esc, formatBytes, h, IS_MAC, key, MOD, store } from "./util.ts"; +import { Help } from "./help.ts"; import { isContextMenuOpen } from "./menu.ts"; import { SearchView } from "./search.ts"; import { ChangesView } from "./changes.ts"; @@ -85,6 +86,11 @@ class App { private status!: HTMLElement; private statusRight!: HTMLElement; private themeBtn!: HTMLButtonElement; + private live!: HTMLElement; + private killArmed = 0; + private help = new Help(); + private sideSplit!: HTMLElement; + private panelSplit!: HTMLElement; private toast!: HTMLElement; private recent: string[] = []; private git = new GitState(); @@ -207,7 +213,7 @@ class App { this.changes.el, ); this.setSideView(this.L.sideView, false); - const sideSplit = h("div", { class: "splitter vertical", role: "separator" }); + const sideSplit = (this.sideSplit = h("div", { class: "splitter vertical", role: "separator", "aria-label": "Sidebar width" })); // Editor const kb = (k: string, label: string) => h("div", { class: "kb-row" }, h("span", null, label), h("kbd", null, k)); @@ -221,14 +227,15 @@ class App { "div", { class: "kb" }, kb(`${MOD}P`, "Go to file"), - kb(`${MOD}⇧P`.replace("Ctrl+⇧", "Ctrl+Shift+"), "Commands"), - kb(IS_MAC ? "⌃`" : "Ctrl+`", "Toggle terminal"), + kb(key("⌘⇧P", "Ctrl+Shift+P"), "Commands"), + kb(key("⌃`", "Ctrl+`"), "Toggle terminal"), kb(`${MOD}F`, "Find in file"), - kb(IS_MAC ? "⌘⇧F" : "Ctrl+Shift+F", "Search in files"), + kb(key("⌘⇧F", "Ctrl+Shift+F"), "Search in files"), kb("F4", "Next search result"), - kb(IS_MAC ? "⌃G" : "Ctrl+G", "Go to line"), - kb(`${MOD}⇧G`, "Review changes"), + kb(key("⌃G", "Ctrl+G"), "Go to line"), + kb(key("⌘⇧G", "Ctrl+Shift+G"), "Changes"), kb(`${MOD}B`, "Toggle sidebar"), + kb(key("⌘/", "Ctrl+/"), "All shortcuts and tips"), ), h("p", { class: "welcome-hint" }, "Tip: run ", h("code", null, "codeviewer open :"), " in the terminal, or ", h("kbd", null, IS_MAC ? "⌘" : "Ctrl"), "-click a path."), ); @@ -273,7 +280,7 @@ class App { (p, line) => void this.openFromTerminal(p, line), (url) => void api.external(url), ); - const panelSplit = h("div", { class: "splitter panel-split", role: "separator" }); + const panelSplit = (this.panelSplit = h("div", { class: "splitter panel-split", role: "separator", "aria-label": "Terminal panel size", tabindex: "0" })); this.main = h("div", { class: "main" }, this.editor.el, panelSplit, this.terms.el); this.workbench = h("div", { class: "workbench" }, this.sidebar, sideSplit, this.main); @@ -284,6 +291,7 @@ class App { const statusbar = h("footer", { class: "statusbar" }, this.status, this.statusRight); this.toast = h("div", { class: "toast", hidden: true }); + this.live = h("div", { class: "sr-only", role: "status", "aria-live": "polite" }); window.addEventListener("cv-toast", (e) => this.notify((e as CustomEvent).detail)); // Updates: launch check result, the Mac app's menu item, and its native restart. this.updates.onInfo = () => this.renderStatus(this.lastStatus); @@ -296,7 +304,13 @@ class App { this.notify(`Codeviewer ${u.latest} is available: click "Update" in the status bar`); } }); - document.body.replaceChildren(titlebar, this.workbench, statusbar, this.palette.el, this.updates.el, this.toast); + document.body.replaceChildren(titlebar, this.workbench, statusbar, this.palette.el, this.updates.el, this.help.el, this.toast, this.live); + // Closing the window takes every terminal down: ask first while a program is running in one. + window.addEventListener("beforeunload", (e) => { + if (!this.terms.busy) return; + e.preventDefault(); + e.returnValue = ""; + }); // Splitters dragSplitter(sideSplit, "x", () => L.sidebarWidth, (s, d) => { @@ -326,6 +340,17 @@ class App { panelSplit.addEventListener("pointermove", move); panelSplit.addEventListener("pointerup", up); }); + panelSplit.addEventListener("keydown", (e) => { + // Towards the editor shrinks the panel, away from it grows it, 16px per press. + const grow = L.panelPos === "bottom" ? "ArrowUp" : "ArrowLeft"; + const shrink = L.panelPos === "bottom" ? "ArrowDown" : "ArrowRight"; + if (e.key !== grow && e.key !== shrink) return; + e.preventDefault(); + const mainRect = this.main.getBoundingClientRect(); + const max = (L.panelPos === "bottom" ? mainRect.height : mainRect.width) - 120; + L.panelSize[L.panelPos] = Math.max(120, Math.min(max, L.panelSize[L.panelPos] + (e.key === grow ? 16 : -16))); + this.applyLayout(); + }); this.palette.recent = this.recent; this.palette.onOpenFile = (p, line) => void this.openFile(p, { pin: true, line }); @@ -341,9 +366,11 @@ class App { private commands(): Command[] { const c = (id: string, label: string, run: () => void, keys?: string): Command => ({ id, label, run, keys }); return [ - c("term.toggle", "Toggle Terminal", () => this.togglePanel(), IS_MAC ? "⌃`" : "Ctrl+`"), + c("term.toggle", "Toggle Terminal", () => this.togglePanel(), key("⌃` · ⌘J", "Ctrl+` · Ctrl+J")), c("term.new", "New Terminal", () => this.newTerminal(), IS_MAC ? "⌃⇧`" : "Ctrl+Shift+`"), c("term.kill", "Kill Active Terminal", () => this.terms.close()), + c("term.next", "Next Terminal", () => this.terms.cycle(1), key("⌘⇧] in terminal", "Ctrl+Shift+] in terminal")), + c("term.prev", "Previous Terminal", () => this.terms.cycle(-1), key("⌘⇧[ in terminal", "Ctrl+Shift+[ in terminal")), c("term.pos", "Move Terminal Panel (Bottom/Right)", () => this.setPanelPos(this.L.panelPos === "bottom" ? "right" : "bottom")), c("term.max", "Maximize/Restore Terminal Panel", () => this.toggleMaximize()), c("term.clear", "Clear Terminal", () => this.terms.active?.view.clear()), @@ -354,7 +381,7 @@ class App { 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"), - c("search.prev", "Previous Search Result", () => this.search.step(-1), "⇧F4"), + c("search.prev", "Previous Search Result", () => this.search.step(-1), key("⇧F4", "Shift+F4")), c("view.explorer", "Show Explorer", () => this.setSideView("explorer")), markdownToggleCommand({ isMarkdownTab: () => this.editor.isMarkdownTab(), toggleMarkdown: () => this.editor.toggleMarkdown() }), c("file.find", "Find in File", () => this.editor.openFind(), `${MOD}F`), @@ -363,11 +390,19 @@ class App { c("file.copyPath", "Copy Path of Active File", () => this.copyPath(false)), c("file.copyAbs", "Copy Absolute Path of Active File", () => this.copyPath(true)), c("file.close", "Close Tab", () => this.editor.close(), IS_MAC ? "⌘W" : "Ctrl+W"), + c("file.next", "Next Tab", () => this.editor.cycle(1), key("⌘⇧] · ⌃Tab", "Ctrl+Shift+] · Ctrl+Tab")), + c("file.prev", "Previous Tab", () => this.editor.cycle(-1), key("⌘⇧[ · ⌃⇧Tab", "Ctrl+Shift+[ · Ctrl+Shift+Tab")), c("file.closeAll", "Close All Tabs", () => this.editor.closeAll()), c("tree.collapse", "Collapse All Folders", () => this.tree.collapseAll()), c("tree.clear", "Clear Change Markers", () => this.tree.clearChanges()), - c("tree.focus", "Focus Explorer", () => this.tree.focus(), `${MOD}⇧E`), - c("git.changes", "Git: Review Changes", () => this.showChanges(), `${MOD}⇧G`), + c("tree.focus", "Focus Explorer", () => this.tree.focus(), key("⌘⇧E", "Ctrl+Shift+E")), + c("git.changes", "Git: Show Changes", () => this.showChanges(), key("⌘⇧G", "Ctrl+Shift+G")), + c("git.peek", "Git: Peek HEAD on Current Line", () => { + const code = this.editor.active?.code; + if (!code) this.notify("Open a file first"); + else if (!code.peekCurrent()) this.notify("No change on this line"); + }), + c("app.help", "Help: Keyboard Shortcuts & Tips", () => this.help.open(), key("⌘/", "Ctrl+/")), 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", () => { @@ -375,8 +410,8 @@ class App { store.set("updates", { auto }); this.notify(`Automatic update checks ${auto ? "on" : "off"} (from the next launch)`); }), - c("git.next", "Git: Next Change", () => this.editor.nextChange(1), "⌥F5"), - c("git.prev", "Git: Previous Change", () => this.editor.nextChange(-1), "⌥⇧F5"), + 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")), ]; } @@ -501,9 +536,15 @@ class App { } this.editor.setFontSize(L.codeSize); this.terms.setFont(cssVar("--font-mono"), L.termSize, TERM_LINE_HEIGHT); + this.applyUiScale(); this.saveLayout(); } + /** The chrome (sidebar, tabs, status bar, palette) follows the code size, so zooming scales everything. */ + private applyUiScale() { + document.documentElement.style.setProperty("--ui-scale", (this.L.codeSize / BASE_SIZE).toFixed(3)); + } + private cycleTheme() { const order: ThemeName[] = ["system", "dark", "light"]; this.L.theme = order[(order.indexOf(this.L.theme) + 1) % order.length]!; @@ -529,6 +570,10 @@ class App { const L = this.L; const wb = this.workbench; wb.style.setProperty("--sidebar-w", `${L.sidebarWidth}px`); + this.sideSplit.setAttribute("aria-valuenow", String(L.sidebarWidth)); + this.panelSplit.setAttribute("aria-valuenow", String(L.panelSize[L.panelPos])); + this.panelSplit.setAttribute("aria-orientation", L.panelPos === "bottom" ? "horizontal" : "vertical"); + this.applyUiScale(); wb.style.setProperty("--panel-h", `${L.panelSize.bottom}px`); wb.style.setProperty("--panel-w", `${L.panelSize.right}px`); wb.classList.toggle("no-sidebar", !L.sidebar); @@ -542,6 +587,7 @@ class App { private saveLayout = debounce(() => store.set("layout", { ...this.L, v: LAYOUT_VERSION }), 200); private notify(msg: string) { + this.live.textContent = msg; // announced by screen readers this.toast.textContent = msg; this.toast.hidden = false; this.toast.classList.remove("show"); @@ -553,7 +599,8 @@ class App { private renderStatus(s: StatusInfo | null) { this.lastStatus = s; - const item = (text: string, title = "") => h("span", { class: "st-item", title }, text); + // The text sits in its own span so a long path gets an ellipsis (flex items don't ellipsize bare text). + const item = (text: string, title = "") => h("span", { class: "st-item", title }, h("span", { class: "st-text" }, text)); const left: HTMLElement[] = [h("span", { class: "st-item st-root", title: this.info.root }, h("span", { class: "st-dot live" }), this.info.name)]; const g = this.git.status; if (g.repo) { @@ -577,7 +624,7 @@ class App { if (c.conflicts) parts.push(`${c.conflicts} conflict${c.conflicts === 1 ? "" : "s"}`); if (c.changed) parts.push(`${c.changed} changed`); if (c.untracked) parts.push(`${c.untracked} untracked`); - const changes = h("button", { class: "st-item", title: `${c.staged} staged · click to list changed files (${MOD}⇧G)`, onclick: () => this.showChanges() }); + const changes = h("button", { class: "st-item", title: `${c.staged} staged · click to show Changes (${key("⌘⇧G", "Ctrl+Shift+G")})`, onclick: () => this.showChanges() }); changes.innerHTML = parts.join('·'); left.push(changes); } @@ -627,6 +674,10 @@ class App { const mod = IS_MAC ? e.metaKey : e.ctrlKey; const k = e.key.toLowerCase(); const inTerm = (e.target as HTMLElement).classList?.contains("term-input"); + const inPalette = (e.target as HTMLElement).classList?.contains("palette-input"); + // On Linux the modifier is Ctrl, which terminal programs use heavily (^P history, ^W + // delete word, ^_ undo…): plain Ctrl shortcuts stay out of a focused terminal there. + const appKey = IS_MAC || !inTerm; let handled = true; const sk = searchKey(e, { isMac: IS_MAC, inTerm: !!inTerm }); if (sk === "search") this.showSearch(); @@ -638,7 +689,7 @@ class App { } else if (mod && e.shiftKey && k === "g") this.showChanges(); else if (e.altKey && e.key === "F5" && !inTerm) this.editor.nextChange(e.shiftKey ? -1 : 1); else if (mod && e.shiftKey && k === "p") this.palette.open(">"); - else if (mod && !e.shiftKey && k === "p") this.palette.open(); + else if (mod && !e.shiftKey && k === "p" && appKey && (IS_MAC || !inPalette)) this.palette.open(); // Linux: Ctrl+P is the palette's own "up" else if (mod && e.shiftKey && k === "e") { if (!this.L.sidebar) this.toggleSidebar(true); this.tree.focus(); @@ -646,13 +697,30 @@ class App { else if (mod && !e.shiftKey && k === "f" && !inTerm) this.editor.openFind(); else if (e.ctrlKey && k === "g" && !inTerm) this.palette.open(":"); else if (mod && k === "w" && !inTerm) this.editor.close(); - else if (mod && k === "w" && inTerm && IS_MAC) this.terms.close(); - else if (mod && e.shiftKey && (e.code === "BracketRight" || e.code === "BracketLeft")) this.editor.cycle(e.code === "BracketRight" ? 1 : -1); + else if (mod && k === "w" && inTerm) { + // Closing an exited terminal kills nothing. A live one (an agent may be running) needs + // a second press; on Linux Ctrl+W belongs to the shell and the exited case is the only one. + if (this.terms.activeExited) this.terms.close(); + else if (!IS_MAC) handled = false; + else if (performance.now() - this.killArmed < 3000) { + this.killArmed = 0; + this.terms.close(); + } else { + this.killArmed = performance.now(); + this.notify(`Press ${MOD}W again to kill this terminal`); + } + } else if (mod && e.shiftKey && (e.code === "BracketRight" || e.code === "BracketLeft")) { + const dir = e.code === "BracketRight" ? 1 : -1; + if (inTerm) this.terms.cycle(dir); + 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 === "+")) this.zoom(1); - else if (mod && k === "-") this.zoom(-1); - else if (mod && k === "0") this.zoom(0); + 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" && !inTerm) this.togglePanel(); + else if (mod && k === "/" && appKey) this.help.open(); + else if (e.key === "Escape" && this.help.isOpen) this.help.close(); else if (e.key === "Escape" && this.palette.isOpen) this.palette.close(); else handled = false; if (handled) { @@ -670,6 +738,7 @@ class App { const ws = new WebSocket(wsUrl("/ws/events")); ws.onopen = () => { retry = 500; + if (document.body.classList.contains("offline")) this.notify("Reconnected to codeviewer"); document.body.classList.remove("offline"); }; ws.onmessage = (ev) => { @@ -697,6 +766,7 @@ class App { } }; ws.onclose = () => { + if (!document.body.classList.contains("offline")) this.notify("Connection to codeviewer lost: reconnecting…"); document.body.classList.add("offline"); setTimeout(connect, (retry = Math.min(retry * 2, 8000))); }; diff --git a/src/client/changes.ts b/src/client/changes.ts index a271836..c02d2c2 100644 --- a/src/client/changes.ts +++ b/src/client/changes.ts @@ -252,9 +252,15 @@ export class ChangesView { r.setAttribute("aria-selected", String(k === this.sel)); }); } else this.render(); + this.syncActive(); this.list.querySelector(".ch-row.sel")?.scrollIntoView?.({ block: "nearest" }); } + private syncActive() { + if (this.sel >= 0 && this.sel < this.files.length) this.list.setAttribute("aria-activedescendant", `ch-${this.sel}`); + else this.list.removeAttribute("aria-activedescendant"); + } + private accept(pin: boolean) { const f = this.files[this.sel]; if (f) this.onOpen(f, pin); @@ -326,10 +332,11 @@ export class ChangesView { ? `—` : `${f.added ? `+${f.added}` : ""}${f.removed ? `−${f.removed}` : ""}`; html += - `
` + + `
` + `${letter}${name}${dir}${counts}
`; }); if (d.truncated) html += `
Showing the first ${files.length.toLocaleString()} files
`; this.list.innerHTML = html; + this.syncActive(); } } diff --git a/src/client/codeview.ts b/src/client/codeview.ts index b581a21..6221e43 100644 --- a/src/client/codeview.ts +++ b/src/client/codeview.ts @@ -106,7 +106,7 @@ export class CodeView { this.sizer = h("div", { class: "code-sizer" }, this.linesEl); this.scroller = h("div", { class: "code-scroll", tabindex: "-1" }, this.sizer); this.findInput = h("input", { class: "find-input", placeholder: "Find", spellcheck: "false", "aria-label": "Find in file" }); - this.findCount = h("span", { class: "find-count" }); + this.findCount = h("span", { class: "find-count", role: "status", "aria-live": "polite" }); const toggle = (label: string, key: keyof CodeView["opts"], title: string) => { const b = h("button", { class: "find-opt", title, "aria-pressed": "false" }, label); b.addEventListener("click", () => { @@ -412,7 +412,9 @@ export class CodeView { this.hunks = [{ a0: 0, a1: 0, b0: 0, b1: n }]; this.gitMarks = new Uint8Array(n).fill(ADDED); } else if (this.gitBase) { - this.hunks = diffLines(this.gitBase, this.lines); + // An empty buffer is one empty line in the view but no lines for the diff (as the base is). + const cur = n === 1 && this.lines[0] === "" ? [] : this.lines; + this.hunks = diffLines(this.gitBase, cur); this.gitMarks = gutterMarks(this.hunks, n); } else { this.hunks = []; @@ -478,8 +480,19 @@ export class CodeView { ); } - /** Read-only peek at what HEAD had for the change under `line`. */ - private togglePeek(line: number, rowEl: HTMLElement) { + /** Peeks at HEAD for the change on the active line; false when that line is unchanged. */ + peekCurrent(): boolean { + const line = this.activeLine >= 0 ? this.activeLine : Math.floor(this.scroller.scrollTop / this.lh); + if (!this.gitMarks[line]) { + this.closePeek(); + return false; + } + this.togglePeek(line, null); + return true; + } + + /** Read-only peek at what HEAD had for the change under `line` (`rowEl` null: computed from the line). */ + private togglePeek(line: number, rowEl: HTMLElement | null) { const hk = this.hunkAt(line); if (!hk || (this.peek && this.peek.dataset.hunk === `${hk.b0}:${hk.a0}`)) return this.closePeek(); this.closePeek(); @@ -500,8 +513,8 @@ export class CodeView { box.insertAdjacentHTML("beforeend", body); box.dataset.hunk = `${hk.b0}:${hk.a0}`; const host = this.el.getBoundingClientRect(); - const row = rowEl.getBoundingClientRect(); - box.style.top = `${row.bottom - host.top + 2}px`; + const rowBottom = rowEl ? rowEl.getBoundingClientRect().bottom - host.top : this.scroller.getBoundingClientRect().top - host.top + (line + 1) * this.lh - this.scroller.scrollTop; + box.style.top = `${rowBottom + 2}px`; box.style.left = `${this.gutterW - 6}px`; this.el.append(box); this.peek = box; @@ -522,7 +535,8 @@ export class CodeView { const lines = this.lines; for (let i = 0; i < lines.length; i++) { const l = lines[i]!; - if (l.length > max) max = l.length + (l.includes("\t") ? countTabs(l) * 3 : 0); + const w = l.length + (l.includes("\t") ? countTabs(l) * 3 : 0); + if (w > max) max = w; } max = Math.min(max, MAX_RENDER_COLS); const digits = String(lines.length).length; diff --git a/src/client/diffview.ts b/src/client/diffview.ts index 15b7bd6..8feb6af 100644 --- a/src/client/diffview.ts +++ b/src/client/diffview.ts @@ -167,11 +167,16 @@ export class DiffView { this.scroller.addEventListener("scroll", () => this.onScroll(), { passive: true }); this.ro = new ResizeObserver(() => this.schedule()); this.ro.observe(this.scroller); - this.rowsEl.addEventListener("click", (e) => { + const openGap = (e: Event) => { const gap = (e.target as HTMLElement).closest(".dr-gap"); if (!gap) return; + e.preventDefault(); this.open.add(Number(gap.dataset.g)); this.rebuild(); + }; + this.rowsEl.addEventListener("click", openGap); + this.rowsEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") openGap(e); }); } @@ -221,8 +226,14 @@ export class DiffView { this.newLines = splitLines(newText ?? ""); this.hlA = new Highlighter(this.lang, this.oldLines); this.hlB = new Highlighter(this.lang, this.newLines); - this.hunks = diffLines(this.oldLines, this.newLines); - this.open.clear(); + const hunks = diffLines(this.oldLines, this.newLines); + // A reload that keeps the same number of hunks (an edit inside one) keeps the opened gaps + // and the current hunk; anything else starts over. + if (hunks.length !== this.hunks.length) { + this.open.clear(); + this.cur = -1; + } + this.hunks = hunks; if (this.hunks.length) { this.notice.hidden = true; this.rows = buildRows(this.hunks, this.oldLines.length, this.newLines.length, this.open); @@ -232,7 +243,7 @@ export class DiffView { this.showNotice(oldText === newText ? "No differences." : "Only line endings or the final newline changed."); } } - this.cur = -1; + if (this.cur >= this.hunks.length) this.cur = -1; this.updateCounts(); this.relayout(); this.scroller.scrollTop = Math.min(top, Math.max(0, this.rows.length * this.lh - (this.scroller.clientHeight || 0))); @@ -342,7 +353,7 @@ export class DiffView { for (let i = first; i < last; i++) { const r = this.rows[i]!; if (r.k === "gap") { - html += `
⋯${r.n.toLocaleString()} unchanged lines
`; + html += `
⋯${r.n.toLocaleString()} unchanged lines
`; continue; } let cls = `dr dr-${r.k}`; diff --git a/src/client/editor.ts b/src/client/editor.ts index 793ff6b..d22f12c 100644 --- a/src/client/editor.ts +++ b/src/client/editor.ts @@ -8,7 +8,7 @@ import { isMarkdown } from "./markdown.ts"; import { MarkdownView } from "./mdview.ts"; import { decidePreview, tooLargeNote } from "./mdgate.ts"; import { planView, toggleLabel, toggleMode, viewMode } from "./viewstate.ts"; -import { basename, debounce, esc, formatBytes, h, MOD } from "./util.ts"; +import { basename, debounce, dirname, esc, formatBytes, h, MOD } from "./util.ts"; import { GitState, relativeTime } from "./git.ts"; type Kind = "text" | "image" | "binary" | "error" | "dir"; @@ -35,6 +35,8 @@ export interface Tab { truncated: boolean; eol: "LF" | "CRLF"; encoding: string; + /** A line (and range) to show once the first load lands. */ + goto?: { line: number; col?: number; len?: number }; /** Bumped per load/diff request; stale async results are dropped. */ gen: number; /** HEAD oid + git status the gutter was computed for (skip identical refreshes). */ @@ -137,7 +139,7 @@ export class Editor { if (opts.line && tab.code) { if (opts.col !== undefined && opts.len) tab.code.showRange(opts.line - 1, opts.col, opts.col + opts.len); else tab.code.revealLine(opts.line - 1); - } + } else if (opts.line && !tab.md && !tab.image) tab.goto = { line: opts.line, col: opts.col, len: opts.len }; // still loading if (opts.focus !== false) tab.code?.focus(); this.render(); } @@ -321,6 +323,12 @@ export class Editor { } } } + if (tab.goto && tab.code) { + const g = tab.goto; + tab.goto = undefined; + if (g.col !== undefined && g.len) tab.code.showRange(g.line - 1, g.col, g.col + g.len); + else tab.code.revealLine(g.line - 1); + } await this.refreshGit(tab, !reload); } else if (f.kind === "image") { if (tab.image && reload) tab.image.reload(tab.mtime); @@ -418,8 +426,10 @@ export class Editor { if (this.active === tab) { this.active = null; const next = this.tabs[Math.min(i, this.tabs.length - 1)]; - if (next) this.activate(next); - else { + if (next) { + this.activate(next); + if (document.activeElement === document.body) (next.code ?? next.diff)?.focus(); + } else { this.body.replaceChildren(this.empty); this.onActive(null); this.status(); @@ -446,6 +456,7 @@ export class Editor { } private disposeTab(tab: Tab) { + tab.gen++; // a load still in flight must not build a view for a closed tab tab.code?.dispose(); tab.diff?.dispose(); tab.md?.dispose(); @@ -572,6 +583,17 @@ export class Editor { } render() { + // Same-named files from different folders: show the nearest folder that differs. + const byName = new Map>(); + for (const t of this.tabs) (byName.get(basename(t.path)) ?? byName.set(basename(t.path), new Set()).get(basename(t.path))!).add(t.path); + const hint = (p: string) => { + const paths = byName.get(basename(p))!; + if (paths.size < 2) return ""; + const dir = dirname(p); + const parent = dir.slice(dir.lastIndexOf("/") + 1); + const parents = new Set([...paths].map((x) => dirname(x)).map((d) => d.slice(d.lastIndexOf("/") + 1))); + return parents.size === paths.size ? parent : dir || "/"; + }; for (const t of this.tabs) { const cls = ["tab"]; if (t === this.active) cls.push("active"); @@ -583,7 +605,7 @@ export class Editor { t.tabEl.className = cls.join(" "); t.tabEl.title = g ? `${t.path} · ${g.label}${t.isDiff ? ` · diff ${this.diffContext.vs}` : ""}` : t.path; t.tabEl.setAttribute("aria-selected", String(t === this.active)); - t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(basename(t.path))}${t.isDiff ? 'diff' : ""}`; + t.tabEl.innerHTML = `${fileIcon(t.path)}${esc(basename(t.path))}${hint(t.path) ? `${esc(hint(t.path))}` : ""}${t.isDiff ? 'diff' : ""}`; t.tabEl.querySelector(".tab-close")!.addEventListener("click", (e) => { e.stopPropagation(); this.close(t); diff --git a/src/client/git.ts b/src/client/git.ts index b5f42c5..c53bc47 100644 --- a/src/client/git.ts +++ b/src/client/git.ts @@ -12,6 +12,8 @@ export interface GitStatus { stash: number; files: Record; head: { hash: string; subject: string; author: string; time: number } | null; + /** The viewed folder is itself git-ignored: everything in it is ignored (git lists nothing inside). */ + rootIgnored?: boolean; } /** Display status, VS Code style: Modified, Added, Untracked, Deleted, Renamed, Conflict, Ignored. */ @@ -55,10 +57,13 @@ export class GitState { private prev = { files: this.files, prefixes: this.prefixes }; private dirs = new Map(); private listeners = new Set<() => void>(); + private rootIgnored = false; + private static readonly IGNORED: FileGit = { letter: "I", label: "Ignored", xy: "!!" }; counts = { changed: 0, staged: 0, untracked: 0, conflicts: 0 }; set(s: GitStatus) { this.status = s; + this.rootIgnored = !!s.rootIgnored; this.prev = { files: this.files, prefixes: this.prefixes }; this.files = new Map(); this.prefixes = new Map(); @@ -95,7 +100,7 @@ export class GitState { file(path: string): FileGit | null { path = nfc(path); - return this.files.get(path) ?? underPrefix(this.prefixes, path); + return this.files.get(path) ?? underPrefix(this.prefixes, path) ?? (this.rootIgnored ? GitState.IGNORED : null); } /** True if the last status update took `path` from changed to clean or ignored. */ @@ -108,7 +113,7 @@ export class GitState { /** Status of a directory: its own (untracked/ignored) or the strongest inside it. */ dir(path: string): Letter | null { path = nfc(path); - return (this.prefixes.get(path) ?? underPrefix(this.prefixes, path))?.letter ?? this.dirs.get(path) ?? null; + return (this.prefixes.get(path) ?? underPrefix(this.prefixes, path))?.letter ?? this.dirs.get(path) ?? (this.rootIgnored ? "I" : null); } /** Changed (non-ignored) files, most important first. */ diff --git a/src/client/help.ts b/src/client/help.ts new file mode 100644 index 0000000..a456d53 --- /dev/null +++ b/src/client/help.ts @@ -0,0 +1,73 @@ +import { icons } from "./icons.ts"; +import { h, IS_MAC, key } from "./util.ts"; + +/** A sheet of every shortcut and the two terminal tips, in the platform's own key names (⌘/ or Ctrl+/). */ +export class Help { + readonly el: HTMLElement; + private box: HTMLElement; + private returnTo: Element | null = null; + + constructor() { + const row = (k: string, label: string) => h("div", { class: "kb-row" }, h("span", null, label), h("kbd", null, k)); + const group = (title: string, rows: HTMLElement[]) => [h("h3", null, title), h("div", { class: "kb" }, ...rows)]; + this.box = h( + "div", + { class: "upd help", role: "dialog", "aria-modal": "true", "aria-label": "Keyboard shortcuts", tabindex: "-1" }, + h("div", { class: "upd-head" }, h("div", { class: "upd-logo", html: icons.search }), h("div", null, h("h2", null, "Keyboard shortcuts"), h("p", null, `Press ${key("⌘/", "Ctrl+/")} any time to see this sheet.`)), h("button", { class: "btn", onclick: () => this.close() }, "Close")), + ...group("Files", [ + row(key("⌘P", "Ctrl+P"), "Go to file (path:line works)"), + row(key("⌘⇧P", "Ctrl+Shift+P"), "Command palette"), + row(key("⌘⇧E", "Ctrl+Shift+E"), "Focus the explorer"), + row(key("⇧F10", "Shift+F10"), "Context menu for the selected explorer entry"), + row(key("⌘B", "Ctrl+B"), "Toggle the sidebar"), + row(key("⌘W", "Ctrl+W"), "Close tab"), + row(key("⌘⇧[ ⌘⇧] · ⌃Tab", "Ctrl+Shift+[ ] · Ctrl+Tab"), "Previous / next tab"), + ]), + ...group("In a file", [ + row(key("⌘F", "Ctrl+F"), "Find in file"), + row(key("⌃G", "Ctrl+G"), "Go to line"), + row(key("⌘⇧F", "Ctrl+Shift+F"), "Search in files"), + row(key("F4 · ⇧F4", "F4 · Shift+F4"), "Next / previous search result"), + row(key("⌘= ⌘- ⌘0", "Ctrl+= Ctrl+- Ctrl+0"), "Font size (code, terminal and chrome)"), + ]), + ...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("click a gutter mark", "Peek at what HEAD had (or run \u201cGit: Peek HEAD\u201d from the palette)"), + ]), + ...group("Terminal", [ + row(key("⌃`", "Ctrl+`"), "Toggle / focus the terminal"), + row(key("⌃⇧`", "Ctrl+Shift+`"), "New terminal"), + row(key("⌘J", "Ctrl+J"), "Hide / show the terminal panel"), + row(key("⌘⇧[ ⌘⇧]", "Ctrl+Shift+[ ]"), "Previous / next terminal (while the terminal has focus)"), + row(key("⌘W", "Ctrl+W"), IS_MAC ? "Close an exited terminal; a running one asks for a second press" : "Close an exited terminal (a running shell keeps Ctrl+W)"), + row(key("⌘-click", "Ctrl+click"), "Open a path:line or a URL printed in the terminal"), + row(key("⌘C · ⌘V", "Ctrl+Shift+C · Ctrl+Shift+V"), "Copy the selection · paste"), + ]), + h("p", { class: "welcome-hint" }, "Tip: run ", h("code", null, "codeviewer open :"), " inside the terminal and the viewer opens that file. Agents can use it to show you what they mean."), + ); + this.el = h("div", { class: "overlay upd-overlay", hidden: true }, this.box); + this.el.addEventListener("mousedown", (e) => { + if (e.target === this.el) this.close(); + }); + window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !this.el.hidden) this.close(); + }); + } + + get isOpen() { + return !this.el.hidden; + } + + open() { + this.returnTo = document.activeElement; + this.el.hidden = false; + this.box.focus(); + } + + close() { + this.el.hidden = true; + (this.returnTo as HTMLElement | null)?.focus?.(); + this.returnTo = null; + } +} diff --git a/src/client/highlight.ts b/src/client/highlight.ts index 87f7626..1728ada 100644 --- a/src/client/highlight.ts +++ b/src/client/highlight.ts @@ -391,7 +391,8 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] } // JSON/YAML-style keys: "name": value let tok: Tok = "s"; - if (L.colonProps && /^\s*:/.test(text.slice(e, e + 8))) tok = "p"; + // YAML: a key's colon is followed by space or the end of line; "http://" is a value. + if (L.colonProps && (L.special === "yaml" ? /^\s*:(?=\s|$)/ : /^\s*:/).test(text.slice(e, e + 8))) tok = "p"; out.push([i, e, tok]); i = e; continue outer; @@ -490,7 +491,8 @@ export function lexLine(langId: string, text: string, state: number, out: Span[] while (j < n && text.charCodeAt(j) === 32) j++; const next = text[j]; if (next === "(" || (next === "!" && langId === "rs" && text[j + 1] === "(")) tok = "f"; - else if (L.colonProps && next === ":" && text[j + 1] !== ":") tok = "p"; + // YAML keys end in ": " or ":" at the end of the line; "http://" inside a value is not a key. + else if (L.colonProps && next === ":" && text[j + 1] !== ":" && (L.special !== "yaml" || j + 1 >= n || /\s/.test(text[j + 1]!))) tok = "p"; else if (/^[A-Z][a-z0-9]/.test(w) && langId !== "sql") tok = "t"; else if (/^[A-Z][A-Z0-9_]+$/.test(w) && w.length > 1) tok = "o"; else if (prevCh === "." && langId !== "sql") tok = null; diff --git a/src/client/markdown.ts b/src/client/markdown.ts index 1caf4eb..fbb2388 100644 --- a/src/client/markdown.ts +++ b/src/client/markdown.ts @@ -301,42 +301,87 @@ function unescapeStr(s: string): string { type Defs = Map; const normLabel = (l: string) => l.trim().replace(/\s+/g, " ").toLowerCase(); -function parseDef(line: string): { label: string; href: string; title: string | null } | null { - let i = indent(line); - if (i > 3 || line[i] !== "[") return null; - let j = i + 1; - while (j < line.length && j - i <= MAX_LABEL && line[j] !== "]") { - if (line[j] === "[") return null; - if (line[j] === "\\") j++; +interface Def { + label: string; + href: string; + title: string | null; + /** Offset of the line after the definition in the paragraph text. */ + end: number; +} + +/** + * A link reference definition starting at `s[p]` (the start of a paragraph line). The destination + * and the title may each sit on the line after (CommonMark 4.7); a title that does not take up the + * rest of its line(s) is dropped when it started on its own line and voids the definition otherwise. + */ +function parseDef(s: string, p: number): Def | null { + if (s[p] !== "[") return null; + let j = p + 1; + while (j < s.length && j - p <= MAX_LABEL && s[j] !== "]") { + if (s[j] === "[") return null; + if (s[j] === "\\") j++; j++; } - if (line[j] !== "]" || line[j + 1] !== ":") return null; - const label = line.slice(i + 1, j); - if (isBlank(label)) return null; - let k = j + 2; - while (isWs(line[k])) k++; + if (s[j] !== "]" || s[j + 1] !== ":") return null; + const label = s.slice(p + 1, j); + if (/^\s*$/.test(label)) return null; + // Whitespace with at most one line ending. + const skip = (k: number): number => { + while (isWs(s[k])) k++; + if (s[k] === "\n") { + k++; + while (isWs(s[k])) k++; + } + return k; + }; + let k = skip(j + 2); let href: string; - if (line[k] === "<") { - const e = line.indexOf(">", k + 1); - if (e < 0) return null; - href = line.slice(k + 1, e); + if (s[k] === "<") { + let e = k + 1; + while (e < s.length && s[e] !== ">" && s[e] !== "<" && s[e] !== "\n") e += s[e] === "\\" ? 2 : 1; + if (s[e] !== ">") return null; + href = s.slice(k + 1, e); k = e + 1; } else { const st = k; - while (k < line.length && !isWs(line[k])) k++; - href = line.slice(st, k); - if (!href) return null; + let depth = 0; + while (k < s.length) { + const c = s[k]!; + if (c === "\\" && k + 1 < s.length && PUNCT.test(s[k + 1]!)) { + k += 2; + continue; + } + if (c <= " " || c === "\x7f") break; + if (c === "(") depth++; + else if (c === ")") { + if (depth === 0) break; + depth--; + } + k++; + } + if (k === st || depth !== 0) return null; + href = s.slice(st, k); } const afterDest = k; - while (isWs(line[k])) k++; - if (k >= line.length) return { label, href: unescapeStr(href), title: null }; - if (k === afterDest) return null; - const open = line[k]!; + while (isWs(s[k])) k++; + // The definition can end here (no title) when nothing else follows on the line. + const lineEnd = k >= s.length || s[k] === "\n" ? (k >= s.length ? s.length : k + 1) : -1; + if (lineEnd < 0 && k === afterDest) return null; + k = skip(afterDest); + const open = s[k]; const close = open === "(" ? ")" : open; - if (open !== '"' && open !== "'" && open !== "(") return null; - const rest = line.slice(k).trimEnd(); - if (rest.length < 2 || rest[rest.length - 1] !== close) return null; - return { label, href: unescapeStr(href), title: unescapeStr(rest.slice(1, -1)) }; + const def = (title: string | null, end: number): Def => ({ label, href: unescapeStr(href), title, end }); + if (open === '"' || open === "'" || open === "(") { + let e = k + 1; + while (e < s.length && s[e] !== close && !(open === "(" && s[e] === "(")) e += s[e] === "\\" ? 2 : 1; + if (s[e] === close) { + let t = e + 1; + while (isWs(s[t])) t++; + if (t >= s.length) return def(unescapeStr(s.slice(k + 1, e)), s.length); + if (s[t] === "\n") return def(unescapeStr(s.slice(k + 1, e)), t + 1); + } + } + return lineEnd < 0 ? null : def(null, lineEnd); } // --------------------------------------------------------------------------------------------- @@ -400,7 +445,44 @@ function track(t: FenceTrack, line: string) { } else t.f = fenceOpen(line); } -function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[]; gap: boolean } { +/** Takes the link reference definitions off the start of a paragraph; returns the lines left over. */ +function takeDefs(para: string[], ctx: Ctx): string[] { + if (para[0]![0] !== "[") return para; + const text = para.join("\n"); + let pos = 0; + while (pos < text.length && ctx.defs.size < MAX_DEFS) { + const d = parseDef(text, pos); + if (!d) break; + const key = normLabel(d.label); + if (!ctx.defs.has(key)) ctx.defs.set(key, { href: d.href, title: d.title }); + pos = d.end; + } + if (!pos) return para; + return pos >= text.length ? [] : text.slice(pos).split("\n"); +} + +interface Parsed { + blocks: Block[]; + gap: boolean; + /** Index of the first lazy line that was not paragraph continuation text, or -1. */ + reject: number; +} + +interface QuoteLines { + start: number; // source index of lines[0] + end: number; // source index after the last collected line + lines: string[]; + lazy: boolean[]; + fenceClosed: boolean[]; // no fence was open right before this line +} + +/** + * `lazy[i]` marks a line a container took as a lazy paragraph continuation: it must end up as + * paragraph text, so it never becomes a setext underline, a table delimiter row or a sibling list + * item. The first lazy line that cannot be used that way is reported as `reject`, so the caller + * closes the container right before it; the blocks already built are the parse of the lines before. + */ +function parseBlocks(lines: string[], from: number, depth: number, ctx: Ctx, lazy: boolean[] | null): Parsed { const blocks: Block[] = []; const n = lines.length; const canNest = depth < MAX_DEPTH; @@ -408,6 +490,8 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ let blank = false; let para: string[] = []; let paraBlank = false; + let reject = -1; + let hint: QuoteLines | null = null; const add = (b: Block) => { if (blank && blocks.length) gap = true; @@ -420,25 +504,13 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ }; const flush = () => { if (!para.length) return; - let from = 0; - while (from < para.length && ctx.defs.size < MAX_DEFS) { - const d = parseDef(para[from]!); - if (!d) break; - const key = normLabel(d.label); - if (!ctx.defs.has(key)) ctx.defs.set(key, { href: d.href, title: d.title }); - from++; - } - const rest = from ? para.slice(from) : para; + const rest = takeDefs(para, ctx); para = []; - if (!rest.length) { - if (paraBlank && blocks.length) gap = true; - return; - } if (paraBlank && blocks.length) gap = true; - addText({ type: "paragraph", children: [] }, rest.join("\n").trimEnd()); + if (rest.length) addText({ type: "paragraph", children: [] }, rest.join("\n").trimEnd()); }; - let i = 0; + let i = from; while (i < n) { const line = lines[i]!; if (isBlank(line)) { @@ -447,16 +519,23 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ i++; continue; } + const lz = lazy !== null && lazy[i] === true; + if (lz && !para.length) { + reject = i; + break; + } if (para.length) { - const su = setext(line); + const su = lz ? null : setext(line); if (su) { - if (paraBlank && blocks.length) gap = true; - addText({ type: "heading", level: su, children: [] }, para.map((l) => l.trim()).join("\n")); + const rest = takeDefs(para, ctx); para = []; + if (paraBlank && blocks.length) gap = true; + if (rest.length) addText({ type: "heading", level: su, children: [] }, rest.map((l) => l.trim()).join("\n")); + else para.push(line.trimStart()); // only definitions before it: the underline is plain text i++; continue; } - const al = delimRow(line); + const al = lz ? null : delimRow(line); if (al) { const head = splitRow(para[para.length - 1]!); if (head.length === al.length) { @@ -471,7 +550,7 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ }; for (const h of head) cell(h, tbl.head); i++; - while (i < n && !isBlank(lines[i]!) && !startsBlock(lines[i]!, canNest)) { + while (i < n && !isBlank(lines[i]!) && !(lazy !== null && lazy[i] === true) && !startsBlock(lines[i]!, canNest)) { const cells = splitRow(lines[i]!); const row: Inline[][] = []; for (let k = 0; k < al.length; k++) cell(cells[k] ?? "", row); @@ -488,6 +567,10 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ continue; } flush(); + if (lz) { + reject = i; + break; + } } const ind = indent(line); if (ind >= 4) { @@ -533,20 +616,45 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ continue; } if (canNest && quoteAt(line) >= 0) { - const inner: string[] = []; - const t: FenceTrack = { f: null }; - while (i < n) { - const L = lines[i]!; - const q = quoteAt(L); - if (q >= 0) { - const s = L.slice(q); - inner.push(s); - track(t, s); - } else if (!isBlank(L) && inner.length && !t.f && paraOpen(inner[inner.length - 1]!) && !startsBlock(L, true) && !listMarker(L)) inner.push(L); - else break; - i++; + // A rejected collection is reused for the quote that follows: the lines after the rejected + // lazy line are collected identically from any `>` line reached with no fence open (a lazy + // line never changes the fence state), so re-collecting them would be quadratic. + let q: QuoteLines; + let from: number; + if (hint && i > hint.start && i - hint.start < hint.lines.length && hint.fenceClosed[i - hint.start]) { + q = hint; + from = i - hint.start; + i = hint.end; + } else { + q = { start: i, end: i, lines: [], lazy: [], fenceClosed: [] }; + from = 0; + const t: FenceTrack = { f: null }; + while (i < n) { + const L = lines[i]!; + const at = quoteAt(L); + const prev = q.lines[q.lines.length - 1]; + if (at >= 0) { + q.fenceClosed.push(t.f === null); + const s = L.slice(at); + q.lines.push(s); + q.lazy.push(false); + track(t, s); + } else if (!isBlank(L) && prev !== undefined && !t.f && paraOpen(prev) && !startsBlock(L, true)) { + q.fenceClosed.push(true); + q.lines.push(L); + q.lazy.push(true); + } else break; + i++; + } + q.end = i; + } + const r = parseBlocks(q.lines, from, depth + 1, ctx, q.lazy); + hint = null; + if (r.reject >= 0) { + i = q.start + r.reject; // every collected line maps to one source line + hint = q; } - add({ type: "blockquote", children: parseBlocks(inner, depth + 1, ctx).blocks }); + add({ type: "blockquote", children: r.blocks }); continue; } const m = canNest ? listMarker(line) : null; @@ -556,14 +664,22 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ while (i < n) { const cur = listMarker(lines[i]!); if (!cur || cur.key !== m.key || isHr(lines[i]!)) break; + const start = i; const itemLines = [lines[i]!.slice(cur.offset)]; + const itemLazy = [false]; const task = /^\[([ xX])\](?:[ \t]|$)/.exec(itemLines[0]!); if (task) itemLines[0] = itemLines[0]!.slice(3).replace(/^[ \t]/, ""); const t: FenceTrack = { f: null }; track(t, itemLines[0]!); i++; let blanks = 0; - if (!(cur.empty && i < n && isBlank(lines[i]!))) { + if (cur.empty && i < n && isBlank(lines[i]!)) { + // An empty item takes no continuation; the blank run still separates it from the next item. + while (i < n && isBlank(lines[i]!)) { + blanks++; + i++; + } + } else { while (i < n) { const L = lines[i]!; if (isBlank(L)) { @@ -571,20 +687,32 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ i++; continue; } + const lzL = lazy !== null && lazy[i] === true; if (indent(L) >= cur.offset) { - for (; blanks > 0; blanks--) itemLines.push(""); + for (; blanks > 0; blanks--) { + itemLines.push(""); + itemLazy.push(false); + } const s = L.slice(cur.offset); itemLines.push(s); + itemLazy.push(lzL); track(t, s); - } else if (blanks === 0 && !t.f && paraOpen(itemLines[itemLines.length - 1]!) && !startsBlock(L, true) && !listMarker(L)) itemLines.push(L); - else break; + } else if (blanks === 0 && !t.f && paraOpen(itemLines[itemLines.length - 1]!) && !startsBlock(L, true) && listMarker(L)?.key !== cur.key) { + // Lazy continuation: a marker that cannot interrupt a paragraph continues it, unless it is a sibling item. + itemLines.push(L); + itemLazy.push(true); + } else break; i++; } } - const r = parseBlocks(itemLines, depth + 1, ctx); + const r = parseBlocks(itemLines, 0, depth + 1, ctx, itemLazy); if (r.gap) loose = true; items.push(task ? { type: "item", checked: task[1] !== " ", children: r.blocks } : { type: "item", children: r.blocks }); - const nxt = i < n ? listMarker(lines[i]!) : null; + if (r.reject >= 0) { + i = start + r.reject; // every item line maps to one source line + break; + } + const nxt = i < n && !(lazy !== null && lazy[i] === true) ? listMarker(lines[i]!) : null; if (nxt && nxt.key === m.key && !isHr(lines[i]!)) { if (blanks > 0) loose = true; } else { @@ -601,7 +729,7 @@ function parseBlocks(lines: string[], depth: number, ctx: Ctx): { blocks: Block[ i++; } flush(); - return { blocks, gap }; + return { blocks, gap, reject }; } // --------------------------------------------------------------------------------------------- @@ -634,6 +762,7 @@ interface Bracket { const WS = /\s/; const UPUNCT = /[\p{P}\p{S}]/u; +const WORD = /[\p{L}\p{N}_]/u; function toInline(nodes: N[]): Inline[] { const out: Inline[] = []; @@ -815,6 +944,58 @@ function parseInline(s: string, defs: Defs): Inline[] { for (const x of kids) if (x.k === 1 && x.d > d) d = x.d; return d; }; + const autolink = (href: string, label: string) => nodeOf({ type: "link", href, title: null, children: [{ type: "text", value: label }] }, 1); + + // Autolinks are held back inside an open `[` (a link must not contain one). When the bracket is + // dropped as literal text instead, the text nodes from it onward get the autolinks they were denied. + const rescan = (from: N) => { + for (let x: N | null = from, next: N | null; x; x = next) { + next = x.next; + if (x.k !== 0) continue; + const v = x.v; + let parts: N[] | null = null; + let run = 0; + for (let p = 0; p < v.length; ) { + const c = v.charCodeAt(p); + let node: N | null = null; + let end = p + 1; + if (c === 60) { + const a = angleAutolink(v, p); + if (a) { + node = autolink(a.href, a.label); + end = a.end; + } + } else if (c === 104 || c === 72) { + const prev = p > 0 ? v[p - 1] : x.prev && x.prev.k === 0 ? x.prev.v[x.prev.v.length - 1] : undefined; + const u = prev !== undefined && WORD.test(prev) ? null : bareUrl(v, p); + if (u) { + node = autolink(u, u); + end = p + u.length; + } + } + if (node) { + parts ??= []; + if (p > run) parts.push(mk(0, v.slice(run, p))); + parts.push(node); + run = end; + } + p = end; + } + if (!parts) continue; + if (run < v.length) parts.push(mk(0, v.slice(run))); + // Splice the parts in place of x. + let at = x.prev; + for (const y of parts) { + y.prev = at; + if (at) at.next = y; + else head = y; + at = y; + } + at!.next = next; + if (next) next.prev = at; + else tail = at; + } + }; function processEmph(bottom: N | null) { const ob = new Map(); @@ -997,8 +1178,7 @@ function parseInline(s: string, defs: Defs): Inline[] { case 93: { flush(i); const b = brackets[brackets.length - 1]; - if (!b || (!b.img && b.serial < linkSerial)) { - if (b) brackets.pop(); + if (!b) { text("]"); i = run = i + 1; break; @@ -1006,11 +1186,12 @@ function parseInline(s: string, defs: Defs): Inline[] { let href: string | null = null; let title: string | null = null; let end = i + 1; - if (s[i + 1] === "(") { + const active = b.img || b.serial >= linkSerial; + if (active && s[i + 1] === "(") { const t = linkTail(s, i + 2); if (t) ({ href, title, end } = t); } - if (href === null) { + if (active && href === null) { let def: { href: string; title: string | null } | undefined; const own = () => (i - b.src <= MAX_LABEL ? s.slice(b.src, i) : null); if (s[i + 1] === "[") { @@ -1049,6 +1230,7 @@ function parseInline(s: string, defs: Defs): Inline[] { } brackets.pop(); text("]"); + if (!brackets.length) rescan(b.n); i = run = i + 1; break; } @@ -1059,19 +1241,19 @@ function parseInline(s: string, defs: Defs): Inline[] { break; } flush(i); - append(nodeOf({ type: "link", href: a.href, title: null, children: [{ type: "text", value: a.label }] }, 1)); + append(autolink(a.href, a.label)); i = run = a.end; break; } case 104: case 72: { - const u = brackets.length || (i > 0 && /[\p{L}\p{N}_]/u.test(s[i - 1]!)) ? null : bareUrl(s, i); + const u = brackets.length || (i > 0 && WORD.test(s[i - 1]!)) ? null : bareUrl(s, i); if (!u) { i++; break; } flush(i); - append(nodeOf({ type: "link", href: u, title: null, children: [{ type: "text", value: u }] }, 1)); + append(autolink(u, u)); i = run = i + u.length; break; } @@ -1101,6 +1283,7 @@ function parseInline(s: string, defs: Defs): Inline[] { } } flush(len); + if (brackets.length) rescan(brackets[0]!.n); // never closed: everything after it is literal text processEmph(null); return toInline(range(null)); } @@ -1119,7 +1302,7 @@ function tooLong(s: string): boolean { export function parseMarkdown(src: string): Block[] { const lines = src.split(/\r\n|\r|\n/).map(expandTabs); const ctx: Ctx = { defs: new Map(), pending: [] }; - const { blocks } = parseBlocks(lines, 0, ctx); + const { blocks } = parseBlocks(lines, 0, 0, ctx, null); for (const { set, text } of ctx.pending) { set(!text ? [] : tooLong(text) ? [{ type: "text", value: text }] : parseInline(text, ctx.defs)); } diff --git a/src/client/mdview.ts b/src/client/mdview.ts index 37a7166..1ab90c0 100644 --- a/src/client/mdview.ts +++ b/src/client/mdview.ts @@ -54,6 +54,11 @@ export class MarkdownView { e.preventDefault(); this.clearNotice(); this.scrollToFragment(href.slice(1)); + } else if (/^https?:/i.test(href)) { + // Like terminal links: the server hands http(s) URLs to the default browser, so the + // locked-down app window never navigates or opens a second window itself. + e.preventDefault(); + void api.external(href); } } diff --git a/src/client/palette.ts b/src/client/palette.ts index d434d7a..f8696b9 100644 --- a/src/client/palette.ts +++ b/src/client/palette.ts @@ -108,8 +108,11 @@ export class Palette { onGoToLine: (line: number) => void = () => {}; constructor() { - this.input = h("input", { class: "palette-input", spellcheck: "false", "aria-label": "Search files or commands" }); - this.list = h("div", { class: "palette-list", role: "listbox" }); + this.input = h("input", { + class: "palette-input", spellcheck: "false", "aria-label": "Search files or commands", + role: "combobox", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-controls": "pal-list", "aria-autocomplete": "list", + }); + this.list = h("div", { class: "palette-list", role: "listbox", id: "pal-list" }); const box = h("div", { class: "palette" }, h("div", { class: "palette-field" }, h("span", { html: icons.search }), this.input), this.list); this.el = h("div", { class: "overlay", hidden: true }, box); this.el.addEventListener("mousedown", (e) => { @@ -155,6 +158,7 @@ export class Palette { open(prefix = "") { this.prevFocus = document.activeElement; this.el.hidden = false; + this.input.setAttribute("aria-expanded", "true"); this.input.value = prefix; this.input.focus(); this.update(); @@ -163,9 +167,16 @@ export class Palette { close() { this.el.hidden = true; + this.input.setAttribute("aria-expanded", "false"); (this.prevFocus as HTMLElement | null)?.focus?.(); } + /** Tells assistive tech which option is current. */ + private syncActive() { + if (this.items.length) this.input.setAttribute("aria-activedescendant", `pal-${this.sel}`); + else this.input.removeAttribute("aria-activedescendant"); + } + get isOpen() { return !this.el.hidden; } @@ -221,11 +232,15 @@ export class Palette { if (!this.items.length) { const msg = this.stale && this.loading ? "Indexing files…" : q ? "No matching files" : "No files"; this.list.innerHTML = `
${msg}
`; + this.syncActive(); return; } } this.sel = 0; - this.list.innerHTML = this.items.map((it, i) => it.html.replace("
it.html.replace("
el.classList.toggle("sel", i === this.sel)); + this.list.querySelectorAll(".pal-item").forEach((el, i) => { + el.classList.toggle("sel", i === this.sel); + el.setAttribute("aria-selected", String(i === this.sel)); + }); + this.syncActive(); this.list.querySelector(".sel")?.scrollIntoView({ block: "nearest" }); } diff --git a/src/client/search.ts b/src/client/search.ts index 00ecf3e..436cf93 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -68,7 +68,7 @@ export class SearchView { this.input = h("input", { class: "sv-query", spellcheck: "false", placeholder: "Search", "aria-label": "Search in files" }); this.include = h("input", { class: "sv-include", spellcheck: "false", placeholder: "files to include", "aria-label": "Files to include" }); this.exclude = h("input", { class: "sv-exclude", spellcheck: "false", placeholder: "files to exclude", "aria-label": "Files to exclude" }); - this.results = h("div", { class: "sv-results", role: "listbox", tabindex: "0" }); + this.results = h("div", { class: "sv-results", role: "listbox", tabindex: "0", "aria-label": "Search results" }); this.progress = h("div", { class: "sv-progress", hidden: true }); const refresh = h("button", { class: "sv-refresh", type: "button" }, "Refresh"); refresh.addEventListener("click", () => void this.run()); @@ -369,15 +369,17 @@ export class SearchView { if (g.collapsed) continue; const pv = m.preview; const on = i === this.sel; - html += `
${m.line}${safe(pv.slice(0, m.pcol))}${safe(pv.slice(m.pcol, m.pcol + m.len))}${safe(pv.slice(m.pcol + m.len))}
`; + html += `
${m.line}${safe(pv.slice(0, m.pcol))}${safe(pv.slice(m.pcol, m.pcol + m.len))}${safe(pv.slice(m.pcol + m.len))}
`; } }); const n = d ? d.matches : total; const files = d ? d.files : this.groups.length; - let head = `
${plural(n, "result", "results")} in ${plural(files, "file", "files")}
`; + let head = `
${plural(n, "result", "results")} in ${plural(files, "file", "files")}
`; if (d?.truncated === "matches") head += `
Showing first 2,000 results
`; if (d?.truncated === "time") head += `
Search timed out - showing partial results
`; if (d?.error || (!d && !this.running)) head += `
Search ended early - showing partial results
`; this.results.innerHTML = head + skipped + html; + if (this.sel >= 0 && this.visible(this.sel)) this.results.setAttribute("aria-activedescendant", `sv-${this.sel}`); + else this.results.removeAttribute("aria-activedescendant"); } } diff --git a/src/client/styles.css b/src/client/styles.css index 17bfa46..d9b2cb5 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -62,7 +62,7 @@ --fg: #d6dbe5; --fg-strong: #f1f4fa; --fg-muted: #8b94a5; - --fg-faint: #535c6c; + --fg-faint: #626b7c; --accent: #7aa2ff; --accent-2: #b38cff; --accent-fg: #0c0f15; @@ -89,7 +89,7 @@ /* syntax */ --sx-k: #c792ea; --sx-s: #a5d68a; - --sx-c: #5d6879; + --sx-c: #6f7a8c; --sx-n: #f5a96b; --sx-f: #82b8ff; --sx-t: #6fd6c6; @@ -138,7 +138,7 @@ --fg: #262c38; --fg-strong: #0f1420; --fg-muted: #667085; - --fg-faint: #a3abb9; + --fg-faint: #7c8595; --accent: #3b6cf6; --accent-2: #8b5cf6; --accent-fg: #ffffff; @@ -163,7 +163,7 @@ --sx-k: #8432d6; --sx-s: #187a3a; - --sx-c: #98a1b0; + --sx-c: #6b7684; --sx-n: #b35300; --sx-f: #1060d0; --sx-t: #08808c; @@ -195,7 +195,7 @@ --ansi-12: #218bff; --ansi-13: #a475f9; --ansi-14: #3192aa; - --ansi-15: #8c959f; + --ansi-15: #57606a; } /* ───────────────────────── base ───────────────────────── */ @@ -441,9 +441,11 @@ svg { cursor: col-resize; } .splitter:hover, -.splitter.active { +.splitter.active, +.splitter:focus-visible { background: var(--accent); transition: background 0.15s 0.15s; + outline: none; } .main { @@ -773,7 +775,8 @@ svg { height: 12px; } .tab:hover .tab-close, -.tab.active .tab-close { +.tab.active .tab-close, +.tab-close:focus-visible { opacity: 1; } .tab-close:hover { @@ -1751,8 +1754,13 @@ mark.cur { gap: 6px; padding: 2px 7px; border-radius: 4px; + min-width: 0; + overflow: hidden; +} +.st-text { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .st-root { color: var(--fg); @@ -1807,6 +1815,12 @@ button.st-item:hover { background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); } +.offline .st-root::after { + content: "disconnected \00b7 reconnecting\2026"; + color: var(--danger); + margin-left: 6px; + white-space: nowrap; +} .toast { position: fixed; @@ -2019,6 +2033,35 @@ li[data-state="done"] .upd-mark { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; } + /* Shown statically for their usual time instead of faded (their script hides them). */ + .toast.show { + animation: none; + opacity: 1; + transform: translate(-50%, 0); + } + .ln.flash, + .dr.flash { + animation: none; + background: var(--flash); + } + .sv-progress { + animation: none; + background: var(--accent); + background-size: 100% 100%; + } +} + +/* Screen-reader-only live region (toasts, status changes). */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; } /* ───────────────────────── context menu ───────────────────────── */ @@ -2080,6 +2123,8 @@ li[data-state="done"] .upd-mark { .sv-count { margin-left: auto; background: var(--bg-active); border-radius: 8px; padding: 0 6px; color: var(--fg-muted); font-size: 11px; } .sv-row { display: flex; gap: 8px; padding: 1px 10px 1px 22px; cursor: pointer; font-family: var(--font-mono); white-space: nowrap; } .sv-row:hover, .sv-row.sel { background: var(--bg-hover); } +.sv-results:focus-visible { outline: none; } +.sv-results:focus-visible .sv-row.sel { box-shadow: inset 2px 0 0 var(--accent); } .sv-ln { color: var(--fg-faint); min-width: 3ch; text-align: right; } .sv-pv { overflow: hidden; text-overflow: ellipsis; } .sv-pv mark { background: var(--mark); color: inherit; border-radius: 2px; } @@ -2191,3 +2236,51 @@ li[data-state="done"] .upd-mark { .dr-gap:hover .dt { color: var(--accent); } .dr.cur { box-shadow: inset 3px 0 0 var(--accent); } .dr.flash { animation: flash 1.5s var(--ease) both; } + +/* Keyboard focus on the code and diff scrollers (their outline is otherwise suppressed). */ +.code-scroll:focus-visible, +.diff-scroll:focus-visible { + box-shadow: inset 0 0 0 1px var(--accent); +} + +/* ───────────────────────── UI scale, tab hints, explorer notes, help ───────────────────────── */ +/* ⌘= / ⌘- scale the chrome with the code; positioned popups (menus, toast, peek) stay in viewport px. */ +.side-head, .tree-scroll, .search-view, .changes-view, .tabbar, .crumbs, .statusbar, .panel-head, .palette, .upd { + zoom: var(--ui-scale, 1); +} +.tab-dir { + margin-left: 4px; + color: var(--fg-faint); + font-size: 11px; +} +.row.note { + color: var(--fg-faint); + font-style: italic; + cursor: default; +} +.row.note.error { + color: var(--danger); +} +.help { + max-height: calc(100vh - 32px); + overflow: auto; +} +.help h3 { + margin: 16px 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); +} +.help .kb { + gap: 5px; + min-width: 0; +} +.help .kb-row { + gap: 16px; + font-size: 12.5px; +} +.help .welcome-hint { + margin-top: 16px; +} diff --git a/src/client/term/renderer.ts b/src/client/term/renderer.ts index 228d3e0..9fcfacd 100644 --- a/src/client/term/renderer.ts +++ b/src/client/term/renderer.ts @@ -69,6 +69,8 @@ export class Renderer { constructor(private term: Terminal) { this.canvas = document.createElement("canvas"); this.canvas.className = "term-canvas"; + // Output reaches assistive technology through the view's live log, not the pixels. + this.canvas.setAttribute("aria-hidden", "true"); this.ctx = this.canvas.getContext("2d", { alpha: false })!; } diff --git a/src/client/term/terminal.ts b/src/client/term/terminal.ts index cfab199..9d5ad42 100644 --- a/src/client/term/terminal.ts +++ b/src/client/term/terminal.ts @@ -26,6 +26,8 @@ export class Line { wrapped = false; /** High-water mark: cells at or beyond `hw` are guaranteed blank (default attributes). */ hw = 0; + /** True once the line was reported through `onLine` (cleared by any later print). */ + spoken = false; constructor(cols: number, bg = DEFAULT) { this.text = new Array(cols).fill(" "); @@ -104,6 +106,7 @@ export class Line { this.bg.fill(bg, 0, n); this.fl.fill(0, 0, n); this.wrapped = false; + this.spoken = false; this.hw = bg === DEFAULT ? 0 : this.text.length; } @@ -167,6 +170,11 @@ export interface TerminalHost { cwd?(path: string): void; /** Colors for OSC 10/11/4 queries, as #rrggbb. */ colors?(): { fg: string; bg: string; cursor: string; palette: string[] }; + /** + * A line of output the program finished (line feed past it, or it scrolled into history), + * trimmed; for assistive technology. Not called in the alternate screen or while `silent`. + */ + onLine?(text: string): void; } const enum S { @@ -212,6 +220,9 @@ export class Terminal { /** Bumped when scrollback is trimmed, so absolute selections can be invalidated. */ trimmed = 0; lastChar = ""; + /** Where lastChar was printed; a ZWJ join only applies when the cursor is still there. */ + private lastX = -1; + private lastY = -1; private state = S.Ground; private params: number[] = []; @@ -340,6 +351,7 @@ export class Terminal { return; } if (c === 0x9c) return this.oscEnd(); + if (c < 0x20) return; // other C0 controls are ignored inside a string (VT500) if (this.osc.length < 1 << 20) this.osc += s[i]; return; case S.OscEsc: @@ -491,8 +503,9 @@ export class Terminal { } return; } - // Emoji ZWJ sequences: the glyph after a ZWJ joins the previous cluster. - if (this.lastChar.endsWith("‍")) { + // Emoji ZWJ sequences: the glyph after a ZWJ joins the previous cluster (unless the + // cursor was moved in between: then it is an ordinary print at the new position). + if (this.lastChar.endsWith("‍") && cur.x === this.lastX && cur.y === this.lastY) { const line = this.lines[cur.y]!; let x = cur.wrapPending ? cur.x : cur.x - 1; if (x > 0 && line.text[x] === "") x--; @@ -532,12 +545,15 @@ export class Terminal { line.fl[cur.x + 1] = cur.fl; } this.dirty[cur.y] = 1; + line.spoken = false; this.lastChar = ch; cur.x += w; if (cur.x >= this.cols) { cur.x = this.cols - 1; cur.wrapPending = this.modes.autowrap; } + this.lastX = cur.x; + this.lastY = cur.y; } /** Writing over half of a wide character must erase the other half. */ @@ -598,6 +614,7 @@ export class Terminal { /** IND: move down, scrolling the region when at its bottom margin. */ index() { + if (this.host.onLine) this.announce(this.lines[this.cur.y]!); if (this.cur.y === this.bottom) this.scrollUp(1); else if (this.cur.y < this.rows - 1) this.cur.y++; } @@ -624,6 +641,7 @@ export class Terminal { // and only the cells it actually used need clearing. History keeps a compact copy. const used = old.usedWidth(); if (toScrollback) { + if (this.host.onLine) this.announce(old); this.scrollback.push(old.compactCopy(used)); this.scrolledOff++; } @@ -637,6 +655,15 @@ export class Terminal { this.markRegion(); } + /** Reports a completed, non-empty line to the host once (screen reader log). */ + private announce(line: Line) { + if (line.spoken || this.alt || line.hw === 0) return; + line.spoken = true; // replayed history is consumed silently, and never reported later + if (this.silent) return; + const text = line.toString(); + if (text) this.host.onLine!(text); + } + scrollDown(n: number) { const { top, bottom } = this; n = Math.min(n, bottom - top + 1); @@ -651,8 +678,11 @@ export class Terminal { const line = this.lines[this.cur.y]!; const x = this.cur.x; n = Math.min(n, this.cols - x); + this.splitWide(line, x); for (let i = this.cols - 1; i >= x + n; i--) line.copyCell(i, line, i - n); line.clear(x, x + n, this.cur.bg); + // A wide character pushed onto the last column lost its second half. + if (line.fl[this.cols - 1]! & WIDE) line.clear(this.cols - 1, this.cols, this.cur.bg); this.markDirty(this.cur.y); } @@ -660,11 +690,17 @@ export class Terminal { const line = this.lines[this.cur.y]!; const x = this.cur.x; n = Math.min(n, this.cols - x); + this.splitWide(line, x); for (let i = x; i < this.cols - n; i++) line.copyCell(i, line, i + n); line.clear(this.cols - n, this.cols, this.cur.bg); this.markDirty(this.cur.y); } + /** Shifting cells from the second half of a wide character blanks the character (as xterm does). */ + private splitWide(line: Line, x: number) { + if (x > 0 && line.text[x] === "" && line.fl[x - 1]! & WIDE) line.clear(x - 1, x, this.cur.bg); + } + private insertLines(n: number) { const y = this.cur.y; if (y < this.top || y > this.bottom) return; @@ -1084,6 +1120,9 @@ export class Terminal { this.cur.bg = DEFAULT; this.cur.fl = 0; this.cur.originMode = false; + this.cur.charsets = [false, false]; + this.cur.gl = 0; + this.saved = null; this.top = 0; this.bottom = this.rows - 1; return; diff --git a/src/client/term/view.ts b/src/client/term/view.ts index 4d8a2f8..8f4abda 100644 --- a/src/client/term/view.ts +++ b/src/client/term/view.ts @@ -28,6 +28,9 @@ export interface TerminalViewOptions { // The last path segment may contain dots (foo.test.ts, index.d.ts, jquery.min.js). const LINK_RE = /(https?:\/\/[^\s"'<>`]+[^\s"'<>`.,;:!?)\]}])|((?:~|\.{1,2})?\/?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.\w+(?::\d+(?::\d+)?)?)/g; +/** Completed lines kept in the screen-reader log. */ +const LOG_LIMIT = 200; + const KEYS: Record = { // key: [normal, application-cursor-mode] ArrowUp: ["\x1b[A", "\x1bOA"], @@ -54,6 +57,26 @@ const KEYS: Record = { F12: ["\x1b[24~"], }; +/** + * Tooltip for a hyperlink whose label may lie about its destination: the URI, prefixed with + * "Link target:" when the visible text itself looks like a URL or host of a different site. + */ +export function linkTitle(text: string, uri: string): string { + const label = text.trim(); + const looksLikeUrl = label.includes("://") || /^www\./i.test(label); + if (!looksLikeUrl) return uri; + const host = (s: string) => { + try { + return new URL(s.includes("://") ? s : `https://${s}`).host.toLowerCase(); + } catch { + return null; + } + }; + const a = host(label); + const b = host(uri); + return a !== null && a === b ? uri : `Link target: ${uri}`; +} + /** * A complete terminal widget: emulator + canvas renderer + keyboard/mouse/clipboard. */ @@ -62,6 +85,10 @@ export class TerminalView { readonly term: Terminal; readonly renderer: Renderer; private input: HTMLTextAreaElement; + /** Visually hidden live region: completed output lines, for screen readers. */ + private log: HTMLElement; + private pendingLines: string[] = []; + private logFrame = 0; private scrollbar: HTMLElement; private thumb: HTMLElement; private ro: ResizeObserver; @@ -90,6 +117,10 @@ export class TerminalView { void this.el.offsetWidth; this.el.classList.add("bell"); }, + onLine: (text) => { + this.pendingLines.push(text); + if (!this.logFrame) this.logFrame = requestAnimationFrame(() => this.flushLog()); + }, }); this.renderer = new Renderer(this.term); this.renderer.setTheme(opts.theme); @@ -103,13 +134,20 @@ export class TerminalView { this.input.setAttribute("aria-label", "Terminal input"); this.input.tabIndex = 0; + this.log = document.createElement("div"); + this.log.className = "sr-only"; + this.log.setAttribute("role", "log"); + this.log.setAttribute("aria-live", "polite"); + this.log.setAttribute("aria-atomic", "false"); + this.log.setAttribute("aria-label", "Terminal output"); + this.scrollbar = document.createElement("div"); this.scrollbar.className = "term-scrollbar"; this.thumb = document.createElement("div"); this.thumb.className = "term-thumb"; this.scrollbar.append(this.thumb); - this.el.append(this.renderer.canvas, this.input, this.scrollbar); + this.el.append(this.renderer.canvas, this.input, this.log, this.scrollbar); this.bindKeyboard(); this.bindMouse(); this.ro = new ResizeObserver(() => this.fit()); @@ -117,14 +155,20 @@ export class TerminalView { this.renderer.setFont(opts.fontFamily, opts.fontSize, opts.lineHeight); // Browser zoom / moving between displays changes devicePixelRatio: re-measure the font. let dpr = window.devicePixelRatio; - window.addEventListener("resize", () => { + this.onWindowResize = () => { if (window.devicePixelRatio !== dpr) { dpr = window.devicePixelRatio; this.refreshFont(); } - }); + }; + window.addEventListener("resize", this.onWindowResize); } + private onWindowResize: () => void = () => {}; + private onWindowKeyUp = (e: KeyboardEvent) => { + if (e.code === "AltLeft") this.leftAltDown = false; + }; + write(data: Uint8Array | string, silent = false) { this.term.silent = silent; this.term.write(data); @@ -132,6 +176,21 @@ export class TerminalView { this.renderer.schedule(); } + /** Appends the lines completed since the last frame to the live log (at most 200 kept). */ + private flushLog() { + this.logFrame = 0; + const lines = this.pendingLines; + if (!lines.length) return; + this.pendingLines = []; + const start = Math.max(0, lines.length - LOG_LIMIT); + for (let i = start; i < lines.length; i++) { + const d = document.createElement("div"); + d.textContent = lines[i]!; + this.log.append(d); + } + while (this.log.childElementCount > LOG_LIMIT) this.log.firstElementChild!.remove(); + } + focus() { this.input.focus({ preventScroll: true }); } @@ -171,6 +230,8 @@ export class TerminalView { reset() { this.term.reset(); + this.pendingLines = []; + this.log.replaceChildren(); this.renderer.viewOffset = 0; this.renderer.selection = null; this.renderer.invalidate(); @@ -180,6 +241,9 @@ export class TerminalView { this.disposed = true; this.ro.disconnect(); clearInterval(this.blinkTimer); + cancelAnimationFrame(this.logFrame); + window.removeEventListener("resize", this.onWindowResize); + window.removeEventListener("keyup", this.onWindowKeyUp); this.el.remove(); } @@ -228,9 +292,7 @@ export class TerminalView { const text = e.clipboardData?.getData("text/plain"); if (text) this.paste(text); }); - window.addEventListener("keyup", (e) => { - if (e.code === "AltLeft") this.leftAltDown = false; - }); + window.addEventListener("keyup", this.onWindowKeyUp); inp.addEventListener("keydown", (e) => this.onKey(e)); } @@ -257,7 +319,9 @@ export class TerminalView { void navigator.clipboard.writeText(sel); return; } - if (IS_MAC) return; + // No selection: the copy chord is a no-op (Ctrl+Shift+C must never reach the PTY as ^C). + e.preventDefault(); + return; } if (k === "v") return; // native paste event follows if (k === "k") { @@ -581,7 +645,7 @@ export class TerminalView { // ───────────────────────────── links ───────────────────────────── - private linkAt(x: number, y: number): { start: number; end: number; target: string; uri?: string } | null { + private linkAt(x: number, y: number): { start: number; end: number; target: string; uri?: string; text?: string } | null { const line = this.term.viewLine(y, this.renderer.viewOffset); if (!line) return null; const id = line.fl[x]! >>> 16; @@ -590,7 +654,8 @@ export class TerminalView { let end = x + 1; while (start > 0 && line.fl[start - 1]! >>> 16 === id) start--; while (end < line.cols && line.fl[end]! >>> 16 === id) end++; - return { start, end, target: this.term.links[id] ?? "", uri: this.term.links[id] }; + const uri = this.term.links[id] ?? ""; + return { start, end, target: uri, uri, text: line.text.slice(start, end).join("") }; } // Map string offsets to columns (wide chars occupy two cells, one string slot). let text = ""; @@ -619,6 +684,10 @@ export class TerminalView { if (prev?.row === next?.row && prev?.start === next?.start && prev?.end === next?.end) return; r.hoverLink = next; this.el.style.cursor = next ? "pointer" : ""; + // OSC 8 text and target can differ ("README.md" → https://evil.example): show the real target. + const title = link?.uri ? linkTitle(link.text ?? "", link.uri) : ""; + if (title) r.canvas.title = title; + else r.canvas.removeAttribute("title"); r.touchAll(); } diff --git a/src/client/terminals.ts b/src/client/terminals.ts index 638c36f..319fd88 100644 --- a/src/client/terminals.ts +++ b/src/client/terminals.ts @@ -88,6 +88,11 @@ export class TerminalPanel { return this.sessions.length; } + /** A live session whose title a program changed (agents and TUIs set it): closing would interrupt work. */ + get busy(): boolean { + return this.sessions.some((s) => !s.exited && s.title !== this.shellName); + } + create(id = newId(), activate = true): Session { const s: Session = { id, @@ -164,7 +169,11 @@ export class TerminalPanel { ws.onclose = (ev) => { if (s.ws !== ws) return; s.ws = null; - if (!s.exited && ev.code !== 4000) { + if (s.exited) return; + if (ev.code === 4000) { + // Another window attached to this session; the next keystroke here takes it back. + s.view.write(`\r\n\x1b[2m[session attached in another window: press a key to reattach]\x1b[0m\r\n`); + } else { s.view.write(`\r\n\x1b[2m[connection to codeviewer lost]\x1b[0m\r\n`); s.exited = true; this.renderTabs(); @@ -185,7 +194,22 @@ export class TerminalPanel { } const bytes = enc.encode(data); if (s.ws?.readyState === WebSocket.OPEN) s.ws.send(bytes); - else s.pending.push(bytes); + else { + s.pending.push(bytes); + if (!s.ws) this.connect(s); // detached (replaced by another window): reattach on input + } + } + + /** True when the active session's shell has exited (closing it kills nothing). */ + get activeExited(): boolean { + return !this.active || this.active.exited; + } + + /** Activates the next/previous terminal tab. */ + cycle(dir: 1 | -1) { + if (!this.active || this.sessions.length < 2) return; + const i = this.sessions.indexOf(this.active); + this.activate(this.sessions[(i + dir + this.sessions.length) % this.sessions.length]!); } private restart(s: Session) { diff --git a/src/client/tree.ts b/src/client/tree.ts index f6736d4..aee5bf8 100644 --- a/src/client/tree.ts +++ b/src/client/tree.ts @@ -13,6 +13,12 @@ interface TNode { expanded: boolean; children: TNode[] | null; loading?: Promise; + /** A change arrived while a listing was in flight: list again when it lands. */ + dirty?: boolean; + /** Why the last listing failed (shown as a row under the folder). */ + error?: string; + /** A synthetic row: "Loading…" or an error message, never a real entry. */ + note?: "loading" | "error"; } const ROW = 24; @@ -38,9 +44,10 @@ export class FileTree { constructor(rootName: string) { this.root = { name: rootName, path: "", type: "dir", depth: -1, expanded: true, children: null }; this.byPath.set("", this.root); - this.rows = h("div", { class: "tree-rows", role: "tree" }); + this.rows = h("div", { class: "tree-rows", role: "presentation" }); this.sizer = h("div", { class: "tree-sizer" }, this.rows); - this.scroller = h("div", { class: "tree-scroll", tabindex: "0" }, this.sizer); + // The focused scroller is the tree: aria-activedescendant names the selected row. + this.scroller = h("div", { class: "tree-scroll", tabindex: "0", role: "tree", "aria-label": "Explorer" }, this.sizer); this.el = this.scroller; this.scroller.addEventListener("scroll", () => this.onScroll(), { passive: true }); new ResizeObserver(() => this.schedule()).observe(this.scroller); @@ -56,29 +63,40 @@ export class FileTree { } private async load(node: TNode) { - if (node.loading) return node.loading; + if (node.loading) { + node.dirty = true; + return node.loading; + } node.loading = (async () => { - try { - const entries = await api.list(node.path); - const old = new Map((node.children ?? []).map((c) => [c.name, c])); - node.children = entries.map((e) => { - const prev = old.get(e.name); - if (prev && prev.type === e.type) return prev; - const path = node.path ? `${node.path}/${e.name}` : e.name; - const child: TNode = { name: e.name, path, type: e.type, depth: node.depth + 1, link: e.link, expanded: false, children: null }; - this.byPath.set(path, child); - return child; - }); - for (const [name, c] of old) if (!node.children.some((n) => n.name === name)) this.forget(c); - } catch { - node.children = node.children ?? []; - } finally { - node.loading = undefined; - } + do { + node.dirty = false; + await this.list(node); + } while (node.dirty); + node.loading = undefined; })(); return node.loading; } + private async list(node: TNode) { + try { + const entries = await api.list(node.path); + const old = new Map((node.children ?? []).map((c) => [c.name, c])); + node.children = entries.map((e) => { + const prev = old.get(e.name); + if (prev && prev.type === e.type) return prev; + const path = node.path ? `${node.path}/${e.name}` : e.name; + const child: TNode = { name: e.name, path, type: e.type, depth: node.depth + 1, link: e.link, expanded: false, children: null }; + this.byPath.set(path, child); + return child; + }); + for (const [name, c] of old) if (!node.children.some((n) => n.name === name)) this.forget(c); + node.error = undefined; + } catch (e) { + node.children = node.children ?? []; + node.error = (e as Error).message || "Can't read folder"; + } + } + private forget(n: TNode) { this.byPath.delete(n.path); n.children?.forEach((c) => this.forget(c)); @@ -86,10 +104,14 @@ export class FileTree { private reflow() { const out: TNode[] = []; + const note = (c: TNode, kind: "loading" | "error", text: string): TNode => ({ name: text, path: `${c.path}\0${kind}`, type: "file", depth: c.depth + 1, expanded: false, children: null, note: kind }); const walk = (n: TNode) => { for (const c of n.children ?? []) { out.push(c); - if (c.expanded && c.children) walk(c); + if (c.expanded && c.children) { + walk(c); + if (c.error) out.push(note(c, "error", `Can't read folder: ${c.error}`)); + } else if (c.expanded && c.loading) out.push(note(c, "loading", "Loading…")); } }; walk(this.root); @@ -130,11 +152,11 @@ export class FileTree { const n = this.flat[i]!; const isDir = n.type === "dir"; const cls = ["row", isDir ? "dir" : "file"]; + if (n.note) cls.push("note", n.note); if (n.path === this.selected) cls.push("selected"); if (n.path === this.active) cls.push("active"); if (n.type === "link-broken") cls.push("broken"); if (n.name.startsWith(".")) cls.push("hidden"); - const changed = isDir ? this.changedDirs.has(n.path) : this.changed.has(n.path); let gitBadge = ""; let gitTitle = ""; if (this.git?.repo) { @@ -142,7 +164,7 @@ export class FileTree { const l = this.git.dir(n.path); if (l) { cls.push(`git-${l}`); - if (l !== "I") gitBadge = ``; + if (l !== "I") gitBadge = ``; } } else { const g = this.git.file(n.path); @@ -153,12 +175,14 @@ export class FileTree { } } } + // A folder git already flags gets one marker, not a second look-alike dot for disk activity. + const changed = isDir ? this.changedDirs.has(n.path) && !gitBadge : this.changed.has(n.path); const indent = n.depth * 12 + 8; html += - `
` + - (isDir ? `${chevron}${folderIcon(n.expanded)}` : `${fileIcon(n.name)}`) + + (n.note ? `` : isDir ? `${chevron}${folderIcon(n.expanded)}` : `${fileIcon(n.name)}`) + `${esc(n.name)}` + (n.link ? `↗` : "") + (changed ? `` : "") + @@ -166,6 +190,9 @@ export class FileTree { `
`; } this.rows.innerHTML = html; + const si = this.selected ? this.flat.findIndex((n) => n.path === this.selected) : -1; + if (si >= first && si < last) this.scroller.setAttribute("aria-activedescendant", `tr-${si}`); + else this.scroller.removeAttribute("aria-activedescendant"); } private onClick(e: MouseEvent, dbl: boolean) { @@ -173,6 +200,7 @@ export class FileTree { const row = (e.target as HTMLElement).closest(".row"); if (!row) return; const n = this.flat[Number(row.dataset.i)]; + if (n?.note) return; if (!n) return; this.selected = n.path; if (n.type === "dir") { @@ -189,7 +217,7 @@ export class FileTree { const row = (e.target as HTMLElement).closest(".row"); const n = row && this.flat[Number(row.dataset.i)]; const collapse = { label: "Collapse All", run: () => this.collapseAll() }; - if (!n) return showContextMenu(e, [collapse]); + if (!n || n.note) return showContextMenu(e, [collapse]); this.selected = n.path; this.schedule(); if (n.type === "dir") { @@ -251,9 +279,19 @@ export class FileTree { break; case "Enter": case " ": + if (n?.note) break; if (n?.type === "dir") void this.toggle(n); else if (n) this.onOpen(n.path, e.key === "Enter"); break; + case "ContextMenu": + case "F10": { + if (e.key === "F10" && !e.shiftKey) return; + // The row's own context menu, anchored under the selected row. + const row = idx >= 0 ? this.scroller.querySelector(`.row[data-i="${idx}"]`) : null; + const r = (row ?? this.scroller).getBoundingClientRect(); + (row ?? this.scroller).dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: r.left + 24, clientY: row ? r.bottom : r.top + 8 })); + break; + } default: return; } diff --git a/src/client/updates.ts b/src/client/updates.ts index 653352f..e447547 100644 --- a/src/client/updates.ts +++ b/src/client/updates.ts @@ -27,7 +27,7 @@ export class Updates { onInfo: (info: UpdateInfo) => void = () => {}; constructor() { - this.box = h("div", { class: "upd", role: "dialog", "aria-modal": "true", "aria-label": "Software update" }); + this.box = h("div", { class: "upd", role: "dialog", "aria-modal": "true", "aria-label": "Software update", tabindex: "-1" }); this.el = h("div", { class: "overlay upd-overlay", hidden: true }, this.box); this.el.addEventListener("mousedown", (e) => { if (e.target === this.el && !this.installing) this.close(); @@ -61,13 +61,19 @@ export class Updates { } } + private returnTo: Element | null = null; + open() { + this.returnTo = document.activeElement; this.el.hidden = false; if (this.info) this.render(); + (this.box.querySelector(".btn") ?? this.box).focus(); } close() { this.el.hidden = true; + (this.returnTo as HTMLElement | null)?.focus?.(); + this.returnTo = null; } private header(title: string, sub: string) { diff --git a/src/client/util.ts b/src/client/util.ts index 015d950..f3c0d74 100644 --- a/src/client/util.ts +++ b/src/client/util.ts @@ -1,5 +1,7 @@ export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); export const MOD = IS_MAC ? "⌘" : "Ctrl+"; +/** A shortcut label in the platform's own words (macOS glyphs vs. "Ctrl+Shift+…"). */ +export const key = (mac: string, other: string) => (IS_MAC ? mac : other); type Child = Node | string | null | undefined | false; @@ -106,6 +108,16 @@ export function debounce(fn: (...a: A) => void, ms: number) /** Drag a splitter: calls onMove with the pointer delta from the drag start. */ export function dragSplitter(handle: HTMLElement, axis: "x" | "y", onStart: () => number, onMove: (start: number, delta: number) => void) { + // Keyboard: the splitter is a focusable separator; arrows move it 16px at a time. + handle.tabIndex = 0; + handle.setAttribute("aria-orientation", axis === "x" ? "vertical" : "horizontal"); + handle.addEventListener("keydown", (e) => { + const neg = axis === "x" ? "ArrowLeft" : "ArrowUp"; + const pos = axis === "x" ? "ArrowRight" : "ArrowDown"; + if (e.key !== neg && e.key !== pos) return; + e.preventDefault(); + onMove(onStart(), e.key === pos ? 16 : -16); + }); handle.addEventListener("pointerdown", (e) => { e.preventDefault(); handle.setPointerCapture(e.pointerId); diff --git a/src/server/git.ts b/src/server/git.ts index 6eb201a..5e22779 100644 --- a/src/server/git.ts +++ b/src/server/git.ts @@ -11,8 +11,10 @@ import { looksBinary, openRegular } from "./files.ts"; * programs (filters, textconv, diff drivers, gpg, fsmonitor). Hardening, layered: * - GIT_ATTR_SOURCE = the empty tree: in-tree .gitattributes are ignored, so no path is * bound to a filter/diff driver. - * - Every filter.* / diff.* driver defined in any config is blanked with -c overrides - * (covers $GIT_DIR/info/attributes and global attributes too). + * - Every filter.* / diff.* / merge.* driver defined in any config is blanked through + * GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n (covers $GIT_DIR/info/attributes and global + * attributes too). Not `-c key=value`: git splits that at the first "=", so a driver + * named "a=b" would survive as `filter.a = "b.clean="` with the real driver still armed. * - fsmonitor off, signature verification off, no textconv, submodules ignored (their * configs are untrusted too), no external diff, no pager, no prompts. * - GIT_OPTIONAL_LOCKS=0: never writes the index or takes locks. @@ -32,6 +34,8 @@ export interface GitStatus { stash: number; /** root-relative path → porcelain XY (e.g. ".M", "A.", "??", "!!", "UU"). Dirs end with "/". */ files: Record; + /** The viewed folder is itself git-ignored (git lists only the folder, so `files` has no "!!" entries). */ + rootIgnored: boolean; head: { hash: string; subject: string; author: string; time: number } | null; } @@ -157,31 +161,39 @@ export class Git { return new Git(bin, root, prefix, [...new Set([gitDir, commonDir])], env, emptyTree); } - private overrides: Promise | null = null; + private overrides: Promise> | null = null; - /** -c overrides that blank every filter/diff driver any config defines (cached until config changes). */ - private neutralize(): Promise { + /** The run environment, with every filter/diff/merge driver any config defines blanked (cached until config changes). */ + private neutralize(): Promise> { return (this.overrides ??= this.computeOverrides()); } - private async computeOverrides(): Promise { + private async computeOverrides(): Promise> { const r = await run(this.bin, this.root, [...HARDEN, "config", "--name-only", "--get-regexp", "^(filter|diff|merge)\\."], 1 << 20, this.env); - const out: string[] = []; + const pairs: [string, string][] = []; const drivers = new Set(); for (const key of new TextDecoder().decode(r.out).split("\n")) { const m = /^(filter|diff|merge)\.(.+)\.[^.]+$/.exec(key.trim()); if (m) drivers.add(`${m[1]}.${m[2]}`); } for (const d of drivers) { - if (d.startsWith("filter.")) out.push("-c", `${d}.clean=`, "-c", `${d}.smudge=`, "-c", `${d}.process=`, "-c", `${d}.required=false`); - else if (d.startsWith("diff.")) out.push("-c", `${d}.textconv=`, "-c", `${d}.command=`); - else out.push("-c", `${d}.driver=`); + if (d.startsWith("filter.")) pairs.push([`${d}.clean`, ""], [`${d}.smudge`, ""], [`${d}.process`, ""], [`${d}.required`, "false"]); + else if (d.startsWith("diff.")) pairs.push([`${d}.textconv`, ""], [`${d}.command`, ""]); + else pairs.push([`${d}.driver`, ""]); } - return out; + // Command-line-level config (same precedence as -c), but key and value travel separately. + // Appended after any entries the user's own environment carries. + const from = Math.max(0, Number(this.env.GIT_CONFIG_COUNT) || 0); + const env: Record = { ...this.env, GIT_CONFIG_COUNT: String(from + pairs.length) }; + pairs.forEach(([k, v], i) => { + env[`GIT_CONFIG_KEY_${from + i}`] = k; + env[`GIT_CONFIG_VALUE_${from + i}`] = v; + }); + return env; } private async git(args: string[], maxBytes = MAX_OUT) { - return run(this.bin, this.root, [...HARDEN, ...(await this.neutralize()), ...args], maxBytes, this.env); + return run(this.bin, this.root, [...HARDEN, ...args], maxBytes, await this.neutralize()); } /** @@ -214,7 +226,7 @@ export class Git { ]); const s: GitStatus = { repo: true, branch: null, detached: false, oid: null, upstream: null, ahead: 0, behind: 0, - state: this.opState(), stash: 0, files: {}, head: null, + state: this.opState(), stash: 0, files: {}, rootIgnored: false, head: null, }; if (stash.code === 0) s.stash = Number(new TextDecoder().decode(stash.out).trim()) || 0; if (head.code === 0) { @@ -225,6 +237,11 @@ export class Git { const recs = new TextDecoder().decode(st.out).split("\0"); const add = (p: string, xy: string) => { + // The viewed subfolder itself: status collapses an ignored folder into one "!! sub/" record. + if (this.prefix && (p === this.prefix || p + "/" === this.prefix)) { + if (xy === "!!") s.rootIgnored = true; + return; + } if (!p.startsWith(this.prefix)) return; const rel = p.slice(this.prefix.length); if (rel) s.files[rel] = xy; @@ -262,7 +279,9 @@ export class Git { } private opState(): string | null { - const has = (f: string) => this.gitDirs.some((d) => existsSync(path.join(d, f))); + // Operation markers are per worktree: only this worktree's git dir, never the common dir + // (a merge in the main checkout must not show as "merging" in a linked worktree). + const has = (f: string) => existsSync(path.join(this.gitDirs[0]!, f)); if (has("rebase-merge") || has("rebase-apply")) return "rebasing"; if (has("MERGE_HEAD")) return "merging"; if (has("CHERRY_PICK_HEAD")) return "cherry-picking"; @@ -292,7 +311,9 @@ export class Git { /** * The branch this one is reviewed against: the remote's default branch (origin/HEAD), else * the first of main/master/develop/trunk that exists and is not the current branch, else the - * upstream. `same` means the merge-base is HEAD itself (nothing committed on top of the base). + * upstream. On one of those branches itself the upstream comes first: a stale local `master` + * must not win over `origin/main`. `same` means the merge-base is HEAD itself (nothing + * committed on top of the base). */ async detectBase(headOid: string): Promise { const dec = (r: { code: number; out: Uint8Array }) => (r.code === 0 ? new TextDecoder().decode(r.out).trim() : ""); @@ -303,8 +324,10 @@ export class Git { ]); const refs: string[] = []; if (originHead.startsWith("refs/remotes/")) refs.push(originHead); + const up = upstream.startsWith("refs/remotes/") ? upstream : ""; + if (up && BASE_NAMES.includes(branch)) refs.push(up); for (const n of BASE_NAMES) if (n !== branch) refs.push(`refs/heads/${n}`); - if (upstream.startsWith("refs/remotes/") && !refs.includes(upstream)) refs.push(upstream); + if (up && !refs.includes(up)) refs.push(up); for (const ref of refs) { const oid = await this.rev(`${ref}^{commit}`); if (!oid) continue; @@ -437,10 +460,11 @@ export class Git { private async untracked(): Promise<{ files: ChangeEntry[]; truncated: boolean }> { const r = await this.git(["ls-files", "-z", "--others", "--exclude-standard", "--", "."]); if (r.code !== 0) return { files: [], truncated: false }; + // A nested repository is listed as "dir/": not a file, nothing to count or diff. const all = new TextDecoder() .decode(r.out) .split("\0") - .filter((f) => f && !f.split("/").some((seg) => HEAVY_DIRS.has(seg))); + .filter((f) => f && !f.endsWith("/") && !f.split("/").some((seg) => HEAVY_DIRS.has(seg))); const paths = all.slice(0, MAX_CHANGES); const files: ChangeEntry[] = paths.map((p) => ({ path: p, status: "?", added: null, removed: null, binary: false })); // A few files at a time through one 64 KiB buffer each: never 200 MiB of buffers per request. diff --git a/src/server/http.ts b/src/server/http.ts index 5b764c6..17c19cb 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -1,7 +1,7 @@ import type { Server, ServerWebSocket } from "bun"; -import { timingSafeEqual } from "node:crypto"; +import { randomBytes, timingSafeEqual } from "node:crypto"; import path from "node:path"; -import { stat } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import { listDir, readForView, resolveSafe, imageMime, toRel, looksBinary, openRegular, MAX_TEXT_BYTES } from "./files.ts"; import { FileIndex } from "./index.ts"; import { watchTree } from "./watch.ts"; @@ -45,8 +45,11 @@ const MIME: Record = { wav: "audio/wav", }; -export function startServer(opts: ServeOptions): { server: Server; url: string; stop(): void } { +export function startServer(opts: ServeOptions): { server: Server; url: string; openToken: string; stop(): void } { const assets = loadAssets(); + // Terminals carry this narrower token (CODEVIEWER_TOKEN): only `codeviewer open` → POST /api/open + // honours it, so a process in the shell never holds the session token. + const openToken = randomBytes(32).toString("base64url"); const index = new FileIndex(opts.root); const settings = new Settings(); const updater = opts.updater ?? new Updater({ version: VERSION }); @@ -86,9 +89,13 @@ export function startServer(opts: ServeOptions): { server: Server; u let ptys: PtyManager; - const tokenOk = (candidate: string | null | undefined) => { - if (!candidate || candidate.length !== opts.token.length) return false; - return timingSafeEqual(Buffer.from(candidate), Buffer.from(opts.token)); + const tokenOk = (candidate: string | null | undefined, expected = opts.token) => { + if (!candidate) return false; + // Byte lengths: timingSafeEqual throws on unequal buffers, and a multibyte string's + // UTF-16 length can equal the token's while its UTF-8 length does not. + const a = Buffer.from(candidate); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); }; /** @@ -97,8 +104,12 @@ export function startServer(opts: ServeOptions): { server: Server; u * cross-site form. The page gets the token from the URL fragment, which is never sent * to any server. */ - const authed = (req: Request, url: URL) => - tokenOk(req.headers.get("x-cv-token")) || (url.pathname.startsWith("/ws/") && tokenOk(url.searchParams.get("t"))); + const authed = (req: Request, url: URL) => { + const h = req.headers.get("x-cv-token"); + if (tokenOk(h)) return true; + if (url.pathname.startsWith("/ws/")) return tokenOk(url.searchParams.get("t")); + return url.pathname === "/api/open" && tokenOk(h, openToken); + }; /** DNS-rebinding guard: only our own loopback host names (unless --host opened it up). */ const hostOk = (req: Request) => { @@ -123,7 +134,7 @@ export function startServer(opts: ServeOptions): { server: Server; u h.set( "content-security-policy", `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self'; ` + - `connect-src 'self' ws://${req.headers.get("host") ?? ""}; object-src 'none'; frame-ancestors 'none'; base-uri 'none'`, + `connect-src 'self' ws://${new URL(req.url).host}; object-src 'none'; frame-ancestors 'none'; base-uri 'none'`, ); if (req.headers.get("if-none-match") === a.etag) return new Response(null, { status: 304, headers: h }); if (a.gzip && (req.headers.get("accept-encoding") ?? "").includes("gzip")) { @@ -147,7 +158,7 @@ export function startServer(opts: ServeOptions): { server: Server; u const exclude = sp.get("exclude") ?? ""; if (!globsOk(include) || !globsOk(exclude)) return fail(400, "Invalid include/exclude globs"); - activeSearch?.abort(); + activeSearch?.abort("superseded"); const ac = new AbortController(); activeSearch = ac; const onReqAbort = () => ac.abort(); @@ -166,6 +177,7 @@ export function startServer(opts: ServeOptions): { server: Server; u let ended = false; let nFiles = 0; let nMatches = 0; + const t0 = performance.now(); const emit = (e: SearchEvent) => { started = true; if (e.t === "done") ended = true; @@ -186,6 +198,11 @@ export function startServer(opts: ServeOptions): { server: Server; u if (started && !ended) emit({ t: "done", files: nFiles, matches: nMatches, skipped: 0, truncated: false, ms: 0, error: true }); }) .finally(() => { + // Cancelled by a newer search (another window, say): its stream still gets a summary line, + // flagged as incomplete, instead of just ending. A client abort needs none. + if (started && !ended && ac.signal.reason === "superseded") { + emit({ t: "done", files: nFiles, matches: nMatches, skipped: 0, truncated: false, ms: Math.round(performance.now() - t0), error: true }); + } finished = true; if (activeSearch === ac) activeSearch = null; req.signal.removeEventListener("abort", onReqAbort); @@ -253,8 +270,9 @@ export function startServer(opts: ServeOptions): { server: Server; u if (p === "/ws/pty") { const id = url.searchParams.get("id") ?? ""; if (!/^[\w-]{6,64}$/.test(id)) return fail(400, "bad id"); - const cols = Math.max(2, Number(url.searchParams.get("cols")) || 80); - const rows = Math.max(1, Number(url.searchParams.get("rows")) || 24); + // Same bounds as a resize message: an out-of-range size makes the PTY throw after the shell spawned. + const cols = Math.min(1000, Math.max(2, Number(url.searchParams.get("cols")) || 80)); + const rows = Math.min(500, Math.max(1, Number(url.searchParams.get("rows")) || 24)); return srv.upgrade(req, { data: { kind: "pty", id, cols, rows } }) ? undefined : fail(400, "upgrade failed"); } return fail(404, "not found"); @@ -326,16 +344,37 @@ export function startServer(opts: ServeOptions): { server: Server; u return fail(400, (e as Error).message); } const limit = Number(url.searchParams.get("limit")); - const size = limit > 0 ? Math.min(limit, opened.st.size) : opened.st.size; + const total = limit > 0 ? Math.min(limit, opened.st.size) : opened.st.size; const fh = opened.fh; - let pos = 0; + // One `bytes=a-b` / `bytes=a-` / `bytes=-n` range, so media elements can seek. + let start = 0; + let end = total - 1; + let status = 200; + h.set("accept-ranges", "bytes"); + const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? ""); + if (range && (range[1] || range[2])) { + if (range[1]) { + start = Number(range[1]); + if (range[2]) end = Math.min(Number(range[2]), total - 1); + } else start = Math.max(0, total - Number(range[2])); + if (start >= total || start > end) { + await fh.close(); + h.set("content-range", `bytes */${total}`); + return new Response(null, { status: 416, headers: h }); + } + status = 206; + h.set("content-range", `bytes ${start}-${end}/${total}`); + } + const stop = end + 1; + let pos = start; const stream = new ReadableStream({ async pull(ctl) { - const chunk = new Uint8Array(Math.min(256 * 1024, size - pos)); + const chunk = new Uint8Array(Math.min(256 * 1024, stop - pos)); const { bytesRead } = chunk.length ? await fh.read(chunk, 0, chunk.length, pos) : { bytesRead: 0 }; if (bytesRead === 0) { await fh.close(); - return ctl.close(); + // The file shrank after fstat: fail the body rather than send fewer bytes than promised. + return pos < stop ? ctl.error(new Error("file changed while reading")) : ctl.close(); } pos += bytesRead; ctl.enqueue(chunk.subarray(0, bytesRead)); @@ -344,8 +383,10 @@ export function startServer(opts: ServeOptions): { server: Server; u await fh.close(); }, }); - h.set("content-length", String(size)); - return new Response(stream, { headers: h }); + // Bun streams a ReadableStream body chunked and drops this header; it documents the + // promise the short-read guard above keeps should that change. + h.set("content-length", String(stop - start)); + return new Response(stream, { status, headers: h }); } case "/api/search": @@ -458,10 +499,14 @@ export function startServer(opts: ServeOptions): { server: Server; u case "/api/open": { if (req.method !== "POST") return fail(405, "POST only"); const body = (await req.json().catch(() => null)) as { path?: string; line?: number } | null; - if (!body?.path) return fail(400, "missing path"); - const rel = path.isAbsolute(body.path) ? toRel(opts.root, body.path) : body.path; + if (!body?.path || typeof body.path !== "string") return fail(400, "missing path"); + // `codeviewer open` from a shell whose cwd goes through a symlink (/tmp → /private/tmp): + // resolve the parent, keeping the name as given (the file itself may not exist yet). + const given = body.path; + const abs = path.isAbsolute(given) ? path.join(await realpath(path.dirname(given)).catch(() => path.dirname(given)), path.basename(given)) : null; + const rel = abs !== null ? toRel(opts.root, abs) : given; if (rel === null || !(await resolveSafe(opts.root, rel))) return fail(404, "path is outside the viewed directory"); - broadcast({ t: "open", path: rel, line: body.line ?? 0 }); + broadcast({ t: "open", path: rel, line: Number.isInteger(body.line) && body.line! > 0 ? body.line : 0 }); return json({ ok: true, clients: events.size }); } } @@ -499,13 +544,14 @@ export function startServer(opts: ServeOptions): { server: Server; u TERM_PROGRAM: "codeviewer", TERM_PROGRAM_VERSION: VERSION, CODEVIEWER_URL: origin, - CODEVIEWER_TOKEN: opts.token, + CODEVIEWER_TOKEN: openToken, }); ptys = new PtyManager({ shell: opts.shell, cwd: opts.root, env }); return { server, url: `${origin}/#t=${opts.token}`, + openToken, stop() { stopWatch(); void gitReady.then((g) => g?.stop()); diff --git a/src/server/index.ts b/src/server/index.ts index 4b027aa..85a8de6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from "node:fs/promises"; +import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; /** Directories that are never worth walking for quick-open. */ @@ -69,11 +69,14 @@ export class FileIndex { await Promise.all( paths.map(async (p) => { if (p.split("/").some((seg) => HEAVY_DIRS.has(seg))) return; - const st = await stat(path.join(this.root, p)).catch(() => null); + const abs = path.join(this.root, p); + const st = await stat(abs).catch(() => null); if (st?.isDirectory()) { // Events for known directories are just content churn inside them. A new (or // moved-in) directory may hold many files: rescan from scratch. if (!this.knownDirs().has(p)) { + // Unless it is a symlink out of the root, which build() never lists. + if (!(await this.inside(abs))) return; this.invalidate(); changed = true; } @@ -95,14 +98,27 @@ export class FileIndex { return changed; } + private rootReal: Promise | null = null; + + /** True when `abs` resolves to the root or somewhere beneath it (symlinks followed). */ + private async inside(abs: string): Promise { + const root = await (this.rootReal ??= realpath(this.root).catch(() => this.root)); + const real = await realpath(abs).catch(() => null); + return real !== null && (real === root || real.startsWith(root + path.sep)); + } + private async build(): Promise> { const out: string[] = []; + const root = await (this.rootReal ??= realpath(this.root).catch(() => this.root)); + // Realpaths reached through a symlink: a link to its own ancestor, or a second link to the + // same target, is not walked again (real directories always are). + const linked = new Set(); // Breadth-first so shallow files win when the cap is hit. - let level: string[] = [""]; + let level: { rel: string; real: string }[] = [{ rel: "", real: root }]; while (level.length && out.length < MAX_FILES) { - const next: string[] = []; + const next: typeof level = []; await Promise.all( - level.map(async (rel) => { + level.map(async ({ rel, real }) => { let dirents; try { dirents = await readdir(path.join(this.root, rel), { withFileTypes: true }); @@ -111,8 +127,21 @@ export class FileIndex { } for (const d of dirents) { const p = rel ? rel + "/" + d.name : d.name; - if (d.isDirectory()) { - if (!HEAVY_DIRS.has(d.name)) next.push(p); + if (d.isSymbolicLink()) { + // Symlinks are followed only inside the root: a link to ~ or / is neither listed nor walked. + const abs = path.join(this.root, p); + const target = await realpath(abs).catch(() => null); + if (!target || !(target === root || target.startsWith(root + path.sep))) continue; + const st = await stat(abs).catch(() => null); + if (!st) continue; + if (!st.isDirectory()) { + if (out.length < MAX_FILES) out.push(p); + } else if (!HEAVY_DIRS.has(d.name) && !linked.has(target) && real !== target && !real.startsWith(target + path.sep)) { + linked.add(target); + next.push({ rel: p, real: target }); + } + } else if (d.isDirectory()) { + if (!HEAVY_DIRS.has(d.name)) next.push({ rel: p, real: real + path.sep + d.name }); } else if (out.length < MAX_FILES) { out.push(p); } diff --git a/src/server/pty.ts b/src/server/pty.ts index 07a631a..bb6a209 100644 --- a/src/server/pty.ts +++ b/src/server/pty.ts @@ -7,6 +7,8 @@ const FLUSH_MS = 4; const FLUSH_BYTES = 64 * 1024; /** Above this much unsent data the client is lagging: stop streaming, resync on drain. */ const HIGH_WATER = 8 * 1024 * 1024; +/** A shell that exits while nobody is attached (page reload) is kept this long for its exit notice. */ +const EXIT_GRACE_MS = 60_000; /** Private modes that change how input/output must be interpreted; restored on replay. */ const TRACKED = [1, 25, 1000, 1002, 1003, 1004, 1006, 2004, 47, 1047, 1049]; @@ -45,6 +47,7 @@ class PtySession { private lagging = false; ws: ServerWebSocket | null = null; exited: number | null = null; + private grace: ReturnType | null = null; constructor( readonly id: string, @@ -65,13 +68,27 @@ class PtySession { this.term = (this.proc as unknown as { terminal: PtySession["term"] }).terminal; this.proc.exited.then((code) => { this.exited = code; - this.flush(); // deliver the last output before the exit notice - this.ws?.send(JSON.stringify({ t: "exit", code })); - this.ws?.close(1000, "exited"); - this.onGone(this.id); + if (!this.ws) { + // Nobody attached (page reload mid-exit): keep the final output and exit code for the + // next attach, unless no one comes back within the grace period. + this.grace = setTimeout(() => this.onGone(this.id), EXIT_GRACE_MS); + this.grace.unref?.(); + return; + } + // Deliver the last output before the exit notice; a lagging client gets it via the replay ring. + if (this.lagging) this.sendReplay(this.ws); + else this.flush(); + this.finish(this.ws); }); } + /** Exit notice, then the socket is closed and the session forgotten. */ + private finish(ws: ServerWebSocket) { + ws.send(JSON.stringify({ t: "exit", code: this.exited })); + ws.close(1000, "exited"); + this.onGone(this.id); + } + private output(data: Uint8Array) { const copy = data.slice(); this.chunks.push({ data: copy, modes: this.modesStr }); @@ -148,6 +165,13 @@ class PtySession { } attach(ws: ServerWebSocket) { + if (this.exited !== null) { + // The shell ended while detached: replay what it printed last, then the exit notice. + if (this.grace) clearTimeout(this.grace); + this.sendReplay(ws); + this.finish(ws); + return; + } if (this.ws && this.ws !== ws) this.ws.close(4000, "replaced"); this.ws = ws; this.lagging = false; @@ -170,6 +194,11 @@ class PtySession { } kill() { + if (this.grace) { + clearTimeout(this.grace); + this.onGone(this.id); + return; + } try { // Closing the PTY hangs up the whole session (every job, not just the shell). this.term.close(); diff --git a/src/server/search.ts b/src/server/search.ts index 554bae7..e6fc82b 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -219,12 +219,29 @@ export function globsOk(s: string): boolean { return s.length <= MAX_GLOB_CHARS && parseGlobs(s).length <= MAX_GLOBS; } +/** + * Editor-style glob semantics on root-relative paths: a pattern without "/" matches in every + * folder (`*.ts` finds src/deep/b.ts, and a bare name such as `src` also matches everything under + * it), a trailing "/" means the folder's contents (`src/` is `src` + `/**`), and a pattern with "/" + * is matched as written. + */ +export function compileGlob(pattern: string): (path: string) => boolean { + if (pattern.endsWith("/")) pattern += "**"; + if (pattern.includes("/")) { + const g = new Bun.Glob(pattern); + return (p) => g.match(p); + } + const anywhere = new Bun.Glob(`**/${pattern}`); + const below = new Bun.Glob(`**/${pattern}/**`); + return (p) => anywhere.match(p) || below.match(p); +} + /** Keeps paths matching any include glob (all when none) and no exclude glob (Bun.Glob, root-relative paths). */ export function filterByGlobs(files: string[], include: string[], exclude: string[]): string[] { if (!include.length && !exclude.length) return files; - const inc = include.map((g) => new Bun.Glob(g)); - const exc = exclude.map((g) => new Bun.Glob(g)); - return files.filter((f) => (!inc.length || inc.some((g) => g.match(f))) && !exc.some((g) => g.match(f))); + const inc = include.map(compileGlob); + const exc = exclude.map(compileGlob); + return files.filter((f) => (!inc.length || inc.some((g) => g(f))) && !exc.some((g) => g(f))); } /** Drops every path whose realpath is not inside rootReal (symlinked parents, missing files). */ diff --git a/src/server/search.worker.ts b/src/server/search.worker.ts index b6bfc49..b03fcc1 100644 --- a/src/server/search.worker.ts +++ b/src/server/search.worker.ts @@ -48,9 +48,16 @@ function run({ req, cancel }: WorkerJob): void { let fd = -1; try { // Containment: a symlinked parent (or file) that resolves outside the root is never opened. - if (!within(rootReal, realpathSync(path.join(rootReal, rel)))) throw new Error("skip"); + let real: string; + try { + real = realpathSync(path.join(rootReal, rel)); + } catch { + continue; // gone since it was listed (deleted but still in the index): nothing was skipped + } + if (!within(rootReal, real)) throw new Error("skip"); fd = openSync(path.join(rootReal, rel), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const st = fstatSync(fd); + if (st.isDirectory()) continue; // submodules and nested repositories are listed as paths, not files if (!st.isFile() || st.size > MAX_TEXT_BYTES) throw new Error("skip"); const id = `${st.dev}:${st.ino}`; if (seen.has(id)) continue; diff --git a/src/server/update.ts b/src/server/update.ts index 60feead..0789119 100644 --- a/src/server/update.ts +++ b/src/server/update.ts @@ -63,8 +63,12 @@ export function detectInstall( /** Compares "1.2.3" style versions (a pre-release sorts before its release). */ export function compareVersions(a: string, b: string): number { const parse = (v: string) => { - const [core, pre] = v.replace(/^v/, "").split("-", 2); - return { nums: core!.split(".").map((n) => Number.parseInt(n, 10) || 0), pre: pre ?? "" }; + // Build metadata (+sha) never orders; everything after the first "-" is the pre-release. + const base = v.replace(/^v/, "").replace(/\+.*$/, ""); + const dash = base.indexOf("-"); + const core = dash < 0 ? base : base.slice(0, dash); + const pre = dash < 0 ? "" : base.slice(dash + 1); + return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre }; }; const x = parse(a); const y = parse(b); @@ -75,7 +79,24 @@ export function compareVersions(a: string, b: string): number { if (x.pre === y.pre) return 0; if (!x.pre) return 1; if (!y.pre) return -1; - return x.pre < y.pre ? -1 : 1; + // Semver §11: dot-separated identifiers, numeric ones numerically (rc.10 > rc.9), numeric + // before alphanumeric, and a prefix before anything longer. + const xs = x.pre.split("."); + const ys = y.pre.split("."); + for (let i = 0; i < Math.max(xs.length, ys.length); i++) { + const p = xs[i]; + const q = ys[i]; + if (p === undefined) return -1; + if (q === undefined) return 1; + const pn = /^\d+$/.test(p); + const qn = /^\d+$/.test(q); + if (pn && qn) { + const d = Number(p) - Number(q); + if (d) return Math.sign(d); + } else if (pn !== qn) return pn ? -1 : 1; + else if (p !== q) return p < q ? -1 : 1; + } + return 0; } export interface Release { @@ -106,8 +127,17 @@ export interface UpdaterOptions { publicKey?: string; install?: Install; api?: string; + /** Hosts release assets may be downloaded from (always over https); defaults to GitHub's. */ + assetHosts?: string[]; } +/** Where GitHub serves release assets (the initial URL; GitHub's own redirects are followed). */ +export const ASSET_HOSTS = ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]; +/** SHA256SUMS and its signature are tiny; a hostile host must not make us buffer gigabytes. */ +const MAX_SUMS_BYTES = 1 << 20; +/** No release asset is anywhere near this; the download stops here before any verification. */ +const MAX_ASSET_BYTES = 1024 * 1024 * 1024; + /** Parses a SHA256SUMS file into name → hex digest. */ export function parseSums(text: string): Map { const out = new Map(); @@ -151,6 +181,7 @@ export class Updater { private fetch: typeof fetch; private publicKey: string; private api: string; + private assetHosts: string[]; private cached: { info: UpdateInfo; release: Release | null } | null = null; private busy = false; @@ -160,6 +191,19 @@ export class Updater { this.publicKey = o.publicKey ?? UPDATE_PUBLIC_KEY; this.install = o.install ?? detectInstall(); this.api = o.api ?? `https://api.github.com/repos/${REPO}/releases/latest`; + this.assetHosts = o.assetHosts ?? ASSET_HOSTS; + } + + /** Release assets come only from the release host, over TLS: the API answer names the URLs. */ + private assetUrl(url: string): string { + let u: URL; + try { + u = new URL(url); + } catch { + throw new Error("release asset has an invalid URL"); + } + if (u.protocol !== "https:" || !this.assetHosts.includes(u.hostname)) throw new Error(`refusing to download from ${u.host}`); + return u.href; } get installing() { @@ -217,9 +261,12 @@ export class Updater { } private async get(url: string): Promise { - const res = await this.fetch(url, { headers: { "user-agent": `codeviewer/${this.version}` } }); + const res = await this.fetch(this.assetUrl(url), { headers: { "user-agent": `codeviewer/${this.version}` } }); if (!res.ok) throw new Error(`download failed (${res.status}): ${url}`); - return new Uint8Array(await res.arrayBuffer()); + if (Number(res.headers.get("content-length")) > MAX_SUMS_BYTES) throw new Error("checksum file is too large"); + const body = new Uint8Array(await res.arrayBuffer()); + if (body.length > MAX_SUMS_BYTES) throw new Error("checksum file is too large"); + return body; } /** @@ -278,18 +325,23 @@ export class Updater { /** Streams a download to disk, hashing as it goes. Returns the hex SHA-256. */ private async download(url: string, dest: string, pct: (n: number) => void): Promise { - const res = await this.fetch(url, { headers: { "user-agent": `codeviewer/${this.version}` } }); + const res = await this.fetch(this.assetUrl(url), { headers: { "user-agent": `codeviewer/${this.version}` } }); if (!res.ok || !res.body) throw new Error(`download failed (${res.status})`); const total = Number(res.headers.get("content-length")) || 0; + if (total > MAX_ASSET_BYTES) throw new Error("release asset is too large"); const hash = new Bun.CryptoHasher("sha256"); const sink = Bun.file(dest).writer(); let got = 0; let last = -1; const reader = res.body.getReader(); for (let r = await reader.read(); !r.done; r = await reader.read()) { + got += r.value.length; + if (got > MAX_ASSET_BYTES) { + await reader.cancel().catch(() => {}); + throw new Error("release asset is too large"); + } hash.update(r.value); sink.write(r.value); - got += r.value.length; const p = total ? Math.floor((got / total) * 100) : 0; if (p !== last) { last = p; diff --git a/test/client.codeview.layout.test.ts b/test/client.codeview.layout.test.ts new file mode 100644 index 0000000..3e6afcd --- /dev/null +++ b/test/client.codeview.layout.test.ts @@ -0,0 +1,42 @@ +import { afterAll, expect, test } from "bun:test"; +import { uninstallDom } from "./helpers/fakedom.ts"; +import { loadCodeView, mount } from "./helpers/selfake.ts"; + +afterAll(() => uninstallDom()); + +test("the content width counts tab-indented lines whatever their order", async () => { + const env = await loadCodeView(); + const w1 = (mount(env, "abcdefghijklm\n\t\t\t\tabcdefgh").view as unknown as { sizer: HTMLElement }).sizer.style.width; + const w2 = (mount(env, "\t\t\t\tabcdefgh\nabcdefghijklm").view as unknown as { sizer: HTMLElement }).sizer.style.width; + expect(w1).toBe(w2); +}); + +test("an empty file that is empty at HEAD shows no git markers", async () => { + const env = await loadCodeView(); + const m = mount(env, ""); + m.view.setGit("", false); + expect(Array.from((m.view as unknown as { gitMarks: Uint8Array }).gitMarks)).toEqual([0]); + // Content against an empty HEAD is still all added. + const n = mount(env, "a\nb"); + n.view.setGit("", false); + expect(Array.from((n.view as unknown as { gitMarks: Uint8Array }).gitMarks).every((x) => x > 0)).toBe(true); +}); + +test("peekCurrent opens the HEAD peek on a changed active line and reports an unchanged one", async () => { + const env = await loadCodeView(); + const m = mount(env, "a\nB\nc"); + // The fake elements lack the two layout APIs the peek box uses. + const proto = Object.getPrototypeOf(m.view.el) as Record; + proto.insertAdjacentHTML ??= function (this: { innerHTML: string }, _p: string, html: string) { + this.innerHTML += html; + }; + proto.getBoundingClientRect ??= () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }); + m.view.setGit("a\nb\nc", false); + m.view.setActiveLine(0); + expect(m.view.peekCurrent()).toBe(false); + m.view.setActiveLine(1); + expect(m.view.peekCurrent()).toBe(true); + const v = m.view as unknown as { peek: { getAttribute(n: string): string | null } | null }; + expect(v.peek?.getAttribute("aria-label")).toContain("in HEAD"); + // (Closing dispatches a native Event through `dismissable`, which this harness does not model.) +}); diff --git a/test/client.diffview.test.ts b/test/client.diffview.test.ts index 7b27835..605234f 100644 --- a/test/client.diffview.test.ts +++ b/test/client.diffview.test.ts @@ -211,3 +211,28 @@ describe("DiffView", () => { expect(t.el.style["--code-size"]).toBe("20px"); }); }); + +describe("live reload keeps the reader's place", () => { + const make = () => { + const v = new D.DiffView({ path: "a.ts", letter: "M", label: "Modified", vs: "vs main" }, "ts", 13); + return { v, el: v.el as unknown as FakeElement }; + }; + const body = (n: number, edits: Record) => Array.from({ length: n }, (_, i) => edits[i] ?? `line ${i}`).join("\n") + "\n"; + + test("an edit inside a hunk keeps opened gaps and the current hunk; a new hunk starts over", () => { + const t = make(); + const old = body(60, {}); + t.v.setTexts(old, body(60, { 10: "changed A", 50: "changed B" })); + flush(); + const v = t.v as unknown as { open: Set; cur: number; hunks: unknown[] }; + expect(v.hunks.length).toBe(2); + v.open.add(1); + v.cur = 1; + t.v.setTexts(old, body(60, { 10: "changed A again", 50: "changed B" })); + expect([...v.open]).toEqual([1]); + expect(v.cur).toBe(1); + t.v.setTexts(old, body(60, { 10: "changed A", 30: "new hunk", 50: "changed B" })); + expect(v.open.size).toBe(0); + expect(v.cur).toBe(-1); + }); +}); diff --git a/test/client.mdlinks.test.ts b/test/client.mdlinks.test.ts index 1a052c4..423d54b 100644 --- a/test/client.mdlinks.test.ts +++ b/test/client.mdlinks.test.ts @@ -133,12 +133,27 @@ describe("AC-13 repo links", () => { expect((v.el as FakeElement).textContent).not.toContain("File not found"); }); - test("external links are not intercepted", () => { + test("external links open in the default browser through the server, never by navigating the window", () => { const calls: string[] = []; - const v = new V.MarkdownView("[x](https://x.y)", 13, { path: "a.md", onOpen: (p) => void calls.push(p) }); - const ev = click(one(v.el as FakeElement, "a")); - expect(calls).toEqual([]); - expect(ev.defaultPrevented).toBe(false); + const posted: { url: string; body: string }[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = ((url: string, init?: RequestInit) => { + posted.push({ url, body: String(init?.body ?? "") }); + return Promise.resolve(new Response("{}")); + }) as typeof fetch; + try { + const v = new V.MarkdownView("[x](https://x.y/p?q=1) [m](mailto:a@b.c)", 13, { path: "a.md", onOpen: (p) => void calls.push(p) }); + const [ext, mail] = q(v.el as FakeElement, "a"); + const ev = click(ext!); + expect(calls).toEqual([]); + expect(ev.defaultPrevented).toBe(true); + expect(posted).toEqual([{ url: "/api/external", body: JSON.stringify({ url: "https://x.y/p?q=1" }) }]); + // mailto: is left to the platform. + expect(click(mail!).defaultPrevented).toBe(false); + expect(posted.length).toBe(1); + } finally { + globalThis.fetch = realFetch; + } }); test("clicking non-link content does nothing", () => { diff --git a/test/client.palette.test.ts b/test/client.palette.test.ts index 2d02528..28ca137 100644 --- a/test/client.palette.test.ts +++ b/test/client.palette.test.ts @@ -95,6 +95,24 @@ describe("Palette", () => { return { p, input, list, type, key, rows, flush, calls: m.calls }; }; + test("combobox semantics: expanded while open, the current option named for assistive tech", async () => { + const t = await make(); + expect(t.input.getAttribute("role")).toBe("combobox"); + expect(t.input.getAttribute("aria-expanded")).toBe("false"); + t.p.open(); + await t.flush(); + expect(t.input.getAttribute("aria-expanded")).toBe("true"); + expect(t.input.getAttribute("aria-activedescendant")).toBe("pal-0"); + expect(t.list.innerHTML).toContain('data-i="0" id="pal-0" aria-selected="true"'); + t.key("ArrowDown"); + expect(t.input.getAttribute("aria-activedescendant")).toBe("pal-1"); + // (The fake DOM does not materialise innerHTML children, so only the input's attribute is observable here.) + t.type("zzzz-no-such-file"); + expect(t.input.getAttribute("aria-activedescendant")).toBeNull(); + t.p.close(); + expect(t.input.getAttribute("aria-expanded")).toBe("false"); + }); + test("starts hidden; open shows, focuses and loads the index once", async () => { const t = await make(); expect(t.p.isOpen).toBe(false); diff --git a/test/client.tree.reload.test.ts b/test/client.tree.reload.test.ts new file mode 100644 index 0000000..3fcc4f0 --- /dev/null +++ b/test/client.tree.reload.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { installDom, uninstallDom } from "./helpers/fakedom.ts"; + +// A watcher event that lands while a folder listing is already in flight must not be lost: +// the stale request's answer predates the change, so the folder is listed once more. +let T: typeof import("../src/client/tree.ts"); +const g = globalThis as unknown as Record; +const realFetch = globalThis.fetch; +beforeAll(async () => { + installDom(); + g.ResizeObserver = class { + observe() {} + disconnect() {} + }; + g.requestAnimationFrame = () => 1; + T = await import("../src/client/tree.ts"); +}); +afterAll(() => { + globalThis.fetch = realFetch; + uninstallDom(); +}); + +test("a change during an in-flight listing re-lists the folder", async () => { + const listings: Record = { "": [["dir"]], dir: [["a"], ["a", "b"], ["a", "b", "c"]] }; + const calls: string[] = []; + let release: (() => void) | null = null; + globalThis.fetch = (async (url: string) => { + const p = decodeURIComponent(url.split("path=")[1] ?? ""); + calls.push(p); + const q = listings[p]!; + const names = q.length > 1 ? q.shift()! : q[0]!; + // The second listing of "dir" hangs until released, like a slow server. + if (p === "dir" && calls.filter((c) => c === "dir").length === 2) await new Promise((r) => (release = r)); + return new Response(JSON.stringify(names.map((n) => ({ name: n, type: n === "dir" ? "dir" : "file" })))); + }) as typeof fetch; + const t = new T.FileTree("root") as unknown as { + init(): Promise; + toggle(n: unknown, open: boolean): Promise; + onChanged(paths: string[]): Promise | void; + byPath: Map; + }; + await t.init(); + await t.toggle(t.byPath.get("dir"), true); // [a] + const p1 = t.onChanged(["dir/b"]); // listing 2, blocked + await Bun.sleep(5); + const p2 = t.onChanged(["dir/c"]); // arrives while listing 2 is in flight + await Bun.sleep(5); + release!(); + await Promise.all([p1, p2]); + await Bun.sleep(5); + expect(calls.filter((c) => c === "dir").length).toBe(3); // expand, the blocked reload, and one more for the change that arrived meanwhile + expect(t.byPath.has("dir/b")).toBe(true); + expect(t.byPath.has("dir/c")).toBe(true); +}); + +test("an unreadable folder shows why instead of looking empty, and the tree names its selected row", async () => { + globalThis.fetch = (async (url: string) => { + const p = decodeURIComponent(url.split("path=")[1] ?? ""); + if (p === "") return new Response(JSON.stringify([{ name: "locked", type: "dir" }, { name: "a.txt", type: "file" }])); + return new Response(JSON.stringify({ error: "EACCES: permission denied" }), { status: 400 }); + }) as typeof fetch; + const t = new T.FileTree("root") as unknown as { + el: HTMLElement; + init(): Promise; + toggle(n: unknown, open: boolean): Promise; + byPath: Map; + flat: { name: string; note?: string }[]; + focus(): void; + render(): void; + }; + await t.init(); + await t.toggle(t.byPath.get("locked"), true); + const note = t.flat.find((n) => n.note); + expect(note?.note).toBe("error"); + expect(note?.name).toMatch(/^Can't read folder: /); + t.focus(); + const el = t.el as unknown as { getAttribute(n: string): string | null; innerHTML: string; clientHeight: number }; + el.clientHeight = 600; // the fake DOM has no layout: give the virtualizer a viewport + t.render(); + expect(el.getAttribute("role")).toBe("tree"); + expect(el.getAttribute("aria-activedescendant")).toBe("tr-0"); + expect(el.innerHTML).toContain('id="tr-0" data-i="0" aria-level="1" aria-selected="true"'); + expect(el.innerHTML).toContain('class="row file note error"'); +}); diff --git a/test/fixtures/md-core.json b/test/fixtures/md-core.json index ebc6975..5314a8d 100644 --- a/test/fixtures/md-core.json +++ b/test/fixtures/md-core.json @@ -867,5 +867,291 @@ ] } ] + }, + { + "name": "a list marker that cannot interrupt a paragraph is a lazy continuation in a quote", + "src": "> The number of windows is\n14. The number of doors is 6.", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "The number of windows is\n14. The number of doors is 6." + } + ] + } + ] + } + ] + }, + { + "name": "a list marker that cannot interrupt a paragraph is a lazy continuation in a list item", + "src": "- The number of windows is\n14. The number of doors is 6.", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "The number of windows is\n14. The number of doors is 6." + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a sibling marker is not a lazy continuation of a list inside a quote", + "src": "> 1. a\n2. b", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "list", + "ordered": true, + "start": 1, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "list", + "ordered": true, + "start": 2, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a bullet still interrupts a lazy paragraph in a quote", + "src": "> a\n- b", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a setext underline is not a lazy continuation of a quote", + "src": "> a\n===", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\n===" + } + ] + } + ] + } + ] + }, + { + "name": "a setext underline is not a lazy continuation of a list item", + "src": "- a\n===", + "ast": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\n===" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a setext heading inside a quote leaves no paragraph open for lazy lines", + "src": "> a\n> ===\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "text", + "value": "a" + } + ] + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + }, + { + "name": "a lazy line keeps the paragraph open for the next lazy line", + "src": "> > a\n> ===\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a\n===\nb" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "a lazy continuation after a quote with a fenced block in a list item", + "src": "> - ```\n> x\nb", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "list", + "ordered": false, + "start": null, + "tight": true, + "items": [ + { + "type": "item", + "children": [ + { + "type": "code", + "lang": null, + "value": "x" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] } -] +] \ No newline at end of file diff --git a/test/fixtures/md-gfm.json b/test/fixtures/md-gfm.json index 4c1211e..1c918dd 100644 --- a/test/fixtures/md-gfm.json +++ b/test/fixtures/md-gfm.json @@ -803,5 +803,67 @@ ] } ] + }, + { + "name": "a delimiter row is not a lazy continuation of a quoted paragraph", + "src": "> a | b\n--- | ---", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "a | b\n--- | ---" + } + ] + } + ] + } + ] + }, + { + "name": "table rows do not continue lazily outside the quote", + "src": "> a|b\n> -|-\nc", + "ast": [ + { + "type": "blockquote", + "children": [ + { + "type": "table", + "align": [ + null, + null + ], + "head": [ + [ + { + "type": "text", + "value": "a" + } + ], + [ + { + "type": "text", + "value": "b" + } + ] + ], + "rows": [] + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "c" + } + ] + } + ] } ] \ No newline at end of file diff --git a/test/fixtures/md-inline.json b/test/fixtures/md-inline.json index 739b09f..f19725d 100644 --- a/test/fixtures/md-inline.json +++ b/test/fixtures/md-inline.json @@ -671,5 +671,591 @@ ] } ] + }, + { + "name": "definition with the title on the next line", + "src": "[foo]: /url\n\"title\"\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": "title", + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "definition with the destination on the next line", + "src": "[foo]:\n/url\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "definition title spans lines", + "src": "[foo]: /url '\ntitle\nline1\nline2\n'\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": "\ntitle\nline1\nline2\n", + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "definition title with a blank line is not a definition", + "src": "[foo]: /url 'title\n\nwith blank line'\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]: /url 'title" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "with blank line'" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]" + } + ] + } + ] + }, + { + "name": "definition without a destination is a paragraph", + "src": "[foo]:\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]:" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]" + } + ] + } + ] + }, + { + "name": "text after a same-line title voids the definition", + "src": "[foo]: /url \"title\" ok\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]: /url \"title\" ok" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]" + } + ] + } + ] + }, + { + "name": "text after a next-line title keeps the definition and drops the title", + "src": "[foo]: /url\n\"title\" ok\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "\"title\" ok" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "no space between destination and title is not a definition", + "src": "[foo]: (baz)\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]: (baz)" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[foo]" + } + ] + } + ] + }, + { + "name": "definition followed by paragraph text", + "src": "[foo]: /url\nbar\n\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "bar" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "two definitions in one paragraph", + "src": "[a]: /a\n[b]: /b\n\n[a] [b]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/a", + "title": null, + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "text", + "value": " " + }, + { + "type": "link", + "href": "/b", + "title": null, + "children": [ + { + "type": "text", + "value": "b" + } + ] + } + ] + } + ] + }, + { + "name": "a setext underline after only definitions is plain text", + "src": "[foo]: /url\n===\n[foo]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "===\n" + }, + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "definitions before a setext heading are taken out of it", + "src": "[foo]: /url\nbar\n===\n\n[foo]", + "ast": [ + { + "type": "heading", + "level": 1, + "children": [ + { + "type": "text", + "value": "bar" + } + ] + }, + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": null, + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "a definition on a lazy line completes with its title", + "src": "> [foo]: /url\n\"title\"\n\n[foo]", + "ast": [ + { + "type": "blockquote", + "children": [] + }, + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/url", + "title": "title", + "children": [ + { + "type": "text", + "value": "foo" + } + ] + } + ] + } + ] + }, + { + "name": "autolinks after an unclosed bracket", + "src": "[ see https://example.com and ", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[ see " + }, + { + "type": "link", + "href": "https://example.com", + "title": null, + "children": [ + { + "type": "text", + "value": "https://example.com" + } + ] + }, + { + "type": "text", + "value": " and " + }, + { + "type": "link", + "href": "https://b.c", + "title": null, + "children": [ + { + "type": "text", + "value": "https://b.c" + } + ] + } + ] + } + ] + }, + { + "name": "autolinks inside and after a literal bracket pair", + "src": "[see https://a.b] and https://c.d", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[see " + }, + { + "type": "link", + "href": "https://a.b", + "title": null, + "children": [ + { + "type": "text", + "value": "https://a.b" + } + ] + }, + { + "type": "text", + "value": "] and " + }, + { + "type": "link", + "href": "https://c.d", + "title": null, + "children": [ + { + "type": "text", + "value": "https://c.d" + } + ] + } + ] + } + ] + }, + { + "name": "no autolink inside link text", + "src": "[https://a.b](/x) https://c.d", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/x", + "title": null, + "children": [ + { + "type": "text", + "value": "https://a.b" + } + ] + }, + { + "type": "text", + "value": " " + }, + { + "type": "link", + "href": "https://c.d", + "title": null, + "children": [ + { + "type": "text", + "value": "https://c.d" + } + ] + } + ] + } + ] + }, + { + "name": "no autolink inside a literal bracket nested in link text", + "src": "[a [b https://x.y] c](/u) https://z.w", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "href": "/u", + "title": null, + "children": [ + { + "type": "text", + "value": "a [b https://x.y] c" + } + ] + }, + { + "type": "text", + "value": " " + }, + { + "type": "link", + "href": "https://z.w", + "title": null, + "children": [ + { + "type": "text", + "value": "https://z.w" + } + ] + } + ] + } + ] + }, + { + "name": "autolink after a bracket deactivated by an inner link", + "src": "[ x [a](/u) https://c.d", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[ x " + }, + { + "type": "link", + "href": "/u", + "title": null, + "children": [ + { + "type": "text", + "value": "a" + } + ] + }, + { + "type": "text", + "value": " " + }, + { + "type": "link", + "href": "https://c.d", + "title": null, + "children": [ + { + "type": "text", + "value": "https://c.d" + } + ] + } + ] + } + ] + }, + { + "name": "autolink inside emphasis in a literal bracket", + "src": "[ *https://a.b* ]", + "ast": [ + { + "type": "paragraph", + "children": [ + { + "type": "text", + "value": "[ " + }, + { + "type": "em", + "children": [ + { + "type": "link", + "href": "https://a.b", + "title": null, + "children": [ + { + "type": "text", + "value": "https://a.b" + } + ] + } + ] + }, + { + "type": "text", + "value": " ]" + } + ] + } + ] } -] +] \ No newline at end of file diff --git a/test/git.security.test.ts b/test/git.security.test.ts index 5521da9..c8ca7c1 100644 --- a/test/git.security.test.ts +++ b/test/git.security.test.ts @@ -29,7 +29,8 @@ beforeAll(async () => { sh(["config", "user.email", "a@b"]); sh(["config", "user.name", "a"]); await writeFile(path.join(repo, "a.txt"), "one\ntwo\n"); - sh(["add", "a.txt"]); + await writeFile(path.join(repo, "b.md"), "# b\n"); + sh(["add", "a.txt", "b.md"]); sh(["commit", "-qm", "init"]); // Make HEAD a commit carrying a (fake) signature so signature verification would call gpg. const tree = sh(["rev-parse", "HEAD^{tree}"]).trim(); @@ -40,14 +41,17 @@ beforeAll(async () => { // Attack surface: attributes in tree and in $GIT_DIR/info, drivers + gpg + fsmonitor in config. await writeFile(path.join(repo, ".gitattributes"), "*.txt filter=pwn diff=pwn merge=pwn\n"); await mkdir(path.join(repo, ".git", "info"), { recursive: true }); - await writeFile(path.join(repo, ".git", "info", "attributes"), "* filter=pwn2 diff=pwn2\n"); + await writeFile(path.join(repo, ".git", "info", "attributes"), "* filter=pwn2 diff=pwn2\n*.md filter=a=b diff=a=b merge=a=b\n"); for (const [k, v] of [ ["filter.pwn.clean", evil], ["filter.pwn.smudge", evil], ["filter.pwn.process", evil], ["filter.pwn.required", "true"], ["filter.pwn2.clean", evil], ["diff.pwn.textconv", evil], ["diff.pwn2.textconv", evil], ["diff.pwn.command", evil], ["merge.pwn.driver", evil], ["log.showSignature", "true"], ["gpg.program", evil], ["core.fsmonitor", evil], + // A driver whose name contains "=": a `-c filter.a=b.clean=` override would set filter.a instead. + ["filter.a=b.clean", evil], ["filter.a=b.required", "true"], ["diff.a=b.textconv", evil], ["merge.a=b.driver", evil], ]) sh(["config", k!, v!]); // Dirty the file so status must compare content (which would run the clean filter). await writeFile(path.join(repo, "a.txt"), "one\nTWO\n"); + await writeFile(path.join(repo, "b.md"), "# B\n"); }); afterAll(async () => { @@ -59,8 +63,13 @@ test("a malicious repository cannot make codeviewer's git calls run programs", a expect(g).not.toBeNull(); const s = await g.status(); expect(s.files["a.txt"]).toBe(".M"); + expect(s.files["b.md"]).toBe(".M"); expect(s.head?.subject).toBe("signed"); await g.base("a.txt", 1 << 20); + await g.base("b.md", 1 << 20); await g.blame("a.txt", 2); + await g.blame("b.md", 1); + const c = await g.changes("head"); + expect(c.files.map((f) => f.path)).toContain("b.md"); expect(existsSync(marker) ? await readFile(marker, "utf8") : "").toBe(""); }); diff --git a/test/gitstate.test.ts b/test/gitstate.test.ts index 4cb819d..b1cc943 100644 --- a/test/gitstate.test.ts +++ b/test/gitstate.test.ts @@ -48,3 +48,14 @@ test("cleared: only a changed-to-clean/ignored transition counts", () => { expect(g.cleared("d.ts")).toBe(false); // clean to modified expect(g.cleared("a.ts")).toBe(false); // clean to clean }); + +test("a viewed folder that is itself ignored marks everything inside it ignored", () => { + const g = new GitState(); + g.set({ ...base, rootIgnored: true, files: {} }); + expect(g.file("deep/x.js")?.letter).toBe("I"); + expect(g.dir("deep")).toBe("I"); + expect(g.counts).toEqual({ changed: 0, staged: 0, untracked: 0, conflicts: 0 }); + g.set({ ...base, files: {} }); + expect(g.file("deep/x.js")).toBeNull(); + expect(g.dir("deep")).toBeNull(); +}); diff --git a/test/highlight.coverage.test.ts b/test/highlight.coverage.test.ts index 08ecb78..70fe602 100644 --- a/test/highlight.coverage.test.ts +++ b/test/highlight.coverage.test.ts @@ -82,6 +82,9 @@ describe("highlight: config formats", () => { expect(toks("yaml", "name: hello # c")).toEqual(["p:name", "c:# c"]); expect(toks("yaml", ' - "quoted": 1')).toEqual(['p:"quoted"', "n:1"]); expect(toks("yaml", "key: 'x'")).toEqual(["p:key", "s:'x'"]); + // A value's "http:" is not a key: only a colon followed by space or end of line makes a property. + expect(toks("yaml", "url: http://example.test/a")).toEqual(["p:url"]); + expect(toks("yaml", "flow: {a: 1, b: 2}").filter((t) => t.startsWith("p:"))).toEqual(["p:flow", "p:a", "p:b"]); }); test("toml sections and keys", () => { diff --git a/test/http.coverage.test.ts b/test/http.coverage.test.ts index 90e7ee6..973e1ec 100644 --- a/test/http.coverage.test.ts +++ b/test/http.coverage.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile, mkdir, realpath, symlink, readFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, mkdir, realpath, symlink, readFile, truncate } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { startServer, VERSION } from "../src/server/http.ts"; @@ -475,3 +475,54 @@ test("background auto-update check runs once per launch and feeds /api/update", process.env.CODEVIEWER_NO_UPDATE_CHECK = prev ?? "1"; } }, 10000); + +test("/api/raw honours a single byte range (206/416) so media can seek, also under ?limit", async () => { + const r = (range: string, extra = "") => get(`/api/raw?path=noext${extra}`, { ...auth, range }); + expect((await get("/api/raw?path=noext")).headers.get("accept-ranges")).toBe("bytes"); + const mid = await r("bytes=10-19"); + expect(mid.status).toBe(206); + expect(mid.headers.get("content-range")).toBe("bytes 10-19/1000"); + expect((await mid.text()).length).toBe(10); + const tail = await r("bytes=990-"); + expect(tail.status).toBe(206); + expect(tail.headers.get("content-range")).toBe("bytes 990-999/1000"); + expect((await tail.text()).length).toBe(10); + const suffix = await r("bytes=-5"); + expect(suffix.headers.get("content-range")).toBe("bytes 995-999/1000"); + expect((await suffix.text()).length).toBe(5); + const over = await r("bytes=0-5000"); + expect(over.headers.get("content-range")).toBe("bytes 0-999/1000"); + expect((await over.text()).length).toBe(1000); + const bad = await r("bytes=2000-"); + expect(bad.status).toBe(416); + expect(bad.headers.get("content-range")).toBe("bytes */1000"); + expect((await r("bytes=20-10")).status).toBe(416); + // The limit caps the resource the range applies to. + const lim = await r("bytes=90-200", "&limit=100"); + expect(lim.status).toBe(206); + expect(lim.headers.get("content-range")).toBe("bytes 90-99/100"); + expect((await lim.text()).length).toBe(10); + // Unparseable ranges are ignored, not refused. + expect((await r("bytes=a-b")).status).toBe(200); + expect((await get("/api/info")).status).toBe(200); +}); + +test("/api/raw fails the body instead of sending fewer bytes than content-length when the file shrinks", async () => { + const p = path.join(dir, "shrink.bin"); + await writeFile(p, new Uint8Array(2 * 1024 * 1024)); + const r = await get("/api/raw?path=shrink.bin"); + expect(r.status).toBe(200); + const reader = r.body!.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + await truncate(p, 10); + // The transfer fails (the stream is errored): never a quiet success with a truncated body. + let failed = false; + try { + for (let c = await reader.read(); !c.done; c = await reader.read()); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect((await get("/api/info")).status).toBe(200); +}); diff --git a/test/http.security.test.ts b/test/http.security.test.ts index 7fedad5..705ec69 100644 --- a/test/http.security.test.ts +++ b/test/http.security.test.ts @@ -78,3 +78,24 @@ test("FIFOs and other special files are refused instead of hanging the server", expect(performance.now() - t0).toBeLessThan(2000); expect(await (await fetch(`${base}/api/file?path=a.txt`, { headers: auth })).text()).toBe("hello"); }); + +test("the terminal token only opens files: /api/open accepts it, everything else rejects it", async () => { + const open = (t: string) => + fetch(`${base}/api/open`, { method: "POST", headers: { "x-cv-token": t, "content-type": "application/json" }, body: JSON.stringify({ path: "a.txt" }) }); + expect(app.openToken).not.toBe(token); + expect(app.openToken.length).toBeGreaterThanOrEqual(40); + expect((await open(app.openToken)).status).toBe(200); + expect((await open(token)).status).toBe(200); + expect((await open("nope")).status).toBe(401); + expect((await fetch(`${base}/api/info`, { headers: { "x-cv-token": app.openToken } })).status).toBe(401); + expect((await fetch(`${base}/api/file?path=a.txt`, { headers: { "x-cv-token": app.openToken } })).status).toBe(401); + const refused = await new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/events?t=${app.openToken}`, { headers: { origin: base } } as never); + ws.onopen = () => { + ws.close(); + resolve("open"); + }; + ws.onerror = () => resolve("refused"); + }); + expect(refused).toBe("refused"); +}); diff --git a/test/index.coverage.test.ts b/test/index.coverage.test.ts index 0fae1a7..6078556 100644 --- a/test/index.coverage.test.ts +++ b/test/index.coverage.test.ts @@ -138,3 +138,30 @@ test("a new file in a freshly added directory is found after dirs cache refresh" await ix.apply(["src/c.ts"]); expect(await ix.apply(["src/deep"])).toBe(false); }); + +test("symlinked directories inside the root are walked; links out of the root and cycles are not", async () => { + const { symlink } = await import("node:fs/promises"); + const outside = await realpath(await mkdtemp(path.join(tmpdir(), "cv-index-out-"))); + try { + await writeFile(path.join(outside, "secret.txt"), ""); + await symlink(path.join(root, "src", "deep"), path.join(root, "linked")); + await symlink(outside, path.join(root, "out")); + await symlink(path.join(outside, "secret.txt"), path.join(root, "outfile")); + await symlink(root, path.join(root, "src", "loop")); // a link to an ancestor + await symlink(path.join(root, "top.txt"), path.join(root, "src", "toplink")); + const ix = new FileIndex(root); + const files = await list(ix); + expect(files).toContain("linked/b.ts"); + expect(files).toContain("src/toplink"); + expect(files).toContain("src/deep/b.ts"); + expect(files.some((f) => f.startsWith("out"))).toBe(false); + expect(files.some((f) => f.startsWith("src/loop"))).toBe(false); + // A symlinked directory is a known directory: events inside it are churn, not a rebuild. + expect(await ix.apply(["linked"])).toBe(false); + // A link out of the root is never a reason to rescan. + expect(await ix.apply(["out"])).toBe(false); + expect(await list(ix)).toEqual(files); + } finally { + await rm(outside, { recursive: true, force: true }); + } +}); diff --git a/test/markdown.limits.test.ts b/test/markdown.limits.test.ts index e4ae020..bfceef3 100644 --- a/test/markdown.limits.test.ts +++ b/test/markdown.limits.test.ts @@ -38,6 +38,8 @@ const corpus: Record string> = { "many bracket lines": (n) => "[a](\n".repeat(Math.floor(n / 10)), "many quote lines": (n) => "> x\n".repeat(n), "many list lines": (n) => "- x\n".repeat(n), + "lazy sibling markers after quotes": (n) => "> 1. a\n2. b\n".repeat(Math.floor(n / 10)), + "lazy lines after setext headings in quotes": (n) => "> a\n> ===\nb\n".repeat(Math.floor(n / 10)), }; describe("AC-9 adversarial inputs", () => { diff --git a/test/markdown.parse.test.ts b/test/markdown.parse.test.ts index dbaf634..e766957 100644 --- a/test/markdown.parse.test.ts +++ b/test/markdown.parse.test.ts @@ -23,3 +23,20 @@ describe("parseMarkdown headings and paragraphs (fixtures)", () => { }); } }); + +describe("lists: an empty item followed by a blank line", () => { + test("stays one (loose) list", () => { + const blocks = M.parseMarkdown("- a\n-\n\n- c") as { type: string; tight?: boolean; items?: unknown[] }[]; + expect(blocks.map((b) => b.type)).toEqual(["list"]); + expect(blocks[0]!.items!.length).toBe(3); + expect(blocks[0]!.tight).toBe(false); + const ordered = M.parseMarkdown("1. a\n2.\n\n3. c") as { type: string; start?: number; items?: unknown[] }[]; + expect(ordered.map((b) => b.type)).toEqual(["list"]); + expect(ordered[0]!.items!.length).toBe(3); + expect(ordered[0]!.start).toBe(1); + }); + test("an empty item never takes content after the blank line", () => { + const blocks = M.parseMarkdown("-\n\n foo") as { type: string }[]; + expect(blocks.map((b) => b.type)).toEqual(["list", "paragraph"]); + }); +}); diff --git a/test/pty.coverage.test.ts b/test/pty.coverage.test.ts index 4ffafda..56e11f9 100644 --- a/test/pty.coverage.test.ts +++ b/test/pty.coverage.test.ts @@ -194,3 +194,28 @@ test("a lagging client stops receiving live data and is resynced once its socket m.drain(asWs(a)); // not lagging anymore: no-op expect(a.texts.filter((t) => t.includes("replay")).length).toBe(1); }); + +test("an exit with nobody attached is kept: the next attach gets replay + exit notice, then the id is free", async () => { + const m = await manager(); + const a = fakeWs("s8"); + m.open(asWs(a)); + m.message(asWs(a), Buffer.from("echo READY\n")); + await until(() => a.output().includes("READY")); + m.message(asWs(a), Buffer.from("echo LAST; exit 5\n")); + m.close(asWs(a)); // detached before the shell can exit + expect(a.closed).toBeNull(); + await Bun.sleep(400); + const b = fakeWs("s8"); + m.open(asWs(b)); + expect(b.texts[0]).toBe(JSON.stringify({ t: "replay" })); + expect(b.texts).toContain(JSON.stringify({ t: "exit", code: 5 })); + expect(b.closed).toEqual([1000, "exited"]); + expect(b.output()).toContain("LAST"); + // the socket that left never hears about it + expect(a.texts.some((t) => t.includes('"exit"'))).toBe(false); + const c = fakeWs("s8"); + m.open(asWs(c)); + m.message(asWs(c), Buffer.from("echo FRESH\n")); + await until(() => c.output().includes("FRESH")); + expect(c.texts.some((t) => t.includes('"exit"'))).toBe(false); +}); diff --git a/test/pty.test.ts b/test/pty.test.ts index 3c99cf9..94c2884 100644 --- a/test/pty.test.ts +++ b/test/pty.test.ts @@ -46,12 +46,24 @@ function connect(id: string) { return { ws, term, opened, send, frames: () => frames }; } +/** + * Waits for a line that is `word` on its own or after a shell prompt: a command sent before the + * shell printed its first prompt is echoed by the tty first, so the prompt lands in front of the + * output ("# READY") rather than before the command. The echoed command line never matches. + */ +async function line(term: Terminal, word: string): Promise { + const re = new RegExp(`^(\\S*[#$%>] )?${word}$`); + const find = () => term.lines.map((l) => l.toString()).find((l) => re.test(l)); + for (let i = 0; i < 100 && !find(); i++) await Bun.sleep(100); + return find(); +} + test("a reload restores alt screen, bracketed paste and mouse modes even after history was trimmed", async () => { const a = connect("modes-test-1"); await a.opened; // Enter the modes, then push far more than the 512 KB replay window. a.send(`printf '\\033[?2004h\\033[?1049h\\033[?1000h'; i=0; while [ $i -lt 9000 ]; do echo "line $i ............................................................"; i=$((i+1)); done; echo DONE\n`); - for (let i = 0; i < 100 && !a.term.lines.some((l) => l.toString() === "DONE"); i++) await Bun.sleep(100); + expect(await line(a.term, "DONE")).toBeDefined(); expect(a.term.modes.bracketedPaste).toBe(true); a.ws.close(); await Bun.sleep(200); @@ -62,7 +74,7 @@ test("a reload restores alt screen, bracketed paste and mouse modes even after h expect(b.term.alt).toBe(true); expect(b.term.modes.bracketedPaste).toBe(true); expect(b.term.modes.mouse).toBe(1000); - expect(b.term.lines.some((l) => l.toString() === "DONE")).toBe(true); + expect(await line(b.term, "DONE")).toBeDefined(); b.send("exit\n"); b.ws.close(); }, 30_000); @@ -71,9 +83,62 @@ test("output is coalesced into few frames", async () => { const c = connect("coalesce-test-1"); await c.opened; c.send(`i=0; while [ $i -lt 20000 ]; do echo y; i=$((i+1)); done; echo END\n`); - for (let i = 0; i < 100 && !c.term.lines.some((l) => l.toString() === "END"); i++) await Bun.sleep(100); + expect(await line(c.term, "END")).toBeDefined(); // 20k tiny writes must not become 20k WebSocket messages. expect(c.frames()).toBeLessThan(2000); c.send("exit\n"); c.ws.close(); }, 30_000); + +test("a shell that exits while nobody is attached still delivers its last output and exit code on reattach", async () => { + const a = connect("exit-while-detached-1"); + await a.opened; + a.send("echo READY\n"); + expect(await line(a.term, "READY")).toBeDefined(); + // The exit lands after the socket is gone (a reload in the middle of it). + a.send("echo LAST; sleep 0.2; exit 7\n"); + a.ws.close(); + await Bun.sleep(300); + + const b = connect("exit-while-detached-1"); + const texts: string[] = []; + const closed = new Promise((r) => (b.ws.onclose = r)); + const inner = b.ws.onmessage!; + b.ws.onmessage = (e) => { + if (typeof e.data === "string") texts.push(e.data); + inner.call(b.ws, e); + }; + await b.opened; + const ev = await closed; + expect(ev.code).toBe(1000); + expect(texts[0]).toBe(JSON.stringify({ t: "replay" })); + expect(texts).toContain(JSON.stringify({ t: "exit", code: 7 })); + expect(await line(b.term, "LAST")).toBeDefined(); + + // After that the id is free: a new attach starts a fresh shell with no stale exit notice. + const c = connect("exit-while-detached-1"); + const cTexts: string[] = []; + const cInner = c.ws.onmessage!; + c.ws.onmessage = (e) => { + if (typeof e.data === "string") cTexts.push(e.data); + cInner.call(c.ws, e); + }; + await c.opened; + c.send("echo FRESH\n"); + expect(await line(c.term, "FRESH")).toBeDefined(); + expect(cTexts.some((t) => t.includes('"exit"'))).toBe(false); + c.send("exit\n"); + c.ws.close(); +}, 30_000); + +test("terminals get the narrow open token, never the session token", async () => { + const a = connect("env-token-1"); + await a.opened; + a.send("echo TOK=$CODEVIEWER_TOKEN\n"); + const tok = await line(a.term, "TOK=[^$]+"); + expect(tok).toBeDefined(); + expect(tok).toEndWith(`TOK=${app.openToken}`); + expect(tok).not.toContain(token); + a.send("exit\n"); + a.ws.close(); +}, 30_000); diff --git a/test/search.candidates.test.ts b/test/search.candidates.test.ts index 45b06bb..8349cd0 100644 --- a/test/search.candidates.test.ts +++ b/test/search.candidates.test.ts @@ -112,6 +112,15 @@ describe("parseGlobs / filterByGlobs (AC-13, AC-26)", () => { expect(S.filterByGlobs(all, ["src/**/*.ts"], [])).toEqual(["src/a.ts", "src/deep/b.ts"]); expect(S.filterByGlobs(all, ["src/**/*.ts", "docs/**"], [])).toEqual(["src/a.ts", "src/deep/b.ts", "docs/readme.md"]); }); + test("editor-style patterns: no slash matches at any depth, a bare name is a folder, a trailing slash means contents", () => { + expect(S.filterByGlobs(all, ["*.ts"], [])).toEqual(["src/a.ts", "src/deep/b.ts"]); + expect(S.filterByGlobs(all, ["src"], [])).toEqual(["src/a.ts", "src/deep/b.ts", "src/c.md"]); + expect(S.filterByGlobs(all, ["deep"], [])).toEqual(["src/deep/b.ts"]); + expect(S.filterByGlobs(all, ["src/"], [])).toEqual(["src/a.ts", "src/deep/b.ts", "src/c.md"]); + expect(S.filterByGlobs(all, ["a.txt"], [])).toEqual(["a.txt"]); + expect(S.filterByGlobs(all, [], ["*.md"])).toEqual(["a.txt", "src/a.ts", "src/deep/b.ts"]); + expect(S.filterByGlobs(all, [], ["deep"])).toEqual(["a.txt", "src/a.ts", "src/c.md", "docs/readme.md"]); + }); test("exclude removes matching paths and wins over include", () => { expect(S.filterByGlobs(all, [], ["src/**"])).toEqual(["a.txt", "docs/readme.md"]); expect(S.filterByGlobs(all, ["src/**"], ["**/*.md"])).toEqual(["src/a.ts", "src/deep/b.ts"]); diff --git a/test/search.containment.test.ts b/test/search.containment.test.ts index f2a81cd..114b6c5 100644 --- a/test/search.containment.test.ts +++ b/test/search.containment.test.ts @@ -27,12 +27,14 @@ afterAll(async () => { }); describe("escaping candidates are counted in skipped (AC-7)", () => { - test("HTTP: ok.txt matches; the file symlink and the dir symlink count exactly 2 skipped", async () => { + test("HTTP: ok.txt matches; the file symlink and the dir symlink are never candidates (the index drops escaping links)", async () => { const r = await fx.search({ q: "needle" }); expect(r.status).toBe(200); expect(files(r.events).map((f) => f.path)).toEqual(["ok.txt"]); - expect(done(r.events)).toMatchObject({ files: 1, matches: 1, skipped: 2, truncated: false }); + expect(done(r.events)).toMatchObject({ files: 1, matches: 1, skipped: 0, truncated: false }); expect(r.text).not.toContain("OUTSIDE-SECRET"); + expect(r.text).not.toContain("link-file"); + expect(r.text).not.toContain("linkdir"); }); test("worker: a file under a symlinked dir and a symlinked file are each skipped, never read", async () => { @@ -46,7 +48,8 @@ describe("escaping candidates are counted in skipped (AC-7)", () => { ); clearTimeout(timer); const d = events.find((e) => e.t === "done") as Extract; - expect(d).toMatchObject({ files: 1, matches: 1, skipped: 3, truncated: false }); + // The two symlink escapes are skipped; a candidate deleted since it was listed is simply gone. + expect(d).toMatchObject({ files: 1, matches: 1, skipped: 2, truncated: false }); expect(JSON.stringify(events)).not.toContain("OUTSIDE-SECRET"); }); }); diff --git a/test/search.supersede.test.ts b/test/search.supersede.test.ts new file mode 100644 index 0000000..5e1f6d8 --- /dev/null +++ b/test/search.supersede.test.ts @@ -0,0 +1,59 @@ +import { afterAll, expect, test } from "bun:test"; +import { makeServer, type Fx } from "./helpers/searchsrv.ts"; + +const fixtures: Fx[] = []; +afterAll(async () => { + for (const f of fixtures) await f.stop(); +}); + +/** A scanner that emits one hit (unless q is "idle") and then waits to be cancelled, unless q is "fast". */ +async function server() { + const f = await makeServer(async () => {}, { + searchScanner: async (req, signal, emit) => { + if (req.q !== "idle") emit({ t: "file", path: "a.txt", matches: [{ line: 1, col: 0, len: 4, preview: "slow", pcol: 0 }] }); + if (req.q === "fast") return emit({ t: "done", files: 1, matches: 1, skipped: 0, truncated: false, ms: 1 }); + await new Promise((r) => (signal.aborted ? r() : signal.addEventListener("abort", () => r(), { once: true }))); + }, + }); + fixtures.push(f); + return f; +} + +test("a search cancelled by a newer one still ends its stream with a flagged done line", async () => { + const f = await server(); + const first = f.search({ q: "slow" }, 5000); + await Bun.sleep(150); // the first search has produced output + const second = await f.search({ q: "fast" }, 5000); + expect(second.events.at(-1)).toMatchObject({ t: "done", files: 1 }); + expect((second.events.at(-1) as { error?: unknown }).error).toBeUndefined(); + const r = await first; + expect(r.status).toBe(200); + expect(r.events[0]!.t).toBe("file"); + expect(r.events.filter((e) => e.t === "done").length).toBe(1); + expect(r.events.at(-1)).toMatchObject({ t: "done", files: 1, matches: 1, skipped: 0, truncated: false, error: true }); + expect(typeof (r.events.at(-1) as { ms: unknown }).ms).toBe("number"); +}); + +test("a superseded search that produced nothing yet ends cleanly, with no done line", async () => { + const f = await server(); + const first = f.search({ q: "idle" }, 5000); + await Bun.sleep(150); + await f.search({ q: "fast" }, 5000); + const r = await first; + expect(r.status).toBe(200); + expect(r.events).toEqual([]); +}); + +test("a client abort does not get a done line appended", async () => { + const f = await server(); + const ac = new AbortController(); + const r = await f.raw({ q: "slow" }, { signal: ac.signal }); + const reader = r.body!.getReader(); + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toContain('"file"'); + ac.abort(); + await Bun.sleep(100); + // The server is free for the next search. + const again = await f.search({ q: "fast" }, 5000); + expect(again.events.at(-1)!.t).toBe("done"); +}); diff --git a/test/servergit.coverage.test.ts b/test/servergit.coverage.test.ts index df0dbe1..7042fc2 100644 --- a/test/servergit.coverage.test.ts +++ b/test/servergit.coverage.test.ts @@ -246,3 +246,72 @@ test("a corrupt repo yields a status with no file entries rather than throwing", await rename(path.join(gd, "HEAD.bak"), path.join(gd, "HEAD")); } }); + +test("a linked worktree does not report the main worktree's merge, and a nested repo is not an untracked file", async () => { + const wt = path.join(dir, "wt"); + sh(["worktree", "add", "-q", "-b", "wt-branch", wt]); + try { + const gitDir = sh(["rev-parse", "--absolute-git-dir"]).trim(); + await writeFile(path.join(gitDir, "MERGE_HEAD"), sh(["rev-parse", "HEAD"])); + try { + expect((await git()).status().then((s) => s.state)).resolves.toBe("merging"); + expect((await git(wt)).status().then((s) => s.state)).resolves.toBe(null); + } finally { + await rm(path.join(gitDir, "MERGE_HEAD"), { force: true }); + } + await mkdir(path.join(wt, "inner")); + sh(["init", "-q"], path.join(wt, "inner")); + await writeFile(path.join(wt, "inner", "x.txt"), "x\n"); + const c = await (await git(wt)).changes("head"); + expect(c.files.map((f) => f.path)).not.toContain("inner/"); + } finally { + sh(["worktree", "remove", "--force", wt]); + } +}); + +test("status: a viewed subfolder that is itself ignored is reported as rootIgnored", async () => { + await mkdir(path.join(repo, "ignoreddir")); + await writeFile(path.join(repo, "ignoreddir", "x.txt"), "x"); + const { readFile } = await import("node:fs/promises"); + const prior = await readFile(path.join(repo, ".gitignore"), "utf8").catch(() => null); + await writeFile(path.join(repo, ".gitignore"), `${prior ?? ""}ignoreddir/\n`); + try { + const sub = await git(path.join(repo, "ignoreddir")); + const s = await sub.status(); + expect(s.rootIgnored).toBe(true); + expect(s.files).toEqual({}); + const top = await (await git()).status(); + expect(top.rootIgnored).toBe(false); + expect(top.files["ignoreddir/"]).toBe("!!"); + const plain = await (await git(path.join(repo, "sub"))).status(); + expect(plain.rootIgnored).toBe(false); + } finally { + if (prior === null) await rm(path.join(repo, ".gitignore"), { force: true }); + else await writeFile(path.join(repo, ".gitignore"), prior); + await rm(path.join(repo, "ignoreddir"), { recursive: true, force: true }); + } +}); + +test("detectBase: on main, the upstream origin/main wins over a stale local master", async () => { + const r = path.join(dir, "onmain"); + await mkdir(r); + sh(["init", "-q", "-b", "main"], r); + await writeFile(path.join(r, "f.txt"), "1\n"); + sh(["add", "."], r); + sh(["-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-qm", "one"], r); + sh(["branch", "master"], r); // stale: stays on the first commit + await writeFile(path.join(r, "f.txt"), "1\n2\n"); + sh(["-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-qam", "two"], r); + const remote = path.join(dir, "onmain-remote.git"); + sh(["init", "-q", "--bare", "-b", "main", remote], dir); + sh(["remote", "add", "origin", remote], r); + sh(["push", "-q", "-u", "origin", "main"], r); // no origin/HEAD: `remote add` + push never sets it + expect(Bun.spawnSync(["git", "symbolic-ref", "-q", "refs/remotes/origin/HEAD"], { cwd: r }).exitCode).not.toBe(0); + const g = await git(r); + const c = await g.changes("branch"); + expect(c.base?.name).toBe("origin/main"); + expect(c.base?.same).toBe(true); + // Off main, the local branches come first again. + sh(["checkout", "-qb", "topic"], r); + expect((await g.changes("branch")).base?.name).toBe("main"); +}); diff --git a/test/terminal.test.ts b/test/terminal.test.ts index a2c80a7..c2c4a98 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { Terminal, BOLD, INVERSE, WIDE, RGB_FLAG, DEFAULT } from "../src/client/term/terminal.ts"; +import { linkTitle } from "../src/client/term/view.ts"; function mk(cols = 20, rows = 5) { const replies: string[] = []; @@ -280,3 +281,150 @@ describe("terminal emulator", () => { expect(copies).toEqual(["new"]); }); }); + +describe("wide characters under ICH/DCH, ZWJ joins, DECSTR and OSC strings", () => { + const cells = (t: Terminal, y: number) => t.lines[y]!.text.map((c, i) => (t.lines[y]!.fl[i]! & WIDE ? c + "W" : c)); + + test("DCH from the second half of a wide character blanks the character (xterm)", () => { + const { t } = mk(10, 2); + t.write("ab漢cd\x1b[1;4H\x1b[P"); + expect(cells(t, 0).slice(0, 6)).toEqual(["a", "b", " ", "c", "d", " "]); + }); + + test("ICH from the second half of a wide character blanks it; a wide character pushed onto the last column is dropped", () => { + const { t } = mk(10, 2); + t.write("ab漢cd\x1b[1;4H\x1b[@"); + expect(cells(t, 0).slice(0, 7)).toEqual(["a", "b", " ", " ", " ", "c", "d"]); + const { t: u } = mk(5, 2); + u.write("abc漢\x1b[1;1H\x1b[@"); + expect(cells(u, 0)).toEqual([" ", "a", "b", "c", " "]); + expect(u.lines[0]!.fl[4]! & WIDE).toBe(0); + // Shifting by two lands the lead half on the last column, the trailing half past the edge. + const { t: v } = mk(5, 2); + v.write("ab漢e\x1b[1;1H\x1b[2@"); + expect(cells(v, 0)).toEqual([" ", " ", "a", "b", " "]); + }); + + test("DCH of several cells from the second half of a wide character blanks it once and shifts the rest", () => { + const { t } = mk(10, 2); + t.write("ab漢cd\x1b[1;4H\x1b[2P"); + expect(cells(t, 0).slice(0, 5)).toEqual(["a", "b", " ", "d", " "]); + expect(t.lines[0]!.toString()).toBe("ab d"); + }); + + test("resize keeps explicit tab stops and seeds default stops only in the new columns", () => { + const { t } = mk(20, 3); + t.write("\x1b[3g\x1b[1;5H\x1bH\x1b[H"); // clear all stops, set one at column 4, home + t.resize(30, 3); + t.write("\t"); + expect(t.cur.x).toBe(4); + t.write("A\t"); + expect(t.cur.x).toBe(24); // 8 and 16 stay cleared; 24 is new ground and gets a default stop + t.write("\t"); + expect(t.cur.x).toBe(29); + }); + + test("a glyph after a ZWJ joins the cluster only while the cursor is still there", () => { + const { t, row } = mk(20, 5); + t.write("👩\u200d\x1b[2;10HX"); + expect(row(1)).toBe(" X"); + expect([t.cur.x, t.cur.y]).toEqual([10, 1]); + const { t: u } = mk(20, 2); + u.write("👩\u200d👧"); + expect(u.lines[0]!.text[0]).toBe("👩\u200d👧"); + expect(u.cur.x).toBe(2); + }); + + test("DECSTR resets the charsets and the saved cursor", () => { + const { t, row } = mk(10, 2); + t.write("123\x1b7\x1b(0q\x1b[!pq"); + expect(row(0)).toBe("123\u2500q"); // the q after the soft reset is ASCII again + t.write("\x1b8X"); // the saved cursor is gone: DECRC homes instead of returning to column 3 + expect(row(0)).toBe("X23\u2500q"); + }); + + test("C0 controls inside an OSC string are ignored", () => { + let title = ""; + const t = new Terminal(20, 2, { reply: () => {}, title: (s) => (title = s) }); + t.write("\x1b]0;ab\ncd\x07"); + expect(title).toBe("abcd"); + expect(t.lines[1]!.toString()).toBe(""); + }); +}); + +describe("completed lines for assistive technology (onLine)", () => { + const mkLog = (cols = 10, rows = 3) => { + const lines: string[] = []; + const t = new Terminal(cols, rows, { reply: () => {}, onLine: (s) => lines.push(s) }); + return { t, lines }; + }; + + test("reports each line once, in order, when a line feed leaves it or it scrolls into history", () => { + const { t, lines } = mkLog(); + t.write("one\r\ntwo\r\n"); + expect(lines).toEqual(["one", "two"]); + t.write("three\r\nfour\r\nfive"); // "one" and "two" scroll off: not repeated + expect(lines).toEqual(["one", "two", "three", "four"]); + t.write("\x1b[S"); // "three" scrolls into history without a line feed past it: already reported + expect(lines).toEqual(["one", "two", "three", "four"]); + t.write("\x1b[2S"); // "five" was never passed by a line feed: reported as it leaves the screen + expect(lines).toEqual(["one", "two", "three", "four", "five"]); + }); + + test("blank lines are skipped and a rewritten line is reported again", () => { + const { t, lines } = mkLog(); + t.write("\r\n\r\nx\r\n"); + expect(lines).toEqual(["x"]); + t.write("\x1b[A\rxy\r\n"); // back up, overwrite, line feed again + expect(lines).toEqual(["x", "xy"]); + }); + + test("soft-wrapped output reports each screen line", () => { + const { t, lines } = mkLog(5, 3); + t.write("abcdefgh\r\n"); + expect(lines).toEqual(["abcde", "fgh"]); + }); + + test("nothing is reported during a silent replay, nor later for lines the replay completed", () => { + const { t, lines } = mkLog(); + t.silent = true; + t.write("old1\r\nold2\r\n$ "); + t.silent = false; + expect(lines).toEqual([]); + // The prompt line was still open when the replay ended: finishing it live is new output. + t.write("ls\r\nnew\r\n\r\n\r\n"); // scrolls the replayed lines into history + expect(lines).toEqual(["$ ls", "new"]); + }); + + test("the alternate screen is not reported", () => { + const { t, lines } = mkLog(); + t.write("\x1b[?1049happ\r\nmenu\r\n\x1b[?1049l"); + expect(lines).toEqual([]); + t.write("shell\r\n"); + expect(lines).toEqual(["shell"]); + }); + + test("the hook costs nothing when the host does not subscribe", () => { + const t = new Terminal(10, 2, { reply: () => {} }); + t.write("a\r\nb\r\nc\r\n"); + expect(t.scrollback.length).toBe(2); + }); +}); + +describe("hyperlink tooltip (OSC 8 label vs target)", () => { + test("plain labels show the target", () => { + expect(linkTitle("README.md", "https://evil.example/x")).toBe("https://evil.example/x"); + expect(linkTitle("docs", "file:///tmp/a.txt")).toBe("file:///tmp/a.txt"); + }); + + test("a label that looks like a URL of another host is flagged", () => { + expect(linkTitle("https://github.com/a/b", "https://evil.example/x")).toBe("Link target: https://evil.example/x"); + expect(linkTitle("www.github.com", "https://evil.example/")).toBe("Link target: https://evil.example/"); + expect(linkTitle("https://[bad", "https://evil.example/")).toBe("Link target: https://evil.example/"); + }); + + test("a label that names the same host is not flagged", () => { + expect(linkTitle("https://github.com/a/b", "https://github.com/a/b/tree/main")).toBe("https://github.com/a/b/tree/main"); + expect(linkTitle("www.GitHub.com/x", "https://www.github.com/x")).toBe("https://www.github.com/x"); + }); +}); diff --git a/test/update.coverage.test.ts b/test/update.coverage.test.ts index b006a1e..13c5754 100644 --- a/test/update.coverage.test.ts +++ b/test/update.coverage.test.ts @@ -77,7 +77,7 @@ async function cli(r: Rel, version = "1.0.0") { await chmod(target, 0o755); const install: Install = { kind: "cli", target, asset: (v) => `codeviewer-${v}-linux-x64` }; const g = fake({ files: { "codeviewer-1.1.0-linux-x64": enc("new") }, ...r }); - return { dir, target, calls: g.calls, u: new Updater({ version, fetch: g.f, publicKey: keyPem, install }) }; + return { dir, target, calls: g.calls, u: new Updater({ version, fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install }) }; } test("compareVersions: pre-release ordering and padding", () => { @@ -158,14 +158,14 @@ test("check: explains why an update cannot be installed", async () => { const g = fake({ files: { "codeviewer-1.1.0-linux-x64": enc("new") } }); const dir = await tmp(); const install: Install = { kind: "cli", target: path.join(dir, "cv"), asset: (v) => `codeviewer-${v}-linux-x64` }; - expect((await new Updater({ version: "1.0.0", fetch: g.f, publicKey: "# none", install }).check(true)).reason).toContain("no update signing key"); + expect((await new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: "# none", install }).check(true)).reason).toContain("no update signing key"); // unwritable / missing target directory const bad: Install = { kind: "cli", target: path.join(dir, "missing", "cv"), asset: install.asset }; - const info = await new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install: bad }).check(true); + const info = await new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install: bad }).check(true); expect(info).toMatchObject({ available: true, canInstall: false }); expect(info.reason).toContain("No permission"); // source installs - const src = await new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install: { kind: "source", target: "", asset: () => "" } }).check(true); + const src = await new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install: { kind: "source", target: "", asset: () => "" } }).check(true); expect(src.reason).toContain("git pull"); }); @@ -204,7 +204,7 @@ test("apply rejects a signed file name that doesn't carry the release version", const g = fake({ files: { "codeviewer-linux-x64": enc("new") } }); // asset name has no version in it: a mix-up/rollback attempt const install: Install = { kind: "cli", target, asset: () => "codeviewer-linux-x64" }; - await expect(new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install }).apply()).rejects.toThrow("doesn't match the release version"); + await expect(new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install }).apply()).rejects.toThrow("doesn't match the release version"); expect(await readFile(target, "utf8")).toBe("old"); }); @@ -224,7 +224,7 @@ test("apply fails when SHA256SUMS can't be fetched", async () => { const wrapped = (async (u: string | URL | Request, i?: RequestInit) => String(u).endsWith("/SHA256SUMS") ? new Response("", { status: 404 }) : g.f(u, i)) as typeof fetch; const install: Install = { kind: "cli", target, asset: (v) => `codeviewer-${v}-linux-x64` }; - await expect(new Updater({ version: "1.0.0", fetch: wrapped, publicKey: keyPem, install }).apply()).rejects.toThrow("download failed (404)"); + await expect(new Updater({ version: "1.0.0", fetch: wrapped, assetHosts: ["example.test"], publicKey: keyPem, install }).apply()).rejects.toThrow("download failed (404)"); }); test("apply refuses to run concurrently", async () => { @@ -253,10 +253,10 @@ test("linux package: bad layout is rejected and the install is untouched; good l }; const name = "codeviewer-1.1.0-linux-x64.tar.gz"; let g = fake({ files: { [name]: await pack(false) } }); - await expect(new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install }).apply()).rejects.toThrow("unexpected package layout"); + await expect(new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install }).apply()).rejects.toThrow("unexpected package layout"); expect(await readFile(path.join(target, "codeviewer"), "utf8")).toBe("old"); g = fake({ files: { [name]: await pack(true) } }); - await new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install }).apply(); + await new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install }).apply(); expect(await readFile(path.join(target, "codeviewer"), "utf8")).toBe("new"); }); @@ -267,5 +267,19 @@ test("linux package: a corrupt archive fails with the tool's message", async () const name = "codeviewer-1.1.0-linux-x64.tar.gz"; const install: Install = { kind: "linux-app", target, asset: (v) => `codeviewer-${v}-linux-x64.tar.gz` }; const g = fake({ files: { [name]: enc("not a tarball") } }); - await expect(new Updater({ version: "1.0.0", fetch: g.f, publicKey: keyPem, install }).apply()).rejects.toThrow(/tar failed/); + await expect(new Updater({ version: "1.0.0", fetch: g.f, assetHosts: ["example.test"], publicKey: keyPem, install }).apply()).rejects.toThrow(/tar failed/); +}); + +test("compareVersions: pre-release identifiers follow semver (numeric, then alphanumeric, then length)", () => { + expect(compareVersions("1.0.0-rc.10", "1.0.0-rc.9")).toBe(1); + expect(compareVersions("1.0.0-rc.9", "1.0.0-rc.10")).toBe(-1); + expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBe(-1); + expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBe(-1); // numeric < alphanumeric + expect(compareVersions("1.0.0-alpha.beta", "1.0.0-beta")).toBe(-1); + expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.11")).toBe(-1); + expect(compareVersions("1.0.0-beta.11", "1.0.0-rc.1")).toBe(-1); + expect(compareVersions("1.0.0-rc.1", "1.0.0")).toBe(-1); + expect(compareVersions("1.0.0-rc.1+build.5", "1.0.0-rc.1+build.9")).toBe(0); // build metadata never orders + expect(compareVersions("1.0.0+abc", "1.0.0")).toBe(0); + expect(compareVersions("v1.0.0-rc.2", "1.0.0-rc.2")).toBe(0); }); diff --git a/test/update.test.ts b/test/update.test.ts index bdf6a2f..a2551fd 100644 --- a/test/update.test.ts +++ b/test/update.test.ts @@ -109,7 +109,7 @@ describe("CLI binary updates", () => { const install: Install = { kind: "cli", target, asset: (v) => `codeviewer-${v}-linux-x64` }; const version = release.version ?? "1.1.0"; const fetchFn = await fakeGitHub({ version, files: { [`codeviewer-${version}-linux-x64`]: new TextEncoder().encode("new binary") }, ...release }); - return { dir, target, updater: new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install }) }; + return { dir, target, updater: new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: keyPem, install }) }; } test("installs a verified update, reporting every phase", async () => { @@ -124,6 +124,18 @@ describe("CLI binary updates", () => { expect((await readdir(dir)).filter((n) => n.startsWith(".codeviewer-update"))).toEqual([]); }); + test("downloads only from the release hosts, over https", async () => { + const dir = await tmp(); + const target = path.join(dir, "codeviewer"); + await writeFile(target, "old binary"); + const install: Install = { kind: "cli", target, asset: (v) => `codeviewer-${v}-linux-x64` }; + const fetchFn = await fakeGitHub({ version: "1.1.0", files: { "codeviewer-1.1.0-linux-x64": new TextEncoder().encode("new binary") } }); + // The API answer names assets on example.test; by default only GitHub's hosts are trusted. + const u = new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install }); + await expect(u.apply()).rejects.toThrow(/refusing to download from example.test/); + expect(await readFile(target, "utf8")).toBe("old binary"); + }); + test("rejects a checksum list signed by another key", async () => { const { target, updater } = await setup({ signWith: otherKey }); await expect(updater.apply()).rejects.toThrow(/signature is invalid/); @@ -157,13 +169,13 @@ describe("CLI binary updates", () => { const { target } = await setup(); const install: Install = { kind: "cli", target, asset: (v) => `codeviewer-${v}-linux-x64` }; const fetchFn = await fakeGitHub({ version: "1.1.0", files: { "codeviewer-1.1.0-linux-x64": new Uint8Array([1]) } }); - const u = new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: "# placeholder", install }); + const u = new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: "# placeholder", install }); expect((await u.check(true)).reason).toMatch(/no update signing key/); }); test("running from source is never auto-updated", async () => { const fetchFn = await fakeGitHub({ version: "1.1.0", files: {} }); - const u = new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install: { kind: "source", target: "", asset: () => "" } }); + const u = new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: keyPem, install: { kind: "source", target: "", asset: () => "" } }); expect((await u.check(true)).reason).toMatch(/git pull/); }); }); @@ -185,7 +197,7 @@ describe("Linux package updates", () => { const install: Install = { kind: "linux-app", target, asset: (v) => `codeviewer-${v}-linux-x64.tar.gz` }; const fetchFn = await fakeGitHub({ version: "1.1.0", files: { "codeviewer-1.1.0-linux-x64.tar.gz": new Uint8Array(await Bun.file(tgz).arrayBuffer()) } }); - await new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install }).apply(); + await new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: keyPem, install }).apply(); expect(await readFile(path.join(target, "codeviewer"), "utf8")).toBe("new"); expect(await readFile(path.join(target, "chromium", "chrome"), "utf8")).toBe("new chrome"); }); @@ -219,7 +231,7 @@ describe.if(process.platform === "darwin")("macOS app updates", () => { const target = await installed("old"); const install: Install = { kind: "macos-app", target, asset: (v) => `Codeviewer-${v}-macos-arm64.dmg` }; const fetchFn = await fakeGitHub({ version: "1.1.0", files: { "Codeviewer-1.1.0-macos-arm64.dmg": await makeDmg("new") } }); - await new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install }).apply(); + await new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: keyPem, install }).apply(); expect(await readFile(path.join(target, "Contents", "MacOS", "Codeviewer"), "utf8")).toContain("echo new"); }, 60_000); @@ -227,7 +239,7 @@ describe.if(process.platform === "darwin")("macOS app updates", () => { const target = await installed("old"); const install: Install = { kind: "macos-app", target, asset: (v) => `Codeviewer-${v}-macos-arm64.dmg` }; const fetchFn = await fakeGitHub({ version: "1.1.0", files: { "Codeviewer-1.1.0-macos-arm64.dmg": await makeDmg("new", true) } }); - await expect(new Updater({ version: "1.0.0", fetch: fetchFn, publicKey: keyPem, install }).apply()).rejects.toThrow(/codesign/); + await expect(new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: keyPem, install }).apply()).rejects.toThrow(/codesign/); expect(await readFile(path.join(target, "Contents", "MacOS", "Codeviewer"), "utf8")).toBe("old"); }, 60_000); });