mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17a976fa2e | ||
|
|
9240493c43 | ||
|
|
f776ad87b6 | ||
|
|
846c62fbf7 | ||
|
|
73c0bfccc4 | ||
|
|
3af1ff6fae | ||
|
|
988f111cd0 | ||
|
|
5f5de5827e | ||
|
|
0aba9f6ec8 | ||
|
|
61dbd97ba4 | ||
|
|
b5d8122ea7 | ||
|
|
57f7a77573 | ||
|
|
af4cc45cfd | ||
|
|
01eb8ef08a | ||
|
|
140ca35e2d | ||
|
|
17232b01f6 | ||
|
|
612c69d57a | ||
|
|
5e27043bf7 |
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.3",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.33.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f776ad8: ### Thanks
|
||||
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
||||
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
||||
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
||||
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
||||
|
||||
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
||||
|
||||
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
||||
|
||||
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
||||
|
||||
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
||||
|
||||
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
||||
|
||||
## 1.33.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -78,7 +78,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.33.2 (must match `package.json`)
|
||||
**Version**: 1.33.3 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -270,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
@@ -295,6 +295,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||
@@ -333,7 +335,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -338,6 +338,42 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
||||
codeman status | list | attach <path> codeman doctor
|
||||
```
|
||||
|
||||
### Opening a session from your own page
|
||||
|
||||
To send someone from your page to one session, link to the dashboard with the
|
||||
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
||||
dashboard selects that tab when it loads. It also removes the fragment from its
|
||||
own URL, so a later link to the same session still counts as a change.
|
||||
|
||||
Keep reusing one named window to make later links fast:
|
||||
|
||||
```js
|
||||
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
||||
```
|
||||
|
||||
When that window already shows the dashboard, only the fragment differs. The
|
||||
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
||||
reloading it. A session the window has shown before appears at once. A session
|
||||
your page has only just created may not be listed yet, so the dashboard waits
|
||||
for its `session:created` event and selects it then. That wait lasts at most 30
|
||||
seconds: a link whose session never appears (a closed session, a typo, or in
|
||||
multi-user mode another user's session) is dropped with a "Session not found"
|
||||
notice. Clicking another tab, going Home or opening a web tab also ends the
|
||||
wait, so a session that turns up later never takes the screen from the person.
|
||||
|
||||
When your page holds the window reference (`const win = window.open(...)`),
|
||||
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
||||
without a reload, but adds no history entry, so Back in the dashboard window
|
||||
does not turn into a silent no-op.
|
||||
|
||||
Following a link does not count as someone looking at the session, so it
|
||||
leaves the session's idle alert in place. The alert clears when the person
|
||||
clicks the tab or types into the session. A link to a session that is popped
|
||||
out into its own window asks that window to come forward, as clicking its tab does.
|
||||
|
||||
A link to `/session/<id>` opens a page showing that session alone, and that
|
||||
page loads from scratch for every link.
|
||||
|
||||
## Seam 4: Hooks
|
||||
|
||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||
|
||||
@@ -40,6 +40,10 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||
is kept working for the bundled UI.
|
||||
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
||||
`#session=<id>` fragment selects that session if this client can see it. The
|
||||
fragment name and that meaning are stable; see
|
||||
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
||||
|
||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ It renders what it can:
|
||||
|
||||
| Kind | Behaviour |
|
||||
| ------------------------ | ------------------------------------------------------------------------- |
|
||||
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
|
||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||
| Images | Inline. |
|
||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||
@@ -85,8 +86,9 @@ File paths in a session are links. That works in two places:
|
||||
render as underlined monospace links.
|
||||
|
||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
|
||||
the tail viewer instead, which follows a file that is still being written.
|
||||
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
|
||||
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
||||
viewer instead, which follows a file that is still being written.
|
||||
|
||||
Paths **outside** the session's workspace work too, which matters because that is where most
|
||||
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.3",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.33.2",
|
||||
"version": "1.33.3",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
+5
-1
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
||||
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
|
||||
// windows in which Claude Code could run an empty or non-executable file.
|
||||
// rename() swaps the complete, already-executable file in atomically.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
|
||||
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
|
||||
// name let the first rename consume the others' temp file, and their ENOENT
|
||||
// dropped those sessions from tmux to the direct-PTY fallback.
|
||||
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
await writeFile(tmpPath, desired);
|
||||
await chmod(tmpPath, 0o755);
|
||||
await rename(tmpPath, scriptPath);
|
||||
|
||||
+125
-10
@@ -567,6 +567,14 @@ const DEFAULT_SHORTCUTS = [
|
||||
*/
|
||||
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
|
||||
/**
|
||||
* How long a `#session=<id>` link waits for the session list to name its id
|
||||
* before the dashboard drops it with a "Session not found" toast (see
|
||||
* _armUrlSessionWait). Long enough for a page that has just created the
|
||||
* session to see its session:created land here.
|
||||
*/
|
||||
const URL_SESSION_WAIT_MS = 30000;
|
||||
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
@@ -602,6 +610,10 @@ class CodemanApp {
|
||||
// service-worker shell loads), with the server-injected global as a fallback.
|
||||
this.soloSessionId = this._detectSoloSessionId();
|
||||
this.isSoloWindow = !!this.soloSessionId;
|
||||
// A session another page asked for with a `#session=<id>` link. It waits
|
||||
// here until the session list has that id (see _selectUrlSession).
|
||||
this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();
|
||||
this._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
|
||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||
@@ -997,6 +1009,18 @@ class CodemanApp {
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// A page holding this window switches its tab by changing only the
|
||||
// fragment, which keeps the page loaded (see sessionIdFromFragment).
|
||||
if (!this.isSoloWindow) {
|
||||
window.addEventListener('hashchange', () => {
|
||||
const id = this._takeUrlSession();
|
||||
if (!id) return;
|
||||
// A new link replaces one still waiting, and gets a wait of its own.
|
||||
this._retireUrlSession();
|
||||
this._urlSessionId = id;
|
||||
this._selectUrlSession();
|
||||
});
|
||||
}
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -1368,6 +1392,69 @@ class CodemanApp {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Read a `#session=<id>` link off the URL and drop the fragment. The next
|
||||
* link to the same session is then a change the browser reports, even
|
||||
* after you have clicked away to another tab. Returns the id or null. */
|
||||
_takeUrlSession() {
|
||||
const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null;
|
||||
if (id) {
|
||||
try { history.replaceState(history.state, '', location.pathname + location.search); } catch {}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Show the session a `#session=<id>` link asked for, once the session list
|
||||
* has it. A page that has just created a session can link to it before
|
||||
* session:created arrives here, so an unknown id stays pending (for at most
|
||||
* URL_SESSION_WAIT_MS) and _onSessionCreated tries again.
|
||||
*
|
||||
* ⚠️ The selection is `auto`. The page that set the fragment may be a
|
||||
* script, and this window may not even be in front, so following a link is
|
||||
* not a human looking at the session and must not spend its idle alert. */
|
||||
_selectUrlSession() {
|
||||
const id = this._urlSessionId;
|
||||
if (!id) return false;
|
||||
if (!this.sessions.has(id)) {
|
||||
this._armUrlSessionWait(id);
|
||||
return false;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.selectSession(id, { auto: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bound the wait for a link whose id the session list does not have. A
|
||||
* stale link (that session is closed), a typo, or in multi-user mode another
|
||||
* user's session (never in this client's list) would otherwise wait with
|
||||
* nothing on screen, and take the tab whenever a matching session turned up.
|
||||
* One timer per link: handleInit running again (an SSE reconnect) does not
|
||||
* restart it, and every way a link ends goes through _retireUrlSession. */
|
||||
_armUrlSessionWait(id) {
|
||||
if (this._urlSessionWaitTimer) return;
|
||||
this._urlSessionWaitTimer = setTimeout(() => {
|
||||
this._urlSessionWaitTimer = null;
|
||||
if (this._urlSessionId !== id) return;
|
||||
// Listed by a path other than session:created (a session:updated): select it.
|
||||
if (this.sessions.has(id)) {
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
this._retireUrlSession();
|
||||
this.showToast?.('Session not found', 'warning');
|
||||
}, URL_SESSION_WAIT_MS);
|
||||
}
|
||||
|
||||
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
|
||||
* replaced by a newer one, timed out, or the user chose something else
|
||||
* (another tab, Home, a web tab). */
|
||||
_retireUrlSession() {
|
||||
this._urlSessionId = null;
|
||||
if (this._urlSessionWaitTimer) {
|
||||
clearTimeout(this._urlSessionWaitTimer);
|
||||
this._urlSessionWaitTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||
@@ -1949,6 +2036,7 @@ class CodemanApp {
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
if (this.sessions.size === 1) this.startSystemStatsPolling();
|
||||
if (this._urlSessionId === data.id) this._selectUrlSession();
|
||||
}
|
||||
|
||||
_onSessionUpdated(data) {
|
||||
@@ -2255,13 +2343,18 @@ class CodemanApp {
|
||||
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
/**
|
||||
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
|
||||
* `breaks` turns every source newline into a <br>: right for chat, where a
|
||||
* newline is the agent's line break, wrong for a file (the File Viewer passes
|
||||
* false), where a README hard-wrapped at 80 columns would break at every wrap.
|
||||
*/
|
||||
_renderMarkdown(text, { breaks = true } = {}) {
|
||||
const src = text || '';
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
const prepared = this._preprocessAsciiArt(src);
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, gfm: true }));
|
||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||
// on mobile without collapsing into block-level cells.
|
||||
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
||||
@@ -2358,7 +2451,9 @@ class CodemanApp {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const filePath = pathLink.dataset.path;
|
||||
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||
// A rendered document's links name the session the preview was opened
|
||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4367,6 +4462,16 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// A `#session=<id>` link wins over restoring the last active tab.
|
||||
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
||||
this.activeSessionId = null;
|
||||
this._selectUrlSession();
|
||||
return;
|
||||
}
|
||||
// Not listed yet: its wait starts now that the list has loaded, and the
|
||||
// last active tab is restored meanwhile.
|
||||
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
||||
|
||||
const previousActiveId = this.activeSessionId;
|
||||
if (this.sessionOrder.length === 0) {
|
||||
this.activeSessionId = null;
|
||||
@@ -6560,6 +6665,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async selectSession(sessionId, options = {}) {
|
||||
// Picking another tab yourself retires a `#session=<id>` link still
|
||||
// waiting for its session, which would otherwise take the tab from you
|
||||
// whenever that session turned up (see _selectUrlSession).
|
||||
if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) {
|
||||
this._retireUrlSession();
|
||||
}
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||
@@ -6569,12 +6680,13 @@ class CodemanApp {
|
||||
}
|
||||
const forceReload = options?.forceReload === true;
|
||||
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
|
||||
// the boot restore, a solo window opening its target, the fallback after
|
||||
// the active session is deleted. Those must NOT spend a pending idle alert
|
||||
// (the yellow survives until a real tap), because "the app put this on
|
||||
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
|
||||
// site nobody tagged fails toward acknowledging rather than toward an
|
||||
// alert that can never be cleared.
|
||||
// the boot restore, a solo window opening its target, a `#session=<id>`
|
||||
// link from another page, the fallback after the active session is
|
||||
// deleted. Those must NOT spend a pending idle alert (the yellow survives
|
||||
// until a real tap), because "the app put this on screen" is not "I
|
||||
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
|
||||
// fails toward acknowledging rather than toward an alert that can never be
|
||||
// cleared.
|
||||
const userInitiated = options?.auto !== true;
|
||||
if (this.activeSessionId === sessionId && !forceReload) {
|
||||
// Tapping the tab you are already on is still "I checked it". The alert
|
||||
@@ -7537,6 +7649,9 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
goHome() {
|
||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
||||
// waiting for its session must not take the screen later.
|
||||
this._retireUrlSession();
|
||||
// Deselect active session and show welcome screen
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
|
||||
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
|
||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
@@ -1853,10 +1853,27 @@ function reconcilePtyGeometry(local, pty) {
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which session does a dashboard URL's fragment ask for? Another page that
|
||||
* holds the dashboard's window, such as a task board, points it at
|
||||
* `/#session=<id>`. Only the fragment changes between two such links, so the
|
||||
* browser keeps the page loaded and fires `hashchange`, and the dashboard
|
||||
* switches tabs without reloading. Any other fragment asks for nothing.
|
||||
*
|
||||
* @param {string} hash - `location.hash`, with or without its leading `#`
|
||||
* @returns {string|null} the session id, or null
|
||||
*/
|
||||
function sessionIdFromFragment(hash) {
|
||||
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
|
||||
const id = params.get('session');
|
||||
return id && id.trim() ? id.trim() : null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
|
||||
@@ -571,6 +571,8 @@
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -727,6 +729,9 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
'Unsaved changes': '未保存的更改',
|
||||
Saved: '已保存',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
|
||||
@@ -573,6 +573,9 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
|
||||
+320
-10
@@ -20,6 +20,20 @@ const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
||||
// a huge log is a partial read rather than a download the viewer throws away.
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||||
const TEXT_PREVIEW_MAX_LINES = 500;
|
||||
// A markdown document gets the route's ceiling instead of the 500-line preview
|
||||
// cap: a rendered README cut mid-way reads as the whole document.
|
||||
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
|
||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
// File Viewer text-view prefs: per-device, in their own localStorage keys for
|
||||
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
|
||||
// rebuilt from the settings modal on save, so a key toggled from the viewer
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -339,6 +353,67 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save what had focus before a search-first overlay takes it.
|
||||
*
|
||||
* The Command Palette and the Session Manager both call `search.focus()` on
|
||||
* open, and both used to close by removing the `active` class and nothing
|
||||
* else. Hiding the focused input does not hand focus back to anyone — the
|
||||
* browser drops it on `<body>` — so after Escape closed the overlay every
|
||||
* keystroke went nowhere and the user had to click the terminal to type
|
||||
* again (measured: `document.activeElement` is BODY afterwards and the
|
||||
* terminal emits no onData at all). Every close path has the same hole, so
|
||||
* the restore lives in the close functions, not in the global Escape chain.
|
||||
*
|
||||
* ⚠️ Deliberately only the save/restore half of {@link FocusTrap}, not the
|
||||
* whole thing. `FocusTrap.activate()` moves focus to the first focusable
|
||||
* element, which in both of these overlays is not the search box — adopting
|
||||
* it wholesale would fix the focus loss by breaking the thing Cmd+K exists
|
||||
* for, typing a filter the moment it opens.
|
||||
*/
|
||||
_rememberOverlayFocus(key) {
|
||||
this[key] = (typeof document !== 'undefined' && document.activeElement) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hand focus back to whatever {@link _rememberOverlayFocus} saved.
|
||||
*
|
||||
* ⚠️ The terminal fallback is gated on there being an active session: an
|
||||
* overlay opened from the welcome screen has no terminal to return to, and
|
||||
* focusing one on a phone summons the on-screen keyboard over a screen that
|
||||
* has no input on it.
|
||||
*
|
||||
* ⚠️ A focus that already left the overlay is kept, not overridden. The
|
||||
* Session Manager's row menu ("Switch to session", "Open folder") calls
|
||||
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
|
||||
* restoring there would pull focus back to the header button that opened the
|
||||
* modal. Only a focus still inside `modal`, or one dropped on `<body>`, is
|
||||
* the overlay's to hand back.
|
||||
*/
|
||||
_restoreOverlayFocus(key, modal) {
|
||||
const prev = this[key];
|
||||
this[key] = null;
|
||||
const body = typeof document !== 'undefined' ? document.body : null;
|
||||
const current = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
if (current && current !== body && modal?.contains?.(current) === false) return;
|
||||
// `isConnected === false` means the element was removed while the overlay
|
||||
// was open (a re-render of the tab strip, say); anything else — including
|
||||
// a stub with no such property — is treated as still focusable.
|
||||
if (prev && prev !== body && prev.isConnected !== false && typeof prev.focus === 'function') {
|
||||
prev.focus();
|
||||
return;
|
||||
}
|
||||
// ⚠️ `activeSessionId` alone only covers the welcome screen. On a touch device
|
||||
// with the keyboard down, focus sits on `<body>`, so focusing the terminal here
|
||||
// would summon the on-screen keyboard — `selectSession()` deliberately skips the
|
||||
// focus for exactly that reason, and this would override it. The app's own
|
||||
// predicate already encodes the rule (true on desktop, on touch only while the
|
||||
// keyboard is open); the optional call keeps the vm test harness working.
|
||||
if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) {
|
||||
this.terminal?.focus?.();
|
||||
}
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
@@ -351,13 +426,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
// BEFORE the steal, not after: `search.focus()` below is what loses the
|
||||
// caller's focus, so the read has to happen while it is still there.
|
||||
this._rememberOverlayFocus('_commandPalettePrevFocus');
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// ⚠️ Bail out when it was not open. The global Escape handler calls this on
|
||||
// EVERY Escape (app.js), in the CAPTURE phase, so an unconditional restore
|
||||
// runs before the focused element's own Escape handler and steals focus into
|
||||
// the terminal: keys typed after Escape in split Pane B land in Pane A, keys
|
||||
// typed in any text field land in the terminal, and the inline tab rename's
|
||||
// Escape fires the input's blur (which commits) before its own handler
|
||||
// (which cancels), turning a cancel into a rename.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
|
||||
},
|
||||
|
||||
_wireCommandPalette() {
|
||||
@@ -618,6 +705,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
this._rememberOverlayFocus('_sessionManagerPrevFocus');
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
@@ -625,7 +713,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
// Same guard as closeCommandPalette — see the note there.
|
||||
if (!modal?.classList?.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._restoreOverlayFocus('_sessionManagerPrevFocus', modal);
|
||||
},
|
||||
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
@@ -4012,6 +4103,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
// Same for the text-view toggles: they act on the text this load has not
|
||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
@@ -4095,12 +4190,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const text = await res.text();
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
|
||||
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
|
||||
// Markdown keeps every line the Range read returned: the byte bound is
|
||||
// what protects the tab, and a rendered document cut at 500 lines
|
||||
// reads as the whole document.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? Infinity : TEXT_PREVIEW_MAX_LINES;
|
||||
const clippedByLines = lines.length > lineCap;
|
||||
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
|
||||
this.filePreviewContent = shown;
|
||||
// attachmentId: a card's filePath is the bare file name, so the
|
||||
// rebase pass must know there is no directory to resolve against.
|
||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -4146,8 +4248,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// 500 lines is what keeps a huge log from locking the tab in one <pre>;
|
||||
// markdown is rendered as a document and takes the route's ceiling instead.
|
||||
const lineCap = MARKDOWN_EXTS.has(ext) ? MARKDOWN_PREVIEW_MAX_LINES : TEXT_PREVIEW_MAX_LINES;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
const res = await fetch(
|
||||
`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=${lineCap}`
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -4172,10 +4279,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
// Text content
|
||||
// Text content: rendered markdown or plain text, per the viewer's toggles.
|
||||
this.filePreviewContent = data.content;
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||
this.filePreviewText = { ext, sessionId, filePath };
|
||||
this._renderFilePreviewText();
|
||||
const truncNote = data.truncated ? ` (showing ${lineCap}/${data.totalLines} lines)` : '';
|
||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||
// succeed (workspace text file inside the allowlist and size cap).
|
||||
@@ -4203,6 +4311,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// audible and keeps streaming from the server. Closing has to stop it.
|
||||
this._stopFilePreviewMedia();
|
||||
this.filePreviewContent = '';
|
||||
this.filePreviewText = null;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
this.filePreviewDetachUrl = '';
|
||||
const detachBtn = this.$('filePreviewDetachBtn');
|
||||
if (detachBtn) detachBtn.hidden = true;
|
||||
@@ -4251,6 +4361,204 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_filePreviewPref(name) {
|
||||
try {
|
||||
const stored = localStorage.getItem(FILE_PREVIEW_PREF_KEYS[name]);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
/* private mode: fall through to the default */
|
||||
}
|
||||
return FILE_PREVIEW_PREF_DEFAULTS[name];
|
||||
},
|
||||
|
||||
_setFilePreviewPref(name, on) {
|
||||
try {
|
||||
localStorage.setItem(FILE_PREVIEW_PREF_KEYS[name], on ? '1' : '0');
|
||||
} catch {
|
||||
/* private mode: the toggle still applies for this page load */
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Paint the loaded text (filePreviewContent) into the preview body: a
|
||||
* rendered document for .md/.markdown while the MD toggle is on, otherwise
|
||||
* plain text with one span per line so the Lines toggle can number them.
|
||||
* The MD toggle re-runs this without a refetch.
|
||||
*
|
||||
* Markdown goes through the same pipeline as the Response Viewer
|
||||
* (`_renderMarkdown`: marked + the DOMPurify allowlist), never a second
|
||||
* parser, and is built inside a <template>: a detached div with innerHTML
|
||||
* already set starts fetching every <img src>, so the document's relative
|
||||
* image paths would hit the server as /docs/img.png 404s before
|
||||
* `_rebaseFilePreviewMarkdownRefs` rewrote them. `breaks: false` because a
|
||||
* file is not a chat message: a paragraph hard-wrapped in the source is one
|
||||
* paragraph, as on GitHub.
|
||||
*/
|
||||
_renderFilePreviewText() {
|
||||
const info = this.filePreviewText;
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
if (!info || !bodyEl) return;
|
||||
const isMarkdown = MARKDOWN_EXTS.has(info.ext);
|
||||
const rendered = isMarkdown && this._filePreviewPref('mdRendered');
|
||||
if (rendered) {
|
||||
// data-i18n-skip: the translator's MutationObserver would otherwise
|
||||
// rewrite the document's own headings and paragraphs.
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent, { breaks: false })}</div>`;
|
||||
const doc = tmpl.content.firstElementChild;
|
||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
||||
this._linkifyFilePaths(doc);
|
||||
// The linkifier's absolute paths name no session; give them the
|
||||
// preview's, like the rebased links, or they open in the active tab's.
|
||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
||||
bodyEl.replaceChildren(tmpl.content);
|
||||
// The Response Viewer's click delegate (path links, code-block copy
|
||||
// buttons, loopback links): container-bound and idempotent, so binding it
|
||||
// on the body once serves every preview.
|
||||
this._bindResponseViewerInteractions(bodyEl);
|
||||
} else {
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'file-preview-text';
|
||||
pre.classList.toggle('wrap', this._filePreviewPref('wrap'));
|
||||
pre.classList.toggle('show-lines', this._filePreviewPref('lineNumbers'));
|
||||
const code = document.createElement('code');
|
||||
// One span per line joined by real newlines: empty lines survive, select
|
||||
// and copy return the exact text, and the gutter counter hangs off the
|
||||
// spans' ::before so the numbers are never part of the text.
|
||||
code.innerHTML = this.filePreviewContent
|
||||
.split('\n')
|
||||
.map((line) => `<span class="fp-line">${escapeHtml(line)}</span>`)
|
||||
.join('\n');
|
||||
pre.appendChild(code);
|
||||
bodyEl.replaceChildren(pre);
|
||||
}
|
||||
this._updateFilePreviewToolbar(rendered ? 'markdown' : 'text');
|
||||
},
|
||||
|
||||
/**
|
||||
* Point a rendered document's workspace references at the file it came from.
|
||||
*
|
||||
* Images are rebased onto the workspace-confined file-raw route under the
|
||||
* document's directory, root-relative ones (`/docs/x.png`) under the
|
||||
* workspace root as on GitHub (the server refuses escapes, so `..` is safe
|
||||
* to forward). Whatever fails to load degrades to its alt text with one
|
||||
* error handler: a remote image the page CSP blocks, a 404 for a document
|
||||
* outside the workspace, an SVG that file-raw serves as a download. Links
|
||||
* take the `a.rv-path` shape the Response Viewer delegate already opens in
|
||||
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
|
||||
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
|
||||
* preview's own session so a document opened from another session's
|
||||
* attachment card resolves against that workspace, not the active tab's.
|
||||
*
|
||||
* A preview opened by attachment id under a bare file name (attachment
|
||||
* cards and the history drawer: the registry keeps no relative path) has no
|
||||
* directory to resolve against, and the workspace root is the wrong one for
|
||||
* docs/report.md and for a file outside the workspace alike. Its workspace
|
||||
* refs degrade instead: images to their alt text, links to their text,
|
||||
* rather than a missing image or a silently different file.
|
||||
*/
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
const isWorkspaceRef = (ref) =>
|
||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
||||
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
|
||||
// `img.png?raw=true`: neither fragment nor query is part of the path.
|
||||
// marked percent-encodes destinations (`my image.png` arrives as
|
||||
// `my%20image.png`), so decode before the route encodes again, or file-raw
|
||||
// looks for a file literally named `my%20image.png`; a malformed escape
|
||||
// keeps the ref as written. `.` and `..` segments are collapsed so the
|
||||
// title reads `README.md`, not `docs/../README.md`; a `..` that climbs
|
||||
// past the start is kept and left for the server to refuse.
|
||||
const resolveRef = (ref) => {
|
||||
let rel = ref.split('#')[0].split('?')[0];
|
||||
try {
|
||||
rel = decodeURIComponent(rel);
|
||||
} catch {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
||||
else parts.push(seg);
|
||||
}
|
||||
return parts.join('/');
|
||||
};
|
||||
for (const img of root.querySelectorAll('img[src]')) {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (unresolvable && isWorkspaceRef(src)) {
|
||||
img.replaceWith(img.getAttribute('alt') || src);
|
||||
continue;
|
||||
}
|
||||
if (isWorkspaceRef(src)) {
|
||||
const path = resolveRef(src);
|
||||
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
|
||||
}
|
||||
img.addEventListener('error', () => img.replaceWith(img.getAttribute('alt') || src), { once: true });
|
||||
}
|
||||
for (const a of root.querySelectorAll('a[href]')) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!isWorkspaceRef(href)) continue;
|
||||
if (unresolvable) {
|
||||
a.replaceWith(...a.childNodes);
|
||||
continue;
|
||||
}
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = resolveRef(href);
|
||||
a.dataset.sessionId = sessionId;
|
||||
a.setAttribute('href', '#');
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
||||
* image, media, PDF, edit mode) hides all three.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
const btn = this.$(id);
|
||||
if (!btn) return;
|
||||
btn.hidden = !shown;
|
||||
if (shown) btn.setAttribute('aria-pressed', String(pressed));
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
},
|
||||
|
||||
toggleFilePreviewLines() {
|
||||
this._toggleFilePreviewTextClass('lineNumbers', 'show-lines');
|
||||
},
|
||||
|
||||
toggleFilePreviewWrap() {
|
||||
this._toggleFilePreviewTextClass('wrap', 'wrap');
|
||||
},
|
||||
|
||||
/** Lines and Wrap are pure class flips on the <pre>; no re-render needed. */
|
||||
_toggleFilePreviewTextClass(pref, className) {
|
||||
const on = !this._filePreviewPref(pref);
|
||||
this._setFilePreviewPref(pref, on);
|
||||
const pre = this.$('filePreviewBody')?.querySelector(':scope > pre.file-preview-text');
|
||||
if (pre) pre.classList.toggle(className, on);
|
||||
this._updateFilePreviewToolbar('text');
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -4318,6 +4626,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||
bodyEl.innerHTML = '';
|
||||
bodyEl.appendChild(textarea);
|
||||
// The MD/Lines/Wrap toggles act on the text view this textarea replaced.
|
||||
this._updateFilePreviewToolbar('none');
|
||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||
// before the user has scrolled to the line they want to change.
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
/**
|
||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||
* blocks may carry `language-*` classes from marked.
|
||||
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
|
||||
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
|
||||
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
|
||||
* README could break every button until a reload.
|
||||
*/
|
||||
var ALLOWED_ATTR = [
|
||||
'href',
|
||||
@@ -93,7 +96,6 @@
|
||||
'title',
|
||||
'class',
|
||||
'id',
|
||||
'name',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'align',
|
||||
|
||||
@@ -10984,6 +10984,19 @@ kbd {
|
||||
.file-preview-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
/* The title yields on a phone, not the buttons. */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview-actions .btn-icon-sm[aria-pressed='true'] {
|
||||
color: var(--accent, #4ea1ff);
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.file-preview-actions .file-preview-pill {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.file-preview-body {
|
||||
@@ -11008,6 +11021,58 @@ kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---- File Viewer text view: Lines / Wrap toggles ----
|
||||
Child-combinator scoped so none of this leaks into the code blocks of a
|
||||
rendered markdown document, which are <pre>s too. */
|
||||
|
||||
.file-preview-body > pre.file-preview-text {
|
||||
counter-reset: fp-line;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text:not(.wrap) {
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Numbers hug the left edge (4px, left-aligned) instead of sitting behind the
|
||||
pre's own padding right-aligned in a 4ch column, where "1" landed 40px in. */
|
||||
.file-preview-body > pre.file-preview-text.show-lines {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-preview-body > pre.file-preview-text.show-lines .fp-line::before {
|
||||
counter-increment: fp-line;
|
||||
content: counter(fp-line);
|
||||
display: inline-block;
|
||||
min-width: 4ch;
|
||||
margin-right: 1ch;
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- File Viewer rendered markdown ----
|
||||
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
|
||||
carries no padding or base font (the chat card supplies those). */
|
||||
|
||||
.file-preview-body > .file-preview-md {
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
/* Relative links in the document are rebased onto a.rv-path so the Response
|
||||
Viewer delegate opens them here; keep them reading as prose, not as paths.
|
||||
Three-class selector on purpose: `.rv-text a.rv-path` (the monospace path
|
||||
style) is declared later in this file and ties on specificity otherwise. */
|
||||
.file-preview-body .file-preview-md a.rv-path {
|
||||
font: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.file-preview-body img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
@@ -68,10 +71,18 @@
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._wsClosed = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
||||
// are held there with their arrival time instead of written under it.
|
||||
this._historyPullAt = 0;
|
||||
this._historyPullUseless = false;
|
||||
this._liveQueue = null;
|
||||
this._onWheel = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -95,6 +106,8 @@
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this._installWheelListener();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
@@ -254,9 +267,9 @@
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
this._onLiveOutput(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
this._onLiveClear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
@@ -280,16 +293,30 @@
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
this.ws.onclose = () => this._onSocketClosed();
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// The socket's close, split out of connect() so the tests can drive it.
|
||||
// While a history pull is running the marker waits for the pull's finally
|
||||
// block: written now, it would sit above the output the pull is still
|
||||
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
|
||||
// land in the middle of a chunked replay.
|
||||
_onSocketClosed() {
|
||||
this._wsReady = false;
|
||||
this._wsClosed = true;
|
||||
if (!this._liveQueue) this._writeDisconnectedMarker();
|
||||
}
|
||||
|
||||
// Extracted so both _onSocketClosed() and a history pull that ends on a
|
||||
// closed socket can write it (see _pullHistory()'s finally block).
|
||||
_writeDisconnectedMarker() {
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
@@ -324,14 +351,168 @@
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||
_endBufferLoad() {
|
||||
this._bufferLoading = false;
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Live terminal output. Written straight through, except while a history
|
||||
// pull is replaying: a capture is current only up to the instant tmux took
|
||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
||||
// _finishBufferLoad `since` rule), never written underneath it.
|
||||
_onLiveOutput(data) {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||
else this.terminal?.write(data);
|
||||
}
|
||||
|
||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||
_onLiveClear() {
|
||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||
else this.terminal?.clear();
|
||||
}
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||
// xterm keeps doing the scrolling.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||
};
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||
}
|
||||
|
||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||
// so this pane's xterm ends up with about one screen of scrollback while
|
||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||
// history was unreachable. The primary pane has the same pull
|
||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||
// tmux the browser xterm never enters the alternate buffer.
|
||||
_maybeLoadMoreHistory() {
|
||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||
if (this._bufferLoading) return;
|
||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||
// _sendResize(): a detached session's own window already owns its PTY
|
||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const active = this.terminal.buffer.active;
|
||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||
// rather than latch; a pull that could only have downgraded the pane
|
||||
// waits far longer.
|
||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||
const now = Date.now();
|
||||
if (now - this._historyPullAt < cooldown) return;
|
||||
this._historyPullAt = now;
|
||||
void this._pullHistory();
|
||||
}
|
||||
|
||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||
// main thread) and replays it under the reader's current place. Holds the
|
||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||
async _pullHistory() {
|
||||
// A close before the pull already wrote its marker; one during it did not.
|
||||
const closedBefore = this._wsClosed;
|
||||
this._bufferLoading = true;
|
||||
this._liveQueue = [];
|
||||
let replayed = false;
|
||||
let capturedAt = 0;
|
||||
try {
|
||||
// A deadline, because live output is held for as long as this runs: a
|
||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
||||
// lands in the catch below, which releases the flag and the queue. It
|
||||
// covers the body read too, not just the headers.
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
||||
});
|
||||
// The cutoff below is the response's arrival, the same `since` rule the
|
||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||
// for the instant tmux took the capture, which lies somewhere in the
|
||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||
// by one round trip and not closable without a server-side capture time.
|
||||
capturedAt = performance.now();
|
||||
const payload = (await res.json())?.data;
|
||||
const buffer = payload?.terminalBuffer;
|
||||
const term = this.terminal;
|
||||
if (!buffer || !term || this._destroyed) return;
|
||||
const rowsBefore = term.buffer.active.length;
|
||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||
// lines, so a window of short lines can carry more rows than this pane
|
||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||
const scrollbackCap = term.options?.scrollback || 0;
|
||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||
// Nothing to gain (this also covers a downgrade, which would delete
|
||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||
// untruncated window IS all of tmux's history and the next burst can add
|
||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||
// what the pane shows, and every ask costs the server a capture-pane of
|
||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||
// off too, since no window can ever fit in it.
|
||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||
return;
|
||||
}
|
||||
this._historyPullUseless = false;
|
||||
term.write('\x1bc');
|
||||
replayed = true;
|
||||
await writeChunked(term, buffer, () => this._destroyed);
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// xterm parses asynchronously: an empty write's callback fires only
|
||||
// after everything before it, so the row count below is the settled one.
|
||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||
if (this._destroyed || !this.terminal) return;
|
||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||
// rows down; land there and the recovered history sits above it.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
} catch {
|
||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||
} finally {
|
||||
const queued = this._liveQueue ?? [];
|
||||
this._liveQueue = null;
|
||||
// After a replay, only frames that arrived after the capture are news;
|
||||
// earlier ones are already in it. With no replay, every held frame is.
|
||||
const cutoff = replayed ? capturedAt : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < cutoff) continue;
|
||||
if (entry.clear) this.terminal?.clear();
|
||||
else this.terminal?.write(entry.data);
|
||||
}
|
||||
// A replay's own `\x1bc` wipes a marker written before the pull,
|
||||
// painting a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket, so re-stamp it
|
||||
// after a replay. A close DURING the pull wrote no marker at all
|
||||
// (_onSocketClosed() defers it while the queue is live), so write it
|
||||
// whether or not this pull replayed. Checked after the queue flush so
|
||||
// it is the last thing on screen, matching what the close would have
|
||||
// left had the pull never run.
|
||||
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
|
||||
this._endBufferLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
@@ -381,6 +562,10 @@
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this._onWheel) {
|
||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||
this._onWheel = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
|
||||
@@ -274,7 +274,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// one `/`, and refuse whatever still opens a second one. The proxied form
|
||||
// is refused server-side as well (resolveUpstreamUrl).
|
||||
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
||||
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
|
||||
void this.openWebview(id, {
|
||||
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
|
||||
auto: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -356,15 +359,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
|
||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||
* against the real URL in direct mode. A mounted frame is navigated there
|
||||
* rather than left on whatever page it was showing.
|
||||
* rather than left on whatever page it was showing. `auto: true` marks an
|
||||
* open the APP made (a frame recovering itself, the fallback after the
|
||||
* active web tab closes), as on selectSession().
|
||||
*/
|
||||
async openWebview(id, options = {}) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
// Opening a web tab yourself is choosing something else, so a
|
||||
// `#session=<id>` link still waiting for its session must not take the
|
||||
// screen from this tab later. Retired before the await below, which a
|
||||
// session:created could otherwise land inside.
|
||||
if (options.auto !== true) this._retireUrlSession?.();
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
@@ -513,7 +524,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
this.openWebview(next, { auto: true });
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
|
||||
@@ -1724,7 +1724,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
|
||||
// Shared with the attachment registry so a video plays the same whether it
|
||||
// sits in the workspace or is reached by id from outside it.
|
||||
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
||||
@@ -2045,6 +2045,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
mp4: 'video/mp4',
|
||||
|
||||
@@ -111,7 +111,7 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
app.getSessionName = (session: any) =>
|
||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||
|
||||
return { app, elements, listeners };
|
||||
return { app, elements, listeners, makeClassList };
|
||||
}
|
||||
|
||||
describe('Command-K session palette', () => {
|
||||
@@ -480,6 +480,150 @@ describe('Session Manager unified list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
|
||||
/**
|
||||
* Both overlays focus their search box on open. Closing them used to leave
|
||||
* focus on <body>, so after Escape every keystroke went nowhere until the
|
||||
* user clicked the terminal — measured in a real browser against a shell
|
||||
* session: activeElement BODY, zero onData for anything typed afterwards.
|
||||
*/
|
||||
function focusHarness() {
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
|
||||
const body = { tagName: 'BODY' };
|
||||
let active: any = priorElement;
|
||||
// The harness builds its element map internally, so getElementById reads it
|
||||
// through this binding, filled in once the harness returns.
|
||||
let els: Record<string, any> = {};
|
||||
const { app, elements, makeClassList } = loadPaletteHarness({
|
||||
document: {
|
||||
getElementById: (id: string) => els[id] ?? null,
|
||||
get activeElement() {
|
||||
return active;
|
||||
},
|
||||
body,
|
||||
},
|
||||
});
|
||||
els = elements;
|
||||
app.terminal = { focus: terminalTextarea.focus };
|
||||
app.activeSessionId = 'sess-beta';
|
||||
// The overlay's own focus() is what moves focus in a real browser; the
|
||||
// fake document needs the same transition or the test proves nothing.
|
||||
elements.commandPaletteSearch.focus = vi.fn(() => {
|
||||
active = elements.commandPaletteSearch;
|
||||
});
|
||||
// The restore keeps a focus that already left the overlay, so the modal has
|
||||
// to know its own search box is inside it, as the real DOM does.
|
||||
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
|
||||
// Same wiring for the Session Manager. A real classList: the close guard
|
||||
// reads `contains('active')`, and a stub without it reports "not open" and
|
||||
// skips the restore.
|
||||
const installSessionManager = () => {
|
||||
const search: any = { value: '', addEventListener: vi.fn() };
|
||||
search.focus = vi.fn(() => {
|
||||
active = search;
|
||||
});
|
||||
elements.sessionManagerSearch = search;
|
||||
elements.sessionManagerModal = {
|
||||
classList: makeClassList(),
|
||||
addEventListener: vi.fn(),
|
||||
contains: (el: any) => el === search,
|
||||
};
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._loadSessionManagerList = vi.fn();
|
||||
};
|
||||
return {
|
||||
app,
|
||||
elements,
|
||||
priorElement,
|
||||
terminalTextarea,
|
||||
body,
|
||||
makeClassList,
|
||||
installSessionManager,
|
||||
setActive: (v: any) => (active = v),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns focus to whatever had it when the command palette closes', () => {
|
||||
const { app, priorElement } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.activeSessionId = null;
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restore focus to <body>, which is the bug itself', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
|
||||
// app.js's global Escape handler calls both close methods on EVERY Escape,
|
||||
// in the capture phase. Nothing was saved, so an unguarded restore would fall
|
||||
// through to the terminal and steal focus from split Pane B, from any text
|
||||
// field, and turn the inline rename's Escape into a commit.
|
||||
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
|
||||
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
|
||||
app.closeCommandPalette();
|
||||
app.closeSessionManager();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not focus the terminal on touch while the keyboard is down', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
app._shouldFocusTerminalForTabSwitch = () => false;
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores focus on the session manager too, not just the palette', async () => {
|
||||
const { app, priorElement, installSessionManager } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
|
||||
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
|
||||
// which focuses the terminal on desktop, and only THEN closeSessionManager().
|
||||
// Opened from its header button, the saved focus is that button, so an
|
||||
// unconditional restore pulled focus off the session the user just picked.
|
||||
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.selectSession = vi.fn(() => setActive(terminalTextarea));
|
||||
app.selectSession('sess-alpha');
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel close helpers', () => {
|
||||
it('closes panels when the mobile header helper is unavailable', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @fileoverview File Viewer text view: rendered markdown plus Lines/Wrap toggles.
|
||||
*
|
||||
* Clicking a `.md` in the Files panel showed wrapped source with no way to see
|
||||
* it rendered, although the Response Viewer's marked + DOMPurify pipeline
|
||||
* (`_renderMarkdown`) was already on the page. The viewer now renders markdown
|
||||
* through that same pipeline, with an MD toggle back to source, and the
|
||||
* plain-text view gained Lines and Wrap toggles. Pinned here:
|
||||
*
|
||||
* 1. `.md` renders into `.rv-text.file-preview-md[data-i18n-skip]` while the
|
||||
* pref is on and into a `<pre>` of per-line spans while it is off; the MD
|
||||
* toggle re-renders WITHOUT a second fetch and persists per device.
|
||||
* 2. Relative image refs are rebased onto the workspace-confined file-raw
|
||||
* route under the document's directory and a failed load degrades to alt
|
||||
* text; relative links become `a.rv-path` for the Response Viewer delegate
|
||||
* and lose the `target` marked gave them, while fragment and http(s) links
|
||||
* stay untouched.
|
||||
* 3. Markdown fetches the route's line ceiling; other text keeps 500.
|
||||
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
|
||||
* holds is byte-identical to the file; every toggle is hidden for an image
|
||||
* and while editing.
|
||||
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
|
||||
* (in-workspace text keeps the tail viewer, see architecture-invariants).
|
||||
* 6. A preview opened by attachment id under a bare file name has no
|
||||
* directory to resolve against, so its relative images degrade to alt text
|
||||
* and its relative links to plain text instead of landing on the workspace
|
||||
* root's files; an absolute-path attachment keeps resolving.
|
||||
* 7. A file renders without chat line breaks (`breaks: false`): a paragraph
|
||||
* hard-wrapped in the source is one paragraph, while the Response Viewer
|
||||
* keeps a <br> per newline.
|
||||
*
|
||||
* Loaded via `vm` with a jsdom document injected (the technique from
|
||||
* response-viewer-file-links.test.ts): constants.js + panels-ui.js only, with
|
||||
* the app.js markdown pipeline stubbed to a fixed fragment, except for rule 7,
|
||||
* which runs the shipping app.js + vendored marked + DOMPurify end to end.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const publicFile = (name: string) => readFileSync(resolve(PUBLIC, name), 'utf8');
|
||||
const constantsJs = publicFile('constants.js');
|
||||
const panelsJs = publicFile('panels-ui.js');
|
||||
|
||||
// A real origin: vitest's equality walker reaches the window through a node's
|
||||
// ownerDocument, and jsdom's localStorage getter throws on an opaque one.
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const { document } = dom.window;
|
||||
|
||||
/** What the stubbed `_renderMarkdown` hands back: every ref shape the rebase pass must classify. */
|
||||
const MARKDOWN_HTML =
|
||||
'<h1>Title</h1><p>x</p>' +
|
||||
'<img src="img/a.png#gh-dark-mode-only" alt="Alt A">' +
|
||||
'<img src="https://cdn.example.com/r.png" alt="remote">' +
|
||||
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
|
||||
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
|
||||
'<a href="#top">t</a>' +
|
||||
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>' +
|
||||
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
|
||||
'<img src="my%20image.png" alt="space">' +
|
||||
'<img src="raw.png?raw=true" alt="raw">' +
|
||||
'<img src="bad%zz.png" alt="bad">' +
|
||||
'<img src="/assets/root.png" alt="root">' +
|
||||
'<img src="//cdn.example.com/p.png" alt="protorel">' +
|
||||
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
|
||||
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</a>';
|
||||
|
||||
const MD_CONTENT = '# Title\n\nx\n';
|
||||
const TXT_CONTENT = 'one\n\n three\tfour\n';
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||
}
|
||||
|
||||
/** Answer file-content like the route does: text as JSON, an image as metadata. */
|
||||
function fetchStub(url: string) {
|
||||
// An attachment's by-id raw route answers the bytes themselves.
|
||||
if (url.includes('/attachments/')) return { ok: true, status: 200, text: async () => MD_CONTENT };
|
||||
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
|
||||
const ext = path.split('.').pop() || '';
|
||||
if (ext === 'png') {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { type: 'image', url: `/file-raw?path=${path}`, size: 5, extension: ext },
|
||||
});
|
||||
}
|
||||
const content = ext === 'md' ? MD_CONTENT : TXT_CONTENT;
|
||||
if (url.includes('edit=1')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { content, hash: 'h', eol: 'lf', totalLines: 3, size: content.length },
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { path, content, totalLines: 3, size: content.length, truncated: false, extension: ext, editable: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** The file-preview overlay's elements, as index.html ships them. */
|
||||
function mountPreviewDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
|
||||
<button id="filePreviewMdBtn" hidden></button>
|
||||
<button id="filePreviewLinesBtn" hidden></button>
|
||||
<button id="filePreviewWrapBtn" hidden></button>
|
||||
<button id="filePreviewEditBtn" hidden></button>
|
||||
<button id="filePreviewDetachBtn" hidden></button>
|
||||
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
|
||||
}
|
||||
|
||||
function loadApp(prefs: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(prefs));
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const fetchMock = vi.fn(async (url: string) => fetchStub(url));
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||
localStorage: {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
document,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
|
||||
MobileDetection: {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
confirm: () => true,
|
||||
fetch: fetchMock,
|
||||
});
|
||||
vm.runInContext(`${constantsJs}\n${panelsJs}\nglobalThis.__exts = FILE_PREVIEW_EXTENSIONS;`, context, {
|
||||
filename: 'panels-ui.js',
|
||||
});
|
||||
|
||||
mountPreviewDom();
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app._resetFilePreviewEdit = () => {};
|
||||
app._isExternalPreviewPath = () => false;
|
||||
app.formatFileSize = (n: number) => `${n} B`;
|
||||
app.showToast = vi.fn();
|
||||
app.filePreviewContent = '';
|
||||
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
|
||||
app._linkifyFilePaths = vi.fn();
|
||||
app._bindResponseViewerInteractions = vi.fn();
|
||||
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
|
||||
return {
|
||||
app,
|
||||
fetchMock,
|
||||
store,
|
||||
body: byId('filePreviewBody'),
|
||||
exts: (context as { __exts: Set<string> }).__exts,
|
||||
btn: { md: byId('filePreviewMdBtn'), lines: byId('filePreviewLinesBtn'), wrap: byId('filePreviewWrapBtn') },
|
||||
};
|
||||
}
|
||||
|
||||
describe('file viewer rendered markdown', () => {
|
||||
it('renders .md through the shared markdown pipeline, inert to i18n, with the viewer delegate bound', async () => {
|
||||
const { app, body, btn } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
const doc = body.firstElementChild as HTMLElement;
|
||||
expect(doc.matches('.rv-text.file-preview-md[data-i18n-skip]')).toBe(true);
|
||||
expect(doc.querySelector('h1')?.textContent).toBe('Title');
|
||||
// A file, not a chat message: source newlines inside a paragraph are not breaks.
|
||||
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
|
||||
// Identity, not deep equality: DOM nodes are compared by reference here.
|
||||
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
|
||||
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
|
||||
// The source stays what Copy copies.
|
||||
expect(app.filePreviewContent).toBe(MD_CONTENT);
|
||||
// MD is the only toggle that applies to a rendered document; Edit still offered.
|
||||
expect(btn.md.hidden).toBe(false);
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(btn.lines.hidden).toBe(true);
|
||||
expect(btn.wrap.hidden).toBe(true);
|
||||
expect(document.getElementById('filePreviewEditBtn')!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches the route ceiling for markdown and the 500-line cap for other text', async () => {
|
||||
const { app, fetchMock } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
|
||||
const urls = fetchMock.mock.calls.map((c) => c[0]);
|
||||
expect(urls[0]).toContain('lines=10000');
|
||||
expect(urls[1]).toContain('lines=500');
|
||||
});
|
||||
|
||||
it('rebases relative images and links onto the document directory and leaves the rest alone', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
const local = body.querySelector('img[alt="Alt A"]')!;
|
||||
expect(local.getAttribute('src')).toBe(`/api/sessions/s1/file-raw?path=${encodeURIComponent('docs/img/a.png')}`);
|
||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
||||
|
||||
const rel = body.querySelector('a.rv-path')!;
|
||||
expect(rel.getAttribute('data-path')).toBe('docs/guide/x.md');
|
||||
expect(rel.getAttribute('href')).toBe('#');
|
||||
expect(rel.hasAttribute('target')).toBe(false);
|
||||
expect(rel.hasAttribute('rel')).toBe(false);
|
||||
|
||||
const anchors = Array.from(body.querySelectorAll('a'));
|
||||
// `..` is collapsed against the document directory, so the title reads
|
||||
// CHANGELOG.md rather than docs/../CHANGELOG.md.
|
||||
const up = anchors.find((a) => a.textContent === 'up')!;
|
||||
expect(up.classList.contains('rv-path')).toBe(true);
|
||||
expect(up.getAttribute('data-path')).toBe('CHANGELOG.md');
|
||||
const fragment = anchors.find((a) => a.textContent === 't')!;
|
||||
expect(fragment.getAttribute('href')).toBe('#top');
|
||||
expect(fragment.classList.contains('rv-path')).toBe(false);
|
||||
const external = anchors.find((a) => a.textContent === 'e')!;
|
||||
expect(external.getAttribute('href')).toBe('https://e.com');
|
||||
expect(external.getAttribute('target')).toBe('_blank');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
|
||||
const { app, body } = loadApp();
|
||||
// An absolute path in the document's prose, linked by the Response
|
||||
// Viewer's linkifier, which knows nothing of the preview's session.
|
||||
app._linkifyFilePaths.mockImplementation((root: HTMLElement) => {
|
||||
const a = root.ownerDocument.createElement('a');
|
||||
a.className = 'rv-path';
|
||||
a.dataset.path = '/tmp/out/run.log';
|
||||
root.appendChild(a);
|
||||
});
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
|
||||
// Decoded once here, encoded once for the route: never `my%2520image.png`.
|
||||
expect(src('space')).toBe(raw('docs/my image.png'));
|
||||
expect(src('raw')).toBe(raw('docs/raw.png'));
|
||||
// A malformed escape keeps the ref as written.
|
||||
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
|
||||
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
|
||||
expect(src('root')).toBe(raw('assets/root.png'));
|
||||
expect(src('protorel')).toBe('//cdn.example.com/p.png');
|
||||
|
||||
const anchors = Array.from(body.querySelectorAll('a'));
|
||||
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
|
||||
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
|
||||
// Every rebased link, and every path the linkifier found in the prose,
|
||||
// names the preview's session, so the delegate opens it in that workspace
|
||||
// even when another tab is active.
|
||||
const rebased = body.querySelectorAll('a.rv-path');
|
||||
expect(rebased.length).toBe(5);
|
||||
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('degrades relative refs of an attachment opened by bare file name instead of resolving them in the workspace', async () => {
|
||||
const { app, body, fetchMock } = loadApp();
|
||||
|
||||
// An attachment card passes the registry's bare file name: the document's
|
||||
// directory is unknown, so `img/a.png` must not become the workspace root's.
|
||||
await app.openFilePreview('report.md', 's1', 'att-1');
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('/attachments/att-1/raw');
|
||||
expect(body.innerHTML).not.toContain('file-raw');
|
||||
// Relative and root-relative images are their alt text, as a text node.
|
||||
for (const alt of ['Alt A', 'space', 'raw', 'bad', 'root']) {
|
||||
expect(body.querySelector(`img[alt="${alt}"]`)).toBeNull();
|
||||
expect(body.textContent).toContain(alt);
|
||||
}
|
||||
// Remote images and links keep today's handling.
|
||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
||||
expect(body.querySelector('img[alt="protorel"]')!.getAttribute('src')).toBe('//cdn.example.com/p.png');
|
||||
// Relative links are unwrapped to their text; fragment and http(s) links stay.
|
||||
expect(body.querySelectorAll('a.rv-path')).toHaveLength(0);
|
||||
const anchors = Array.from(body.querySelectorAll('a')).map((a) => a.textContent);
|
||||
expect(anchors).toEqual(['t', 'e']);
|
||||
for (const text of ['x', 'up', 'cjk', 'rootlink']) expect(body.textContent).toContain(text);
|
||||
});
|
||||
|
||||
it('keeps resolving refs of an absolute-path attachment against its own directory', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('/tmp/out/report.md', 's1', 'att-2');
|
||||
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
expect(body.querySelector('img[alt="Alt A"]')!.getAttribute('src')).toBe(raw('/tmp/out/img/a.png'));
|
||||
const rel = Array.from(body.querySelectorAll('a.rv-path')).find((a) => a.textContent === 'x')!;
|
||||
expect(rel.getAttribute('data-path')).toBe('/tmp/out/guide/x.md');
|
||||
expect(rel.getAttribute('data-session-id')).toBe('s1');
|
||||
});
|
||||
|
||||
it('degrades an image that fails to load to its alt text', async () => {
|
||||
const { app, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const remote = body.querySelector('img[alt="remote"]')!;
|
||||
remote.dispatchEvent(new dom.window.Event('error'));
|
||||
|
||||
expect(body.querySelector('img[alt="remote"]')).toBeNull();
|
||||
expect(body.textContent).toContain('remote');
|
||||
});
|
||||
|
||||
it('MD toggle flips to per-line source and back without refetching, and persists', async () => {
|
||||
const { app, body, btn, fetchMock, store } = loadApp();
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
app.toggleFilePreviewMd();
|
||||
|
||||
const pre = body.firstElementChild as HTMLElement;
|
||||
expect(pre.matches('pre.file-preview-text')).toBe(true);
|
||||
expect(pre.querySelectorAll('.fp-line')).toHaveLength(MD_CONTENT.split('\n').length);
|
||||
expect(pre.textContent).toBe(MD_CONTENT);
|
||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('0');
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(btn.lines.hidden).toBe(false);
|
||||
expect(btn.wrap.hidden).toBe(false);
|
||||
|
||||
app.toggleFilePreviewMd();
|
||||
expect((body.firstElementChild as HTMLElement).matches('.file-preview-md')).toBe(true);
|
||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens as source when the device pref says so', async () => {
|
||||
const { app, body, btn } = loadApp({ 'codeman:filePreviewMdRendered': '0' });
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
expect((body.firstElementChild as HTMLElement).matches('pre.file-preview-text')).toBe(true);
|
||||
expect(btn.md.hidden).toBe(false);
|
||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file viewer Lines and Wrap toggles', () => {
|
||||
it('flip classes on the <pre>, persist, and never alter the text', async () => {
|
||||
const { app, body, btn, store } = loadApp();
|
||||
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
|
||||
const pre = body.firstElementChild as HTMLElement;
|
||||
expect(pre.matches('pre.file-preview-text.wrap:not(.show-lines)')).toBe(true);
|
||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
||||
expect(btn.md.hidden).toBe(true);
|
||||
|
||||
app.toggleFilePreviewLines();
|
||||
expect(pre.classList.contains('show-lines')).toBe(true);
|
||||
expect(store.get('codeman:filePreviewLineNumbers')).toBe('1');
|
||||
expect(btn.lines.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
app.toggleFilePreviewWrap();
|
||||
expect(pre.classList.contains('wrap')).toBe(false);
|
||||
expect(store.get('codeman:filePreviewWrap')).toBe('0');
|
||||
expect(btn.wrap.getAttribute('aria-pressed')).toBe('false');
|
||||
// Same element, no re-render: the counter gutter is CSS, not text.
|
||||
expect(body.firstElementChild).toBe(pre);
|
||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
||||
});
|
||||
|
||||
it('are hidden for an image and while editing', async () => {
|
||||
const { app, btn, body } = loadApp();
|
||||
|
||||
await app.openFilePreview('shot.png', 's1');
|
||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
||||
|
||||
await app.openFilePreview('notes.txt', 's1');
|
||||
expect(btn.lines.hidden).toBe(false);
|
||||
await app.enterFilePreviewEdit();
|
||||
expect(body.querySelector('textarea.file-preview-editor')).not.toBeNull();
|
||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** A vendored UMD build (or sanitize-html.js), evaluated as CommonJS the way the other suites do. */
|
||||
function loadCommonJs<T>(name: string): T {
|
||||
const module: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile(name))(module, module.exports);
|
||||
return module.exports as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SHIPPING pipeline end to end: app.js (`_renderMarkdown` and the Response
|
||||
* Viewer's message builder) with panels-ui.js mixed in, the vendored marked,
|
||||
* and DOMPurify behind the real sanitize-html.js config. `content` is what the
|
||||
* file-content route answers for every path.
|
||||
*/
|
||||
function loadShippingApp(content: string) {
|
||||
const createDOMPurify = loadCommonJs<(win: unknown) => unknown>('vendor/dompurify.min.js');
|
||||
const { createMarkdownSanitizer } = loadCommonJs<{ createMarkdownSanitizer: (dp: unknown) => unknown }>(
|
||||
'sanitize-html.js'
|
||||
);
|
||||
const context = vm.createContext({
|
||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
document,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
|
||||
// _sanitizeHtml fails closed without the page's sanitizer, which would make
|
||||
// every assertion below vacuous.
|
||||
window: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
sanitizeMarkdownHtml: createMarkdownSanitizer(createDOMPurify(dom.window)),
|
||||
},
|
||||
marked: loadCommonJs('vendor/marked.min.js'),
|
||||
MobileDetection: {},
|
||||
confirm: () => true,
|
||||
fetch: vi.fn(async () =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { content, totalLines: 2, size: content.length, truncated: false, extension: 'md' },
|
||||
})
|
||||
),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${constantsJs}\n${publicFile('app.js')}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context,
|
||||
{ filename: 'app.js' }
|
||||
);
|
||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
|
||||
mountPreviewDom();
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map();
|
||||
app.filePreviewContent = '';
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('file viewer markdown line breaks', () => {
|
||||
// A README hard-wrapped at the column limit: one paragraph in the source.
|
||||
const WRAPPED = 'A paragraph hard-wrapped\nat the column limit.';
|
||||
|
||||
it('renders a hard-wrapped paragraph as one paragraph in the file view, while chat keeps a break per newline', async () => {
|
||||
const app = loadShippingApp(`${WRAPPED}\n`);
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
const para = document.querySelector('#filePreviewBody .file-preview-md p')!;
|
||||
expect(para, 'the document rendered through marked').not.toBeNull();
|
||||
expect(para.querySelector('br')).toBeNull();
|
||||
expect(para.textContent).toBe(WRAPPED);
|
||||
|
||||
// The Response Viewer renders the same text the chat way, a <br> per newline.
|
||||
const message = app._buildResponseViewerMessage(WRAPPED, 'assistant', 'Claude') as HTMLElement;
|
||||
const chatPara = message.querySelector('.rv-text p')!;
|
||||
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
|
||||
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('FILE_PREVIEW_EXTENSIONS', () => {
|
||||
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
|
||||
const { exts } = loadApp();
|
||||
expect(exts.has('avif')).toBe(true);
|
||||
expect(exts.has('ico')).toBe(true);
|
||||
expect(exts.has('md')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* hook definitions for desktop notifications.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
ensureStatusLineExporterScript,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
|
||||
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
|
||||
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
|
||||
// A frozen clock makes every writer agree on the timestamp, which is what two
|
||||
// session creates in one millisecond do. A temp name built from pid + Date.now()
|
||||
// is then shared: the first rename consumes it, every later rename fails ENOENT,
|
||||
// and that session's tmux create fell back to a direct PTY.
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
try {
|
||||
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
|
||||
expect(new Set(paths)).toEqual(new Set([scriptPath]));
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
|
||||
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
|
||||
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
|
||||
expect(siblings).toEqual([]);
|
||||
});
|
||||
|
||||
it('never overrides a real, hand-authored statusLine', async () => {
|
||||
const claudeDir = join(testDir, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -165,6 +165,16 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
|
||||
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
||||
});
|
||||
}
|
||||
|
||||
// DOM clobbering: <img name="app"> makes document.app that image, and inline
|
||||
// onclick="app.…()" handlers resolve `app` on the document before the global,
|
||||
// so a rendered README could break every button until a reload.
|
||||
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
|
||||
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
|
||||
expect(out).not.toMatch(/\sname\s*=/i);
|
||||
expect(out).toContain('src="https://example.com/x.png"');
|
||||
expect(out).toContain('href="#a"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legitimate markdown-rendered HTML survives', () => {
|
||||
|
||||
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
|
||||
// either leaves inert paths (no linkify) or dead links (no handler).
|
||||
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
||||
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -681,6 +681,18 @@ describe('file-routes', () => {
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.type).toBe('image');
|
||||
});
|
||||
|
||||
it('returns audio metadata for audio files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||
|
||||
@@ -817,6 +829,19 @@ describe('file-routes', () => {
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
|
||||
const content = Buffer.from('fake avif data');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/avif');
|
||||
});
|
||||
|
||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* `selectSession()` acknowledges the session's idle approval item server-side
|
||||
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
|
||||
* what makes "I checked it" survive a reload and reach the user's other
|
||||
* devices. Three call sites are the APP choosing a session rather than the
|
||||
* user: the boot restore, a solo (popped-out) window opening its target, and
|
||||
* the fallback after the active session is deleted. Those pass `auto: true`
|
||||
* devices. Four call sites are the APP choosing a session rather than the
|
||||
* user: the boot restore, a solo (popped-out) window opening its target, a
|
||||
* `#session=<id>` link from another page, and the fallback after the active
|
||||
* session is deleted. Those pass `auto: true`
|
||||
* and must not spend the alert, or a yellow tab would clear itself every time
|
||||
* the page loaded and the user would never see it.
|
||||
*
|
||||
@@ -110,7 +111,7 @@ describe('selectSession acknowledgement gate', () => {
|
||||
});
|
||||
|
||||
describe('the call sites the app drives itself', () => {
|
||||
// Source guard: these three are the reason the flag exists. If a refactor
|
||||
// Source guard: these call sites are the reason the flag exists. If a refactor
|
||||
// moves or reformats them, fail loudly rather than silently going back to
|
||||
// "every page load clears the user's yellow tab".
|
||||
it.each([
|
||||
@@ -118,6 +119,7 @@ describe('selectSession acknowledgement gate', () => {
|
||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
|
||||
])('%s passes auto: true', (_label, call) => {
|
||||
expect(APP_SOURCE).toContain(call);
|
||||
});
|
||||
|
||||
@@ -11,17 +11,31 @@
|
||||
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
||||
// than dropped, because the in-flight fetch may predate the drop the new frame
|
||||
// reports and no further frame comes to correct stale content.
|
||||
//
|
||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
||||
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
|
||||
let clock = 0;
|
||||
|
||||
type FakeTerminal = {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
scrollToLine: ReturnType<typeof vi.fn>;
|
||||
scrollToTop: ReturnType<typeof vi.fn>;
|
||||
cols: number;
|
||||
rows: number;
|
||||
options: { scrollback: number };
|
||||
buffer: { active: { type: string; viewportY: number; length: number } };
|
||||
};
|
||||
type FakeSocket = {
|
||||
onopen: unknown;
|
||||
@@ -36,43 +50,80 @@ type PaneUnderTest = {
|
||||
_destroyed: boolean;
|
||||
_bufferLoading: boolean;
|
||||
_bufferRefreshPending: boolean;
|
||||
_historyPullAt: number;
|
||||
_historyPullUseless: boolean;
|
||||
_liveQueue: unknown[] | null;
|
||||
_onWheel: unknown;
|
||||
_wsClosed: boolean;
|
||||
detachedSessions: Set<string> | undefined;
|
||||
destroy(): void;
|
||||
_loadBuffer(): Promise<void>;
|
||||
_refreshBuffer(): void;
|
||||
_maybeLoadMoreHistory(): void;
|
||||
_pullHistory(): Promise<void>;
|
||||
_onLiveOutput(data: string): void;
|
||||
_onLiveClear(): void;
|
||||
_installWheelListener(): void;
|
||||
_writeDisconnectedMarker(): void;
|
||||
_onSocketClosed(): void;
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||
const rafQueue: Array<() => void> = [];
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
||||
|
||||
function loadSplitTerminalPane() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
window: {},
|
||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
||||
// compares it with the pane's own row count.
|
||||
window: {
|
||||
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
|
||||
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
|
||||
},
|
||||
performance: { now: () => clock },
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||
// The constants.js globals the module reads at call time.
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
TERMINAL_TAIL_SIZE: 1024 * 1024,
|
||||
TERMINAL_TAIL_SIZE,
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
|
||||
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', {}, { mode });
|
||||
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
|
||||
function makePane(
|
||||
mode = 'claude',
|
||||
mount: unknown = {},
|
||||
opts: { detachedSessions?: Set<string> } = {}
|
||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
||||
pane.terminal = {
|
||||
// xterm invokes a write's callback once everything before it is parsed.
|
||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||
clear: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
scrollToLine: vi.fn(),
|
||||
scrollToTop: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
// xterm keeps at most `scrollback + rows` rows; small here so a test can fill it.
|
||||
options: { scrollback: 1000 },
|
||||
// A pane sitting at the top of a 40-row buffer on the normal screen.
|
||||
buffer: { active: { type: 'normal', viewportY: 0, length: 40 } },
|
||||
};
|
||||
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
||||
}
|
||||
|
||||
function jsonResponse(terminalBuffer: string) {
|
||||
return { json: async () => ({ data: { terminalBuffer } }) };
|
||||
const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n');
|
||||
|
||||
function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {}) {
|
||||
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -83,12 +134,15 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
|
||||
|
||||
/** Lets every microtask the vm-side promise chain queued run. */
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
rafQueue.length = 0;
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
@@ -232,3 +286,513 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
// The replay grows the buffer once xterm has parsed it (the empty write's callback).
|
||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
||||
if (data === '' && done) term.buffer.active.length = 140;
|
||||
done?.();
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// With a deadline: live output is held for as long as the pull runs, so a
|
||||
// request that never answers would freeze the pane.
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||
signal: { timeoutMs: 10_000 },
|
||||
});
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
// What was row 0 is now 100 rows down (140 - 40): the reader keeps their
|
||||
// place with the recovered history above it, instead of being dropped at the bottom.
|
||||
expect(term.scrollToLine).toHaveBeenCalledWith(100);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing away from the top, for other modes, or on the alternate screen', async () => {
|
||||
const midScroll = makePane('shell');
|
||||
midScroll.terminal.buffer.active.viewportY = 12;
|
||||
midScroll._maybeLoadMoreHistory();
|
||||
|
||||
// A repaint-mode agent CLI keeps no tmux history to recover.
|
||||
makePane('claude')._maybeLoadMoreHistory();
|
||||
|
||||
// nano/vim/less own the wheel; their screen is not scrollback.
|
||||
const fullScreenApp = makePane('shell');
|
||||
fullScreenApp.terminal.buffer.active.type = 'alternate';
|
||||
fullScreenApp._maybeLoadMoreHistory();
|
||||
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stands aside for a detached session, mirroring _sendResize()', async () => {
|
||||
// A detached session's own window already owns its PTY size and
|
||||
// scrollback (buildSplitPickerSessions() already refuses to open one).
|
||||
const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) });
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
const startedAt = pane._historyPullAt;
|
||||
// The cooldown is cleared between triggers on purpose, so that only the
|
||||
// in-flight guard can be what drops the overlapping ones.
|
||||
pane._historyPullAt = 0;
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._historyPullAt = 0;
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
pane._historyPullAt = startedAt;
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
|
||||
// Nothing in flight any more, so now it is the 4s cooldown alone.
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the cooldown lapses a later scroll-to-top may pull again.
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
pane._historyPullAt = Date.now() - 5000;
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
// A reset+rewrite here would jump the viewport for no new rows.
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane.terminal.scrollToLine).not.toHaveBeenCalled();
|
||||
expect(pane.terminal.scrollToTop).not.toHaveBeenCalled();
|
||||
// The next burst can put more history in tmux than the pane has.
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => {
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 500;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
// Untruncated: tmux has nothing older, but the next burst can add history.
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
});
|
||||
|
||||
it('a truncated window that fits in the pane backs off for a minute', async () => {
|
||||
// Every ask costs the server a capture-pane of the WHOLE history (`tail` is
|
||||
// cut after the capture), and a window cut at the tail size can never reach
|
||||
// anything older than what the pane already shows.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 500;
|
||||
// Within a screen of what the pane holds, so the old downgrade guard never
|
||||
// latched it: only the truncated-skip rule can back this off.
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' }));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane._historyPullUseless).toBe(true);
|
||||
|
||||
// Inside the 60s back-off, well past the normal 4s cooldown.
|
||||
pane._historyPullAt = Date.now() - 10_000;
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => {
|
||||
// A 1 MiB window of short lines can carry more rows than xterm will ever hold
|
||||
// (`scrollback + rows`), so `incoming <= rows held` never comes true and every
|
||||
// scroll-to-top would reset and re-parse it.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = 1030;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000)));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000));
|
||||
expect(pane._historyPullUseless).toBe(true);
|
||||
});
|
||||
|
||||
it('a successful replay clears the one-minute back-off', async () => {
|
||||
const pane = makePane('shell');
|
||||
pane._historyPullUseless = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' }));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100));
|
||||
expect(pane._historyPullUseless).toBe(false);
|
||||
});
|
||||
|
||||
it('holds live output during the replay and replays only what arrived after the capture', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
expect(pane._liveQueue).toEqual([]);
|
||||
|
||||
// Arrives before the response does: it is IN the capture already.
|
||||
clock = 1;
|
||||
pane._onLiveOutput('early');
|
||||
expect(term.write).not.toHaveBeenCalledWith('early');
|
||||
await settle();
|
||||
|
||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
||||
// three chunks, which leaves the replay mid-write once the fetch lands.
|
||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
||||
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
||||
clock = 2; // the response arrives: this is the cutoff
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
await settle();
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
|
||||
// Arrives while the snapshot is still being written: must not land under it.
|
||||
clock = 3;
|
||||
pane._onLiveOutput('late');
|
||||
expect(term.write).not.toHaveBeenCalledWith('late');
|
||||
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
await settle();
|
||||
|
||||
const written = term.write.mock.calls.map((call) => call[0]);
|
||||
expect(written).not.toContain('early');
|
||||
expect(written.at(-1)).toBe('late');
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('writes every held frame when the pull ends without replaying', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
// Nothing replaced the terminal, so the frame is news even though it
|
||||
// arrived before the response did.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
pane._onLiveOutput('after');
|
||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
||||
});
|
||||
|
||||
it('a refresh frame during the pull runs once behind it', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._refreshBuffer();
|
||||
expect(pane.terminal.clear).not.toHaveBeenCalled();
|
||||
expect(pane._bufferRefreshPending).toBe(true);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30)));
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
|
||||
});
|
||||
|
||||
it('a clear frame during the pull is queued in order, never applied under the replay', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const order: string[] = [];
|
||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
||||
order.push(`write:${data}`);
|
||||
done?.();
|
||||
});
|
||||
term.clear.mockImplementation(() => order.push('clear'));
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('before');
|
||||
pane._onLiveClear();
|
||||
pane._onLiveOutput('after');
|
||||
// Held: clearing now would wipe a half-written snapshot.
|
||||
expect(order).toEqual([]);
|
||||
|
||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
||||
await settle();
|
||||
|
||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
|
||||
// With nothing in flight a clear frame applies straight away.
|
||||
pane._onLiveClear();
|
||||
expect(order.at(-1)).toBe('clear');
|
||||
});
|
||||
|
||||
it('a clear that arrived before the capture is not replayed after it', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
clock = 1;
|
||||
pane._onLiveClear(); // already reflected in the capture
|
||||
clock = 2;
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
pane.destroy();
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal).toBeNull();
|
||||
expect(term.write).not.toHaveBeenCalledWith('\x1bc');
|
||||
expect(term.write).not.toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
|
||||
|
||||
pane._maybeLoadMoreHistory();
|
||||
pane._onLiveOutput('held');
|
||||
await settle();
|
||||
|
||||
expect(pane._bufferLoading).toBe(false);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
||||
});
|
||||
|
||||
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
|
||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const pane = makePane('shell', mount);
|
||||
fetchMock.mockResolvedValue(jsonResponse(rowsOf(100)));
|
||||
|
||||
pane._installWheelListener();
|
||||
|
||||
// Capture phase: xterm's own wheel handler stopPropagation()s the events it
|
||||
// consumes, so a bubbling listener would never fire while the pane still has
|
||||
// scrollback to scroll, and the pull would work only from the exact top row.
|
||||
const [type, listener, options] = mount.addEventListener.mock.calls[0];
|
||||
expect(type).toBe('wheel');
|
||||
expect(options).toEqual({ capture: true, passive: true });
|
||||
|
||||
listener({ deltaY: 120 }); // wheel down
|
||||
listener({ deltaY: 0 });
|
||||
await settle();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
listener({ deltaY: -120 }); // wheel up, at the top
|
||||
await settle();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('destroy() detaches exactly the wheel listener it registered', () => {
|
||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const pane = makePane('shell', mount);
|
||||
pane._installWheelListener();
|
||||
const registered = mount.addEventListener.mock.calls[0][1];
|
||||
|
||||
pane.destroy();
|
||||
|
||||
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true });
|
||||
expect(pane._onWheel).toBeNull();
|
||||
});
|
||||
|
||||
it('connect() installs the wheel listener (static guard)', () => {
|
||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
||||
// rather than executed; the listener's behaviour is exercised above.
|
||||
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
|
||||
expect(connect).toContain('this._installWheelListener();');
|
||||
expect(connect).toContain('this._onLiveClear();');
|
||||
expect(connect).not.toContain('this.terminal.clear();');
|
||||
// The tests below drive the close through _onSocketClosed() directly.
|
||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
||||
});
|
||||
|
||||
it('a close with no pull running writes the marker straight away', () => {
|
||||
const pane = makePane('shell');
|
||||
|
||||
pane._onSocketClosed();
|
||||
|
||||
expect(pane._wsClosed).toBe(true);
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
||||
expect(isMarker(pane.terminal.write.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => {
|
||||
// onclose already wrote the marker once; a replay's own `\x1bc` would wipe
|
||||
// it and paint a fresh, current-looking history while onData keeps
|
||||
// silently dropping every keystroke on the dead socket.
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
const marker = expect.stringContaining('Pane B disconnected');
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
|
||||
// The close lands while the capture is in flight, so the HTTP pull still
|
||||
// succeeds (a Codeman restart drops the WS while the tmux session, and so
|
||||
// the pull, survives) and the replay that follows is what the marker must
|
||||
// end up below.
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
response.resolve(jsonResponse(rowsOf(100)));
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
|
||||
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
|
||||
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
|
||||
])(
|
||||
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
|
||||
async (_label, rowsHeld, mockFetch) => {
|
||||
// No replay ever runs here, so nothing would wipe a marker written at the
|
||||
// close; written straight away it sat ABOVE the output the pull was still
|
||||
// holding, which the finally block then flushed underneath it.
|
||||
const pane = makePane('shell');
|
||||
pane.terminal.buffer.active.length = rowsHeld;
|
||||
mockFetch();
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
pane._onLiveOutput('frame-A');
|
||||
pane._onLiveOutput('frame-B');
|
||||
pane._onSocketClosed();
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes.slice(0, 2)).toEqual(['frame-A', 'frame-B']);
|
||||
expect(writes).toHaveLength(3);
|
||||
expect(isMarker(writes[2])).toBe(true);
|
||||
expect(pane._liveQueue).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('a close during the chunked replay writes exactly one marker, at the end', async () => {
|
||||
const pane = makePane('shell');
|
||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
||||
fetchMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
const pull = pane._pullHistory();
|
||||
// Three chunks, so the replay is still mid-write once the fetch lands.
|
||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
||||
response.resolve(jsonResponse(bigReplay));
|
||||
await settle();
|
||||
expect(rafQueue).toHaveLength(1);
|
||||
|
||||
// Written now, the marker would land between two chunks of recovered history.
|
||||
pane._onSocketClosed();
|
||||
rafQueue.shift()!();
|
||||
rafQueue.shift()!();
|
||||
await pull;
|
||||
|
||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||
expect(writes[0]).toBe('\x1bc');
|
||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
||||
expect(isMarker(writes.at(-1))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the socket is still open', async () => {
|
||||
const pane = makePane('shell');
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
for (const call of pane.terminal.write.mock.calls) {
|
||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => {
|
||||
// Nothing erased the marker in this path, so re-stamping it would be a
|
||||
// second, redundant write.
|
||||
const pane = makePane('shell');
|
||||
pane._wsClosed = true;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay
|
||||
|
||||
void pane._pullHistory();
|
||||
await settle();
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// test/url-session-fragment.test.ts
|
||||
// Port: N/A (no server/browser — loads constants.js and app.js via `vm`, like session-select-ack-gate.test.ts).
|
||||
//
|
||||
// A page that holds the dashboard's window switches its tab with a
|
||||
// `#session=<id>` link, and sessionIdFromFragment() is what reads the link.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {}, URLSearchParams });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanUrlSession: { sessionIdFromFragment: (hash: unknown) => string | null } })
|
||||
.CodemanUrlSession;
|
||||
}
|
||||
|
||||
describe('CodemanUrlSession.sessionIdFromFragment', () => {
|
||||
const { sessionIdFromFragment } = loadHelper();
|
||||
|
||||
it('reads the id from a #session= fragment', () => {
|
||||
expect(sessionIdFromFragment('#session=76763752-fa3a-40aa-a025-e1684c82d00e')).toBe(
|
||||
'76763752-fa3a-40aa-a025-e1684c82d00e'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the fragment without its leading #', () => {
|
||||
expect(sessionIdFromFragment('session=abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('decodes an encoded id', () => {
|
||||
expect(sessionIdFromFragment('#session=' + encodeURIComponent('w1 my/app'))).toBe('w1 my/app');
|
||||
});
|
||||
|
||||
it('finds the id beside other fragment parameters', () => {
|
||||
expect(sessionIdFromFragment('#tab=2&session=abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('asks for nothing when the fragment names no session', () => {
|
||||
expect(sessionIdFromFragment('')).toBeNull();
|
||||
expect(sessionIdFromFragment('#')).toBeNull();
|
||||
expect(sessionIdFromFragment('#settings')).toBeNull();
|
||||
expect(sessionIdFromFragment('#session=')).toBeNull();
|
||||
expect(sessionIdFromFragment('#session=%20')).toBeNull();
|
||||
expect(sessionIdFromFragment(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The dashboard side: reading the link, holding an id it does not list yet,
|
||||
// and handing the selection over. Loaded like session-select-ack-gate.test.ts,
|
||||
// on a bare instance whose DOM-touching methods are stubbed. webview-tabs.js
|
||||
// rides along because opening a web tab is one of the ways a waiting link ends.
|
||||
function loadApp() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const webviewTabs = readFileSync(resolve(import.meta.dirname, '../src/web/public/webview-tabs.js'), 'utf8');
|
||||
const location = { hash: '', pathname: '/', search: '' };
|
||||
const history = {
|
||||
state: null,
|
||||
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
|
||||
location.hash = url.includes('#') ? url.slice(url.indexOf('#')) : '';
|
||||
}),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
URLSearchParams,
|
||||
location,
|
||||
history,
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${constants}\n${app}\n${webviewTabs}\nglobalThis.__CodemanApp = CodemanApp;\nglobalThis.__waitMs = URL_SESSION_WAIT_MS;`,
|
||||
context
|
||||
);
|
||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
const waitMs = (context as { __waitMs: number }).__waitMs;
|
||||
const make = (ids: string[]) => {
|
||||
const inst = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
inst.sessions = new Map(ids.map((id) => [id, { id, name: id }]));
|
||||
inst.sessionOrder = [...ids];
|
||||
inst.detachedSessions = new Set();
|
||||
inst.detachedWindows = new Map();
|
||||
inst.isSoloWindow = false;
|
||||
inst._urlSessionId = null;
|
||||
inst._urlSessionWaitTimer = null;
|
||||
inst.selectSession = vi.fn();
|
||||
inst.showToast = vi.fn();
|
||||
for (const stub of [
|
||||
'saveSessionOrder',
|
||||
'markSessionTabEntering',
|
||||
'markTerminalEntering',
|
||||
'renderSessionTabs',
|
||||
'updateCost',
|
||||
'startSystemStatsPolling',
|
||||
]) {
|
||||
inst[stub] = vi.fn();
|
||||
}
|
||||
return inst;
|
||||
};
|
||||
return { make, location, history, CodemanApp, waitMs };
|
||||
}
|
||||
|
||||
describe('dashboard handling of a #session=<id> link', () => {
|
||||
it('reads the link and removes the fragment, so the same link counts as a change next time', () => {
|
||||
const { make, location, history } = loadApp();
|
||||
const app = make(['a']);
|
||||
location.hash = '#session=a';
|
||||
expect(app._takeUrlSession()).toBe('a');
|
||||
expect(history.replaceState).toHaveBeenCalledWith(null, '', '/');
|
||||
expect(location.hash).toBe('');
|
||||
});
|
||||
|
||||
it('leaves a URL without a session link alone', () => {
|
||||
const { make, location, history } = loadApp();
|
||||
location.hash = '#settings';
|
||||
expect(make([])._takeUrlSession()).toBeNull();
|
||||
expect(history.replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a listed session as an app selection, which leaves its idle alert armed', () => {
|
||||
const { make } = loadApp();
|
||||
const app = make(['a']);
|
||||
app._urlSessionId = 'a';
|
||||
expect(app._selectUrlSession()).toBe(true);
|
||||
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('holds an unlisted id until session:created names it', () => {
|
||||
const { make } = loadApp();
|
||||
const app = make([]);
|
||||
app._urlSessionId = 'new';
|
||||
expect(app._selectUrlSession()).toBe(false);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
app._onSessionCreated({ id: 'other', name: 'other' });
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('retires a waiting link when you pick another tab yourself', async () => {
|
||||
const { make, CodemanApp } = loadApp();
|
||||
const app = make(['a', 'b']);
|
||||
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
|
||||
app._urlSessionId = 'later';
|
||||
await app.selectSession('b').catch(() => {});
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a waiting link through a selection the app makes itself', async () => {
|
||||
const { make, CodemanApp } = loadApp();
|
||||
const app = make(['a', 'b']);
|
||||
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
|
||||
app._urlSessionId = 'later';
|
||||
await app.selectSession('b', { auto: true }).catch(() => {});
|
||||
expect(app._urlSessionId).toBe('later');
|
||||
});
|
||||
|
||||
it('starts the wait for an unlisted link once the page has loaded its session list', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
|
||||
const wait = source.indexOf('if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);');
|
||||
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
|
||||
expect(wait).toBeGreaterThan(link);
|
||||
expect(wait).toBeLessThan(restore);
|
||||
});
|
||||
|
||||
it('puts the link ahead of restoring the last active tab when the page loads', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
|
||||
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
|
||||
expect(link).toBeGreaterThan(-1);
|
||||
expect(link).toBeLessThan(restore);
|
||||
});
|
||||
|
||||
it('never reads the link in a solo window', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
expect(source).toContain('this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();');
|
||||
expect(source).toMatch(/if \(!this\.isSoloWindow\) \{\s*window\.addEventListener\('hashchange'/);
|
||||
});
|
||||
});
|
||||
|
||||
// A link whose session never turns up: a stale link (the session is closed), a
|
||||
// typo, or in multi-user mode another user's session, which is never in this
|
||||
// client's list. It must not wait forever with nothing on screen, and choosing
|
||||
// something else must end it, or a session turning up later takes the screen.
|
||||
describe('a #session=<id> link that is still waiting', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits 30 seconds', () => {
|
||||
expect(loadApp().waitMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it('is dropped with a toast when its session has not appeared in time', () => {
|
||||
vi.useFakeTimers();
|
||||
const { make, waitMs } = loadApp();
|
||||
const app = make([]);
|
||||
app._urlSessionId = 'gone';
|
||||
expect(app._selectUrlSession()).toBe(false);
|
||||
vi.advanceTimersByTime(waitMs - 1);
|
||||
expect(app._urlSessionId).toBe('gone');
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
expect(app._urlSessionWaitTimer).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Session not found', 'warning');
|
||||
// Retired for good: the session turning up afterwards does not take the tab.
|
||||
app._onSessionCreated({ id: 'gone', name: 'gone' });
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still selects a session that arrives before the wait runs out, and stops the timer', () => {
|
||||
vi.useFakeTimers();
|
||||
const { make, waitMs } = loadApp();
|
||||
const app = make([]);
|
||||
app._urlSessionId = 'new';
|
||||
app._selectUrlSession();
|
||||
vi.advanceTimersByTime(waitMs - 1);
|
||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
||||
expect(app._urlSessionWaitTimer).toBeNull();
|
||||
vi.advanceTimersByTime(waitMs);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restart its wait when handleInit asks again', () => {
|
||||
vi.useFakeTimers();
|
||||
const { make, waitMs } = loadApp();
|
||||
const app = make([]);
|
||||
app._urlSessionId = 'gone';
|
||||
app._armUrlSessionWait('gone');
|
||||
vi.advanceTimersByTime(waitMs - 1000);
|
||||
app._armUrlSessionWait('gone');
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is retired by going Home', () => {
|
||||
vi.useFakeTimers();
|
||||
const { make, waitMs } = loadApp();
|
||||
const app = make([]);
|
||||
app.terminal = { clear: vi.fn() };
|
||||
app.showWelcome = vi.fn();
|
||||
app.renderRalphStatePanel = vi.fn();
|
||||
app._urlSessionId = 'later';
|
||||
app._selectUrlSession();
|
||||
app.goHome();
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
expect(app._urlSessionWaitTimer).toBeNull();
|
||||
app._onSessionCreated({ id: 'later', name: 'later' });
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(waitMs);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function withWebTab(app: Record<string, any>) {
|
||||
const webview = { id: 'dash', name: 'Dash', url: 'http://127.0.0.1:8080/' };
|
||||
app.webviews = new Map([['dash', webview]]);
|
||||
app.webviewOrder = ['dash'];
|
||||
app._persistWebviewOrder = vi.fn();
|
||||
app._apiJson = vi.fn(async () => ({ webview, embedUrl: '/webview/cap/' }));
|
||||
app._mountWebviewFrame = vi.fn();
|
||||
app.hideWelcome = vi.fn();
|
||||
app._updateActiveWebviewTab = vi.fn();
|
||||
app.closeSessionSidebarOnHandheld = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('is retired by opening a web tab', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { make, waitMs } = loadApp();
|
||||
const app = withWebTab(make([]));
|
||||
app._urlSessionId = 'later';
|
||||
app._selectUrlSession();
|
||||
const opening = app.openWebview('dash');
|
||||
// Before the open's await: a session:created landing inside it finds no link.
|
||||
expect(app._urlSessionId).toBeNull();
|
||||
expect(app._urlSessionWaitTimer).toBeNull();
|
||||
await opening;
|
||||
expect(app.activeWebviewId).toBe('dash');
|
||||
app._onSessionCreated({ id: 'later', name: 'later' });
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(waitMs);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('survives a web tab the app opens itself', async () => {
|
||||
const { make } = loadApp();
|
||||
const app = withWebTab(make([]));
|
||||
app._urlSessionId = 'later';
|
||||
await app.openWebview('dash', { auto: true });
|
||||
expect(app._urlSessionId).toBe('later');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user