mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Merge the reviewed contributor PRs for 1.40.0
Thirteen contributor PRs, each re-checked against its GitHub head, merged with its own merge commit and landing fixes, reviewed, and gated together (524 test files, 10194 tests): #559, #552, #550, #556, #551, #546, #542, #540, #555, #543, #541, #502, #432. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,9 @@ npm run test:all # literally everything, environmental failures included
|
|||||||
|
|
||||||
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
||||||
|
|
||||||
If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000.
|
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
|
||||||
|
|
||||||
|
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
|
||||||
|
|
||||||
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Browser suite
|
||||||
|
|
||||||
|
# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
|
||||||
|
# so a browser-only regression can merge green. This job runs that suite on a schedule and on
|
||||||
|
# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
|
||||||
|
# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
|
||||||
|
# push or a PR.
|
||||||
|
#
|
||||||
|
# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
|
||||||
|
# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
|
||||||
|
# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '29 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
browser:
|
||||||
|
name: Playwright browser suite
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install tmux
|
||||||
|
run: |
|
||||||
|
if ! command -v tmux >/dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y tmux
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install chromium
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run the browser suite
|
||||||
|
run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
|
||||||
@@ -219,6 +219,8 @@ jobs:
|
|||||||
run: npx vitest run
|
run: npx vitest run
|
||||||
working-directory: packages/xterm-zerolag-input
|
working-directory: packages/xterm-zerolag-input
|
||||||
|
|
||||||
|
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
|
||||||
|
# that job is informational and never gates a push or a PR.
|
||||||
# Note: three suites are excluded from CI, each with its own local runner:
|
# Note: three suites are excluded from CI, each with its own local runner:
|
||||||
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
||||||
# codex binary for codex-predictive-echo)
|
# codex binary for codex-predictive-echo)
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ src/web/public/settings-ui.js
|
|||||||
src/web/public/sw.js
|
src/web/public/sw.js
|
||||||
src/web/public/terminal-ui.js
|
src/web/public/terminal-ui.js
|
||||||
src/web/public/voice-input.js
|
src/web/public/voice-input.js
|
||||||
src/web/public/upload.html
|
|
||||||
scripts/remotion/
|
scripts/remotion/
|
||||||
|
|
||||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ Quick pointers:
|
|||||||
|
|
||||||
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
|
||||||
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
|
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
|
||||||
- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =`
|
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
|
||||||
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||||
- Never commit secrets or local state from `~/.codeman/`
|
- Never commit secrets or local state from `~/.codeman/`
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/git-status.browser.test.ts',
|
'test/git-status.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.browser.test.ts',
|
'test/split-pane-auto-collapse.browser.test.ts',
|
||||||
|
'test/spreadsheet-preview.browser.test.ts',
|
||||||
'test/mobile-ime-preview.browser.test.ts',
|
'test/mobile-ime-preview.browser.test.ts',
|
||||||
|
'test/run-mode-menu-scroll.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+11
-2
@@ -45,6 +45,13 @@ payload return `{ "success": true, "data": {} }`.
|
|||||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||||
|
>
|
||||||
|
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
|
||||||
|
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
|
||||||
|
> use. They are removed in a later MAJOR, after at least one MINOR release that
|
||||||
|
> carries this warning (see `docs/versioning-policy.md`). To hand
|
||||||
|
> a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into
|
||||||
|
> that session's workspace.
|
||||||
|
|
||||||
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
||||||
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||||||
@@ -764,11 +771,13 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
|
|||||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||||
|
|
||||||
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||||
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||||
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||||
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||||
|
|
||||||
`data` is `{ state, repos, reposTruncated, checkedAt }`:
|
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
|
||||||
|
|
||||||
|
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
|
||||||
|
|
||||||
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||||
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
|||||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||||
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
|
|||||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||||
|
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
|
|||||||
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
||||||
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
||||||
- 40ms SGR coalescing: never send per-event writes to the server.
|
- 40ms SGR coalescing: never send per-event writes to the server.
|
||||||
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
|
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
|
||||||
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
|
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
|
||||||
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
||||||
|
|
||||||
## Testing (per repo rules)
|
## Testing (per repo rules)
|
||||||
|
|||||||
@@ -354,8 +354,9 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
|
|||||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||||
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||||
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||||
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
|
||||||
realpath containment.
|
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
|
||||||
|
other text), not realpath containment.
|
||||||
|
|
||||||
Two registration paths, with **different trust**:
|
Two registration paths, with **different trust**:
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
|
|||||||
|
|
||||||
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
||||||
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
||||||
|
⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
|
||||||
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
||||||
|
|
||||||
### 2.6 Helpers that already exist and should be reused
|
### 2.6 Helpers that already exist and should be reused
|
||||||
@@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
|
|||||||
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
||||||
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
||||||
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
||||||
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||||
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
||||||
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
|
|||||||
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
||||||
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
||||||
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
||||||
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
|
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
|
||||||
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
||||||
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
|||||||
programmatically is not supported (there is no stable library entry point).
|
programmatically is not supported (there is no stable library entry point).
|
||||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||||
Gesture Control (beta), Agent Teams
|
Gesture Control (beta), Agent Teams
|
||||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||||
in the UI or docs. These may change or be removed at any time.
|
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
|
||||||
|
anything labeled experimental in the UI or docs. These may change or be
|
||||||
|
removed at any time.
|
||||||
|
|
||||||
## Deprecation policy
|
## Deprecation policy
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Co
|
|||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
|
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
|
||||||
|
controls. Enter a model ID supported by your Codex provider; available reasoning levels
|
||||||
|
depend on the model and CLI version. Empty defaults use Codex's own configuration.
|
||||||
|
The defaults apply to local Codex sessions started from the Run menu, from Resume, and through
|
||||||
|
`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them.
|
||||||
|
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
|
||||||
|
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
|
||||||
|
Changing a default affects new sessions and does not edit Codex configuration files.
|
||||||
|
|
||||||
Two behaviours that are deliberate and worth knowing:
|
Two behaviours that are deliberate and worth knowing:
|
||||||
|
|
||||||
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ npm run lint
|
|||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax
|
npm run check:frontend-syntax
|
||||||
npm run check:browser-excludes
|
npm run check:browser-excludes
|
||||||
npm test -- test/<file>.test.ts # one file, the normal way
|
npm test # the gate, exactly what CI runs
|
||||||
npm run test:ci # the full CI sweep
|
npm test -- test/<file>.test.ts # one file
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||||
@@ -53,13 +53,19 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan
|
|||||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||||
`pre-push` hook of your own is never overwritten.
|
`pre-push` hook of your own is never overwritten.
|
||||||
|
|
||||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
|
||||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
|
||||||
|
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
|
||||||
|
benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot
|
||||||
|
provide what they need; that means "not runnable here", not a regression.
|
||||||
|
|
||||||
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
|
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
|
||||||
tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at
|
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
|
||||||
3150 or above, and never 3000.
|
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
|
||||||
|
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
|
||||||
|
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
|
||||||
|
because that helper caches servers by port.
|
||||||
|
|
||||||
## Finding your way around
|
## Finding your way around
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,15 @@ Ultracode Windows, Cron.
|
|||||||
|
|
||||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||||
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||||
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
|
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
|
||||||
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
|
committed and pushed. Click it for the Git window; see
|
||||||
|
[Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||||
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||||
window; off lists every file by its full path.
|
window; off lists every file by its full path. **Git status: max repositories** (per device,
|
||||||
|
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||||
|
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
|
||||||
|
long one git command may run before that repository is reported as unreadable; raise it for
|
||||||
|
repositories on a slow network share.
|
||||||
|
|
||||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||||
@@ -133,7 +138,9 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
|||||||
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
||||||
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||||
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
|
||||||
|
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
|
||||||
|
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
|
||||||
| Animated status effects | Cosmetic. |
|
| Animated status effects | Cosmetic. |
|
||||||
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
|
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
|
||||||
|
|
||||||
@@ -158,7 +165,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||||
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
mode, the **Users** administration entry is injected here.
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ Worth knowing:
|
|||||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||||
always local scrollback. Other CLIs scroll locally.
|
always local scrollback. OpenCode's wheel and swipes page its own conversation
|
||||||
|
(PageUp/PageDown). Other CLIs scroll locally.
|
||||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||||
`Ctrl+Shift+C` always copies.
|
`Ctrl+Shift+C` always copies.
|
||||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ It renders what it can:
|
|||||||
| 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. |
|
| 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. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||||
|
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
|
||||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
| Anything else | Download. |
|
| Anything else | Download. |
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ surface as an artifact attachment rather than a path you have to go and find.
|
|||||||
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||||
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||||
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||||
conflicts, `✓` when everything is committed and pushed.
|
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
|
||||||
|
|
||||||
Click it for a draggable window, in the style of the File Viewer:
|
Click it for a draggable window, in the style of the File Viewer:
|
||||||
|
|
||||||
@@ -187,7 +188,10 @@ Click it for a draggable window, in the style of the File Viewer:
|
|||||||
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||||
note instead, and a diff over 400 KB is cut short.
|
note instead, and a diff over 400 KB is cut short.
|
||||||
- A session folder that holds several projects gets one collapsible section per repository
|
- A session folder that holds several projects gets one collapsible section per repository
|
||||||
found up to two levels down. They all start collapsed (each summary line shows its branch and
|
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
|
||||||
|
when there are more). A repository git could not read, typically a timeout on a slow network
|
||||||
|
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
|
||||||
|
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
|
||||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||||
in your home folder) is ignored.
|
in your home folder) is ignored.
|
||||||
|
|
||||||
|
|||||||
Generated
+1004
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,8 @@
|
|||||||
"agent-browser": "^0.6.0",
|
"agent-browser": "^0.6.0",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"exceljs": "4.4.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.58.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
|
|||||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||||
|
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
|
||||||
|
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
|
||||||
|
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
|
||||||
|
|
||||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||||
appendFileSync(
|
appendFileSync(
|
||||||
@@ -129,6 +132,7 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'api-client.js',
|
'api-client.js',
|
||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
'image-input.js',
|
'image-input.js',
|
||||||
|
'spreadsheet-preview.js',
|
||||||
'vendor/xterm-zerolag-input.js',
|
'vendor/xterm-zerolag-input.js',
|
||||||
'vendor/xterm-predictive-echo.js',
|
'vendor/xterm-predictive-echo.js',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|||||||
const publicRoot = resolve(repoRoot, 'src/web/public');
|
const publicRoot = resolve(repoRoot, 'src/web/public');
|
||||||
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||||
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
||||||
|
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
|
||||||
|
// They load only inside the spreadsheet worker, but a dependency bump that
|
||||||
|
// balloons them should be a deliberate decision, not a silent one.
|
||||||
|
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
|
||||||
|
|
||||||
function collectTextAssets(dir) {
|
function collectTextAssets(dir) {
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
|
|||||||
const files = collectTextAssets(publicRoot);
|
const files = collectTextAssets(publicRoot);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
|
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
|
||||||
|
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
|
||||||
|
// the content hash of everything the worker loads, or a deploy can pair a new
|
||||||
|
// worker with a stale cached core/vendor file (static assets are cached 1y).
|
||||||
|
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
|
||||||
|
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
|
||||||
|
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
|
||||||
|
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
|
||||||
|
|
||||||
|
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
|
||||||
|
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
|
||||||
|
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
|
||||||
|
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedVersion = createHash('sha256')
|
||||||
|
.update(readFileSync(spreadsheetWorker))
|
||||||
|
.update(readFileSync(spreadsheetCore))
|
||||||
|
.update(readFileSync(spreadsheetVendors[0]))
|
||||||
|
.update(readFileSync(spreadsheetVendors[1]))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
|
||||||
|
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
|
||||||
|
if (actualVersion !== expectedVersion) {
|
||||||
|
failures.push(
|
||||||
|
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const rel = relative(repoRoot, file);
|
const rel = relative(repoRoot, file);
|
||||||
const data = readFileSync(file);
|
const data = readFileSync(file);
|
||||||
|
|||||||
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
|
||||||
|
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (!isGlobalInstall) {
|
||||||
|
try {
|
||||||
|
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
|
||||||
|
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
|
||||||
|
} catch (err) {
|
||||||
|
hasWarnings = true;
|
||||||
|
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
|
||||||
|
console.log(colors.dim(` ${err.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
|
||||||
|
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
|
||||||
|
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
|
||||||
|
* in package.json, and check-public-assets.mjs hashes the output into
|
||||||
|
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
|
||||||
|
* that changes the bytes fails that check until the token is refreshed.
|
||||||
|
* Source-map comments are stripped: the maps are not shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
|
||||||
|
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
|
||||||
|
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
|
||||||
|
|
||||||
|
function copyBrowserBundle(source, outputName) {
|
||||||
|
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
|
||||||
|
if (/sourceMappingURL/.test(content)) {
|
||||||
|
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(outputDir, outputName), content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
copyBrowserBundle(excelSource, 'exceljs.min.js');
|
||||||
|
copyBrowserBundle(fflateSource, 'fflate.min.js');
|
||||||
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|||||||
*/
|
*/
|
||||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||||
|
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||||
|
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||||
|
*/
|
||||||
|
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||||
|
|
||||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
'jpeg',
|
'jpeg',
|
||||||
'gif',
|
'gif',
|
||||||
'webp',
|
'webp',
|
||||||
'pdf',
|
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||||
'docx',
|
|
||||||
'pptx',
|
|
||||||
'md',
|
'md',
|
||||||
'txt',
|
'txt',
|
||||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||||
@@ -154,6 +159,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
|
|||||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||||
if (normalized === 'pdf') return 'pdf';
|
if (normalized === 'pdf') return 'pdf';
|
||||||
if (normalized === 'pptx') return 'presentation';
|
if (normalized === 'pptx') return 'presentation';
|
||||||
|
if (normalized === 'xlsx') return 'spreadsheet';
|
||||||
if (normalized === 'md') return 'markdown';
|
if (normalized === 'md') return 'markdown';
|
||||||
// Everything else in the text family reads as text, including code and
|
// Everything else in the text family reads as text, including code and
|
||||||
// config: the card and the preview both treat it as a plain-text file.
|
// config: the card and the preview both treat it as a plain-text file.
|
||||||
|
|||||||
+6
-2
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
|
|||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.js';
|
import { getErrorMessage } from './types.js';
|
||||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||||
@@ -111,7 +111,11 @@ program
|
|||||||
.action(async (filePath, options) => {
|
.action(async (filePath, options) => {
|
||||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
console.error(
|
||||||
|
palette.err(
|
||||||
|
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||||
|
)
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ const capabilitiesSchema = z
|
|||||||
requiresMux: z.boolean(),
|
requiresMux: z.boolean(),
|
||||||
hooks: z.enum(['none', 'always', 'supervised']),
|
hooks: z.enum(['none', 'always', 'supervised']),
|
||||||
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
|
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
|
||||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
|
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||||
echo: echoSchema,
|
echo: echoSchema,
|
||||||
wheelForward: z
|
wheelForward: z
|
||||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ const OPENCODE: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-and-mouse',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||||
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
||||||
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
|
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
|
||||||
@@ -1157,8 +1157,9 @@ const GROK: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
// Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity:
|
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
|
||||||
// only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
|
||||||
|
// erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
// Buffer-policy fallthrough default, unmeasured against an authenticated grok composer
|
||||||
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
// (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi.
|
||||||
@@ -1491,7 +1492,7 @@ const OMP: CliEntry = {
|
|||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
// Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the
|
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
||||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
// Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what
|
||||||
|
|||||||
@@ -439,11 +439,40 @@ export interface CliCapabilities {
|
|||||||
*/
|
*/
|
||||||
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
|
||||||
/**
|
/**
|
||||||
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
|
* What the server strips from this CLI's output stream before the browser sees it.
|
||||||
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
|
* The value encodes three independent choices (predicates in session.ts):
|
||||||
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
|
*
|
||||||
|
* | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs |
|
||||||
|
* |-----------------------|---------------------|-------------------------|---------------------|
|
||||||
|
* | `strip-full` | stripped | stripped | stripped |
|
||||||
|
* | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux |
|
||||||
|
* | `strip-mux-only` | stripped under tmux | kept | kept |
|
||||||
|
* | `preserve` | stripped under tmux | kept | kept |
|
||||||
|
*
|
||||||
|
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
|
||||||
|
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
|
||||||
|
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
|
||||||
|
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
|
||||||
|
*
|
||||||
|
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
|
||||||
|
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
|
||||||
|
* reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a
|
||||||
|
* direct-PTY fallback the `?1049h` is the program's own and must stay.
|
||||||
|
* - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's
|
||||||
|
* redraw-driven `3J` (strip-full) is noise.
|
||||||
|
* - mouse DECSETs: stripping them keeps a drag a local selection instead of a report
|
||||||
|
* to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated
|
||||||
|
* on the `cliMouseTracking` the server records as it strips. Kept where a program's
|
||||||
|
* own mouse support must work in the pane (htop/vim in a shell).
|
||||||
|
*
|
||||||
|
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
||||||
|
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
|
||||||
|
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
||||||
|
*
|
||||||
|
* A fourth combination is the point to split this into flags; three is still cheaper
|
||||||
|
* as an enum.
|
||||||
*/
|
*/
|
||||||
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
|
altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve';
|
||||||
echo: {
|
echo: {
|
||||||
policy: 'buffer' | 'predict' | 'off';
|
policy: 'buffer' | 'predict' | 'off';
|
||||||
/** How the local-echo overlay locates the composer row. */
|
/** How the local-echo overlay locates the composer row. */
|
||||||
|
|||||||
+75
-35
@@ -15,8 +15,8 @@
|
|||||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||||
* to the outer one, and is not scanned;
|
* to the outer one, and is not scanned;
|
||||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||||
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
|
||||||
* symlinks), each reported separately;
|
* and the like, never following symlinks), each reported separately;
|
||||||
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
|
||||||
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
|
||||||
*
|
*
|
||||||
@@ -52,7 +52,10 @@ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 10_000;
|
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
|
||||||
|
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
|
||||||
|
export const MIN_GIT_TIMEOUT_MS = 5_000;
|
||||||
|
export const MAX_GIT_TIMEOUT_MS = 120_000;
|
||||||
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
|
||||||
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||||
/** Max file rows returned. The counts stay exact. */
|
/** Max file rows returned. The counts stay exact. */
|
||||||
@@ -258,9 +261,9 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
|
||||||
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
|
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||||
|
|
||||||
export const runGit: GitRunner = async (cwd, args) => {
|
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||||
@@ -269,7 +272,7 @@ export const runGit: GitRunner = async (cwd, args) => {
|
|||||||
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
timeout: GIT_TIMEOUT_MS,
|
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
|
||||||
}
|
}
|
||||||
@@ -288,17 +291,14 @@ function describeFailure(err: unknown): { notARepo: boolean; message: string } {
|
|||||||
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
|
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
|
||||||
let statusText: string;
|
let statusText: string;
|
||||||
try {
|
try {
|
||||||
statusText = await git(cwd, [
|
statusText = await git(
|
||||||
'status',
|
cwd,
|
||||||
'--porcelain=v2',
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
||||||
'--branch',
|
{ timeoutMs }
|
||||||
'-z',
|
);
|
||||||
'--untracked-files=normal',
|
|
||||||
'--ignore-submodules=dirty',
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
|
||||||
@@ -307,7 +307,7 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
|
|||||||
|
|
||||||
const safe = async (args: string[]): Promise<string> => {
|
const safe = async (args: string[]): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
return await git(cwd, args);
|
return await git(cwd, args, { timeoutMs });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -415,10 +415,12 @@ async function singleFlight<T>(
|
|||||||
*/
|
*/
|
||||||
export async function getGitWorkspaceStatus(
|
export async function getGitWorkspaceStatus(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
||||||
): Promise<GitWorkspaceStatus> {
|
): Promise<GitWorkspaceStatus> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
|
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
|
||||||
|
collect(cwd, git, opts.timeoutMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
|
||||||
@@ -427,12 +429,12 @@ const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
|
|||||||
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
|
||||||
function enclosingRepoRoot(
|
function enclosingRepoRoot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
|
||||||
): Promise<RepoToplevel> {
|
): Promise<RepoToplevel> {
|
||||||
const git = opts.git ?? runGit;
|
const git = opts.git ?? runGit;
|
||||||
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
|
||||||
try {
|
try {
|
||||||
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
|
||||||
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const f = describeFailure(err);
|
const f = describeFailure(err);
|
||||||
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
|
|||||||
const DISCOVERY_MAX_ENTRIES = 300;
|
const DISCOVERY_MAX_ENTRIES = 300;
|
||||||
/** Repositories reported for one workspace. */
|
/** Repositories reported for one workspace. */
|
||||||
export const MAX_REPOS = 12;
|
export const MAX_REPOS = 12;
|
||||||
|
/** The most repositories a caller may ask for: each one costs several git processes per poll. */
|
||||||
|
export const MAX_REPOS_LIMIT = 50;
|
||||||
|
|
||||||
|
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
|
||||||
|
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
|
||||||
|
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||||
|
}
|
||||||
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
|
||||||
const DISCOVERY_TTL_MS = 30_000;
|
const DISCOVERY_TTL_MS = 30_000;
|
||||||
/** Folders that are never worth descending into when looking for projects. */
|
/** Folders that are never worth descending into when looking for projects. */
|
||||||
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
|
|||||||
reason?: 'remote' | 'docker';
|
reason?: 'remote' | 'docker';
|
||||||
error?: string;
|
error?: string;
|
||||||
repos: GitRepoEntry[];
|
repos: GitRepoEntry[];
|
||||||
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
|
/** More than `repoLimit` repositories were found; only the first are reported. */
|
||||||
reposTruncated: boolean;
|
reposTruncated: boolean;
|
||||||
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
||||||
|
repoLimit?: number;
|
||||||
checkedAt: number;
|
checkedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +567,8 @@ async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] |
|
|||||||
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
|
||||||
export async function discoverChildRepos(
|
export async function discoverChildRepos(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
excludeRealRoots: string[] = []
|
excludeRealRoots: string[] = [],
|
||||||
|
maxRepos: number = MAX_REPOS
|
||||||
): Promise<{ dirs: string[]; truncated: boolean }> {
|
): Promise<{ dirs: string[]; truncated: boolean }> {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
let level = [cwd];
|
let level = [cwd];
|
||||||
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
|
|||||||
}
|
}
|
||||||
level = next;
|
level = next;
|
||||||
}
|
}
|
||||||
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
|
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
|
||||||
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
|
|||||||
home?: string;
|
home?: string;
|
||||||
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
|
||||||
dockerWorkspaces?: string[];
|
dockerWorkspaces?: string[];
|
||||||
|
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
|
||||||
|
maxRepos?: number;
|
||||||
|
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
|
||||||
|
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
|
||||||
|
maxRepos: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
|
||||||
|
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceRepos =
|
type WorkspaceRepos =
|
||||||
| { kind: 'docker' }
|
| { kind: 'docker' }
|
||||||
| { kind: 'error'; error: string }
|
| { kind: 'error'; error: string }
|
||||||
| { kind: 'enclosing'; root: string }
|
| { kind: 'enclosing'; root: string }
|
||||||
| { kind: 'children'; dirs: string[]; truncated: boolean };
|
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
* WHICH repositories belong to the workspace (the module header has the rules), without a full
|
||||||
@@ -617,6 +647,7 @@ type WorkspaceRepos =
|
|||||||
*/
|
*/
|
||||||
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
||||||
const now = opts.now ?? Date.now;
|
const now = opts.now ?? Date.now;
|
||||||
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
||||||
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
|
||||||
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
|
||||||
// could carry config (a clean filter) that runs on the host.
|
// could carry config (a clean filter) that runs on the host.
|
||||||
@@ -624,7 +655,7 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
// The enclosing repository is identified before its full status runs, so an unrelated one above the
|
||||||
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
|
||||||
// repositories below.
|
// repositories below.
|
||||||
const top = await enclosingRepoRoot(cwd, opts);
|
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
|
||||||
if (top.state === 'error') return { kind: 'error', error: top.error };
|
if (top.state === 'error') return { kind: 'error', error: top.error };
|
||||||
if (top.state === 'ok') {
|
if (top.state === 'ok') {
|
||||||
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
|
||||||
@@ -633,18 +664,20 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not inside a repository of this workspace: look below for projects.
|
// Not inside a repository of this workspace: look below for projects.
|
||||||
const hit = discoveryCache.get(cwd);
|
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
|
||||||
|
const discoveryKey = `${cwd}\0${maxRepos}`;
|
||||||
|
const hit = discoveryCache.get(discoveryKey);
|
||||||
let found: { dirs: string[]; truncated: boolean };
|
let found: { dirs: string[]; truncated: boolean };
|
||||||
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
|
||||||
else {
|
else {
|
||||||
found = await discoverChildRepos(cwd, dockerRoots);
|
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
|
||||||
discoveryCache.set(cwd, { at: now(), value: found });
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
||||||
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
||||||
}
|
}
|
||||||
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
|
||||||
return { kind: 'children', dirs, truncated: found.truncated };
|
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,7 +692,7 @@ export async function getGitWorkspaceOverview(
|
|||||||
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
|
||||||
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
|
||||||
if (where.kind === 'enclosing') {
|
if (where.kind === 'enclosing') {
|
||||||
const primary = await getGitWorkspaceStatus(cwd, opts);
|
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
|
||||||
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
|
||||||
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
|
||||||
const root = primary.repoRoot ?? where.root;
|
const root = primary.repoRoot ?? where.root;
|
||||||
@@ -671,14 +704,21 @@ export async function getGitWorkspaceOverview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
|
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
|
||||||
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
|
||||||
|
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
|
||||||
|
);
|
||||||
const repos: GitRepoEntry[] = [];
|
const repos: GitRepoEntry[] = [];
|
||||||
where.dirs.forEach((dir, i) => {
|
where.dirs.forEach((dir, i) => {
|
||||||
const status = statuses[i];
|
const status = statuses[i];
|
||||||
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
|
||||||
|
// list with its error, so it is visible that something is not being reported; only a folder
|
||||||
|
// that turned out not to be a repository after all is left out.
|
||||||
|
if (status.state === 'ok' || status.state === 'error')
|
||||||
|
repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
||||||
});
|
});
|
||||||
if (!repos.length) return emptyOverview('not-a-repo');
|
if (!repos.length) return emptyOverview('not-a-repo');
|
||||||
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
|
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -726,7 +766,7 @@ export function isSafeRepoRelativePath(p: string): boolean {
|
|||||||
export async function getGitFileDiff(
|
export async function getGitFileDiff(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
file: { path: string; origPath?: string; kind: GitFileKind },
|
file: { path: string; origPath?: string; kind: GitFileKind },
|
||||||
opts: { git?: GitRunner } = {}
|
opts: { git?: GitRunner; timeoutMs?: number } = {}
|
||||||
): Promise<GitFileDiff> {
|
): Promise<GitFileDiff> {
|
||||||
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
|
||||||
throw new Error('Invalid path');
|
throw new Error('Invalid path');
|
||||||
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
|
|||||||
let out: string;
|
let out: string;
|
||||||
let cutShort = false;
|
let cutShort = false;
|
||||||
try {
|
try {
|
||||||
out = await git(repoRoot, args);
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: unknown; stdout?: unknown };
|
const e = err as { code?: unknown; stdout?: unknown };
|
||||||
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
// `--no-index` exits 1 when the files differ, which is the normal case for it.
|
||||||
|
|||||||
+55
-17
@@ -277,10 +277,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
|
|||||||
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||||
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
|
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
|
||||||
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
|
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
|
||||||
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
|
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
|
||||||
* with the replay-side strip in session-routes.ts.
|
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
|
||||||
|
* stock CLI in test/claude-scrollback-strip.test.ts.
|
||||||
*
|
*
|
||||||
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
|
||||||
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
|
||||||
@@ -300,8 +301,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
* Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J`
|
||||||
* and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode`
|
* and the mouse-tracking DECSETs alone. Applies to every mode that is neither
|
||||||
* excludes, but ONLY when the session is tmux-backed (`useMux`).
|
* `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode),
|
||||||
|
* so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed
|
||||||
|
* (`useMux`).
|
||||||
*
|
*
|
||||||
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
* The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first
|
||||||
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
* bytes on attach, before any program has run. Unstripped, xterm.js parks in the
|
||||||
@@ -326,7 +329,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
|||||||
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
* `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback".
|
||||||
*/
|
*/
|
||||||
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||||
return useMux && !isAltScreenStripMode(mode);
|
if (!useMux) return false;
|
||||||
|
const altScreen = getCli(mode)?.capabilities.altScreen;
|
||||||
|
return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone:
|
||||||
|
* `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI.
|
||||||
|
*
|
||||||
|
* Why this exists (opencode, measured 2026-09-16): the TUI enables tracking
|
||||||
|
* DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs
|
||||||
|
* straight through to the tmux client, and the browser's xterm obeyed them —
|
||||||
|
* `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI
|
||||||
|
* instead of selecting locally. "Mark text, copy on select" silently did nothing
|
||||||
|
* (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and
|
||||||
|
* the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also
|
||||||
|
* ended sessions. Stripping at the source keeps xterm in selection mode; clicks
|
||||||
|
* still reach the CLI through the browser's hand-encoded tap, which this strip
|
||||||
|
* publishes as `cliMouseTracking` (`_recordStrippedMouseMode`).
|
||||||
|
*
|
||||||
|
* Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY
|
||||||
|
* fallback the program's own DECSETs really do reach xterm and must be honoured.
|
||||||
|
*/
|
||||||
|
export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean {
|
||||||
|
return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||||
@@ -1355,7 +1382,7 @@ export class Session extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* True when this session's PTY is a tmux client rather than the program itself.
|
* True when this session's PTY is a tmux client rather than the program itself.
|
||||||
* Read by the replay-side alt-screen strip, which must apply the same
|
* Read by the replay-side alt-screen strip, which must apply the same
|
||||||
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode).
|
* `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode, isMuxMouseStripMode).
|
||||||
*/
|
*/
|
||||||
get usesMux(): boolean {
|
get usesMux(): boolean {
|
||||||
return this._useMux;
|
return this._useMux;
|
||||||
@@ -2529,14 +2556,21 @@ export class Session extends EventEmitter {
|
|||||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||||
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
|
||||||
//
|
//
|
||||||
// Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it
|
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
|
||||||
// is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
|
||||||
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
// scrollback there is tmux's own client-side smcup at attach, not anything the
|
||||||
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
|
||||||
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
// 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working.
|
||||||
|
//
|
||||||
|
// The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI:
|
||||||
|
// it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking
|
||||||
|
// reaches xterm and every drag becomes a mouse report instead of a text
|
||||||
|
// selection, which is what killed mark-and-copy in opencode — while 3J stays,
|
||||||
|
// because a TUI is not a `clear` consumer.
|
||||||
const fullStrip = isAltScreenStripMode(this.mode);
|
const fullStrip = isAltScreenStripMode(this.mode);
|
||||||
const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux);
|
||||||
if (fullStrip || altOnlyStrip) {
|
const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux);
|
||||||
|
if (fullStrip || mouseStrip || altOnlyStrip) {
|
||||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||||
@@ -2555,14 +2589,18 @@ export class Session extends EventEmitter {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '');
|
||||||
if (fullStrip) {
|
if (fullStrip) {
|
||||||
data = data
|
// eslint-disable-next-line no-control-regex
|
||||||
|
data = data.replace(/\x1b\[3J/g, '');
|
||||||
|
}
|
||||||
|
if (fullStrip || mouseStrip) {
|
||||||
|
data = data.replace(
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[3J/g, '')
|
/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g,
|
||||||
// eslint-disable-next-line no-control-regex
|
(seq) => {
|
||||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
|
||||||
this._recordStrippedMouseMode(seq);
|
this._recordStrippedMouseMode(seq);
|
||||||
return '';
|
return '';
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -812,7 +812,8 @@ export interface SessionState {
|
|||||||
/**
|
/**
|
||||||
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
||||||
* by the server on its way out of the stream (those sequences are stripped for
|
* by the server on its way out of the stream (those sequences are stripped for
|
||||||
* claude/codex/gemini, so the browser can never see them itself). The browser
|
* the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode
|
||||||
|
* under tmux, so the browser can never see them itself). The browser
|
||||||
* hand-encodes a click report ONLY when this is true; without it, every click
|
* hand-encodes a click report ONLY when this is true; without it, every click
|
||||||
* sent mouse reports to a CLI that never asked for them.
|
* sent mouse reports to a CLI that never asked for them.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'presentation'
|
| 'presentation'
|
||||||
|
| 'spreadsheet'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'text';
|
| 'text';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Launch-time defaults for Codex sessions.
|
||||||
|
*
|
||||||
|
* Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the
|
||||||
|
* `codexConfig` a launch uses, filling ONLY the fields the caller left unset.
|
||||||
|
* Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited
|
||||||
|
* settings.json can never smuggle an unchecked value onto the codex command line.
|
||||||
|
*
|
||||||
|
* Scope is the caller's decision: the create and quick-start routes apply it to
|
||||||
|
* local launches only, never to remote, Docker or custom-endpoint launches.
|
||||||
|
* Nothing here writes Codex's own config files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CodexConfig } from '../types.js';
|
||||||
|
import { SettingsUpdateSchema } from './schemas.js';
|
||||||
|
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||||
|
|
||||||
|
/** Resolve launch-only defaults without changing Codex's own configuration files. */
|
||||||
|
export async function resolveCodexLaunchDefaults(
|
||||||
|
config: CodexConfig | undefined,
|
||||||
|
customEndpoint = false
|
||||||
|
): Promise<CodexConfig | undefined> {
|
||||||
|
if (customEndpoint) return config;
|
||||||
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'Codex launch defaults', {});
|
||||||
|
const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel);
|
||||||
|
const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort);
|
||||||
|
const defaultModel = model.success ? model.data || undefined : undefined;
|
||||||
|
const defaultEffort = effort.success ? effort.data || undefined : undefined;
|
||||||
|
if (!defaultModel && !defaultEffort) return config;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
model: config?.model ?? defaultModel,
|
||||||
|
reasoningEffort: config?.reasoningEffort ?? defaultEffort,
|
||||||
|
};
|
||||||
|
}
|
||||||
+73
-3
@@ -1164,6 +1164,8 @@ class CodemanApp {
|
|||||||
this._selectUrlSession();
|
this._selectUrlSession();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// mobile.css keeps the pop-out icon off phones unless a host can open windows.
|
||||||
|
document.documentElement.classList.toggle('host-windows', this.hasHostWindows());
|
||||||
// Initialize mobile handlers
|
// Initialize mobile handlers
|
||||||
KeyboardHandler.init();
|
KeyboardHandler.init();
|
||||||
SwipeHandler.init();
|
SwipeHandler.init();
|
||||||
@@ -1667,6 +1669,24 @@ class CodemanApp {
|
|||||||
// false only when we owned a now-closed window (re-dock + fall through to
|
// false only when we owned a now-closed window (re-dock + fall through to
|
||||||
// genuinely re-open below).
|
// genuinely re-open below).
|
||||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||||
|
// A native wrapper (an Android WebView app) has no browser pop-ups, but can
|
||||||
|
// open the solo URL in a window of its own, beside this one on a foldable or
|
||||||
|
// a split screen. There is no WindowProxy to poll, so the tab is tracked the
|
||||||
|
// way a dashboard reload tracks it: the solo window's channel announcements
|
||||||
|
// plus the roll-call liveness check. Without a channel there is no roll-call
|
||||||
|
// either, so a hosted tab could never re-dock: refuse before asking the host.
|
||||||
|
const hosted = this.hasHostWindows() && !this.windowChannel
|
||||||
|
? false
|
||||||
|
: this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id)));
|
||||||
|
if (hosted !== null) {
|
||||||
|
if (!hosted) {
|
||||||
|
this.showToast?.('Could not open a new window for this session', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._markDetached(id, true);
|
||||||
|
this._postWindowMessage({ type: 'detached', id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||||
let win = null;
|
let win = null;
|
||||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||||
@@ -1681,6 +1701,44 @@ class CodemanApp {
|
|||||||
try { win.focus(); } catch {}
|
try { win.focus(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The embedding app's window opener, when there is one. A native wrapper
|
||||||
|
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
|
||||||
|
* counts as opened) to say it can put a page in a window of its own; browsers
|
||||||
|
* never define it.
|
||||||
|
* @returns {boolean} whether a host window opener is present
|
||||||
|
*/
|
||||||
|
hasHostWindows() {
|
||||||
|
try {
|
||||||
|
return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function';
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab pop-out setting, defaulting ON under a host that opens windows.
|
||||||
|
* The one resolver for the tab icon, App Settings and the tab action menu.
|
||||||
|
* @param {object} settings stored per-device App Settings
|
||||||
|
* @param {object} [defaults] the device's default settings
|
||||||
|
* @returns {boolean} whether the pop-out control shows
|
||||||
|
*/
|
||||||
|
tabDetachButtonEnabled(settings, defaults = {}) {
|
||||||
|
return settings?.showTabDetachButton ?? (this.hasHostWindows() || (defaults?.showTabDetachButton ?? false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an http(s) URL in a host window, usually Codeman's own origin (a saved
|
||||||
|
* web tab passes its own).
|
||||||
|
* @param {string} url absolute or base-relative URL
|
||||||
|
* @returns {boolean|null} null when there is no host (use window.open),
|
||||||
|
* otherwise whether the host opened a window
|
||||||
|
*/
|
||||||
|
openInHostWindow(url) {
|
||||||
|
if (!this.hasHostWindows()) return null;
|
||||||
|
try {
|
||||||
|
return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
/** Raise the popup for an already-detached session. Returns true if the raise
|
/** Raise the popup for an already-detached session. Returns true if the raise
|
||||||
* was handled (caller should stop); false if we owned a now-closed window and
|
* was handled (caller should stop); false if we owned a now-closed window and
|
||||||
* re-docked it (caller should fall through to inline / re-open). Unifies the
|
* re-docked it (caller should fall through to inline / re-open). Unifies the
|
||||||
@@ -1810,8 +1868,12 @@ class CodemanApp {
|
|||||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||||
if (msg.id !== this.soloSessionId) return;
|
if (msg.id !== this.soloSessionId) return;
|
||||||
if (msg.type === 'close-request') { try { window.close(); } catch {} }
|
// A host window ignores window.close()/focus() from script it did not
|
||||||
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
|
// open by window.open, so ask the host when it offers the call.
|
||||||
|
if (msg.type === 'close-request') { this._closeSoloWindow(); }
|
||||||
|
else if (msg.type === 'focus-request') {
|
||||||
|
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Dashboard side.
|
// Dashboard side.
|
||||||
@@ -1863,6 +1925,14 @@ class CodemanApp {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Solo window: close itself (the re-dock button and a dashboard close-request). */
|
||||||
|
_closeSoloWindow() {
|
||||||
|
try {
|
||||||
|
if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow();
|
||||||
|
else window.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Solo window: select the target session and apply minimal single-session
|
/** Solo window: select the target session and apply minimal single-session
|
||||||
* chrome. Called from handleInit once the session list has loaded. */
|
* chrome. Called from handleInit once the session list has loaded. */
|
||||||
_applySoloMode() {
|
_applySoloMode() {
|
||||||
@@ -1894,7 +1964,7 @@ class CodemanApp {
|
|||||||
el.className = 'solo-gone-overlay';
|
el.className = 'solo-gone-overlay';
|
||||||
el.innerHTML = '<h2>Session unavailable</h2>'
|
el.innerHTML = '<h2>Session unavailable</h2>'
|
||||||
+ '<p>This session has ended or is no longer available.</p>'
|
+ '<p>This session has ended or is no longer available.</p>'
|
||||||
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
+ '<button class="btn-primary" onclick="app._closeSoloWindow()">Close window</button>';
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
document.title = (window.codemanT?.('Session ended') || 'Session ended')
|
document.title = (window.codemanT?.('Session ended') || 'Session ended')
|
||||||
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||||
|
|||||||
@@ -1830,7 +1830,7 @@ function computeRewriteScrollLine(input) {
|
|||||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||||
*/
|
*/
|
||||||
const FILE_PATH_LINK_PATTERN =
|
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|avif|bmp|ico|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|xlsx|mp4|webm|mov|mp3|wav))\b/g;
|
||||||
|
|
||||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||||
function absoluteFilePathPattern() {
|
function absoluteFilePathPattern() {
|
||||||
@@ -1848,7 +1848,7 @@ function absoluteFilePathPattern() {
|
|||||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||||
*/
|
*/
|
||||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||||
('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(' ')
|
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx 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. */
|
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
|
||||||
this._gitStatusFetchedAt = Date.now();
|
this._gitStatusFetchedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
|
const query = new URLSearchParams(this.gitStatusLimits());
|
||||||
|
if (fresh) query.set('fresh', '1');
|
||||||
|
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
|
||||||
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
|
||||||
this._gitStatus = data ? { sessionId: sid, data } : null;
|
this._gitStatus = data ? { sessionId: sid, data } : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many repositories to list below a folder that is not itself a repository, and how long one
|
||||||
|
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
|
||||||
|
*/
|
||||||
|
gitStatusLimits() {
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
const defaults = this.getDefaultSettings();
|
||||||
|
const num = (v, min, max, fallback) => {
|
||||||
|
const n = Math.trunc(Number(v));
|
||||||
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
|
||||||
|
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
/** Whether the Git window groups changed files under collapsible folders (default on). */
|
||||||
isGitStatusTree() {
|
isGitStatusTree() {
|
||||||
const settings = this.loadAppSettingsFromStorage();
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let uncommitted = 0;
|
let uncommitted = 0;
|
||||||
let unpushed = 0;
|
let unpushed = 0;
|
||||||
let conflicted = 0;
|
let conflicted = 0;
|
||||||
|
let unreadable = 0;
|
||||||
for (const r of overview.repos) {
|
for (const r of overview.repos) {
|
||||||
|
if (r.status.state === 'error') unreadable += 1;
|
||||||
uncommitted += r.status.counts.uncommitted;
|
uncommitted += r.status.counts.uncommitted;
|
||||||
unpushed += r.status.unpushedCount;
|
unpushed += r.status.unpushedCount;
|
||||||
conflicted += r.status.counts.conflicted;
|
conflicted += r.status.counts.conflicted;
|
||||||
}
|
}
|
||||||
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
|
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
|
||||||
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
|
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
|
||||||
|
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
|
||||||
},
|
},
|
||||||
|
|
||||||
/** One sentence for the tooltip and the screen-reader label. */
|
/** One sentence for the tooltip and the screen-reader label. */
|
||||||
@@ -168,12 +190,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
|
||||||
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
|
||||||
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
|
||||||
|
if (sum.unreadable)
|
||||||
|
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
|
||||||
if (!bits.length) bits.push('everything is committed and pushed');
|
if (!bits.length) bits.push('everything is committed and pushed');
|
||||||
let where;
|
let where;
|
||||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||||
else {
|
else {
|
||||||
const d = overview.repos[0].status;
|
const d = overview.repos[0].status;
|
||||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||||
}
|
}
|
||||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||||
},
|
},
|
||||||
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
|
||||||
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
|
||||||
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
|
||||||
|
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
|
||||||
if (!parts.length) parts.push('✓');
|
if (!parts.length) parts.push('✓');
|
||||||
if (label) label.textContent = parts.join(' ');
|
if (label) label.textContent = parts.join(' ');
|
||||||
const sentence = this._gitStatusSentence(data);
|
const sentence = this._gitStatusSentence(data);
|
||||||
@@ -333,17 +358,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repos = overview.repos;
|
const repos = overview.repos;
|
||||||
if (repos.length === 1) {
|
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
|
||||||
// One repository: the panel is that repository, as it always was.
|
// One repository: the panel is that repository, as it always was. One that git could not read,
|
||||||
|
// or the only one shown of several (the limit is 1), takes the list view below instead, so its
|
||||||
|
// error row or the "Showing the first" notice is not lost.
|
||||||
const d = repos[0].status;
|
const d = repos[0].status;
|
||||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||||
this._renderGitRepoInto(body, d);
|
this._renderGitRepoInto(body, d);
|
||||||
} else {
|
} else {
|
||||||
if (head) head.textContent = `${repos.length} repositories`;
|
if (head) head.textContent = repos.length === 1 ? '1 repository' : `${repos.length} repositories`;
|
||||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||||
if (overview.reposTruncated) {
|
if (overview.reposTruncated) {
|
||||||
body.append(
|
body.append(
|
||||||
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
|
el(
|
||||||
|
'div',
|
||||||
|
'git-status-more',
|
||||||
|
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +395,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_gitRepoSection(r) {
|
_gitRepoSection(r) {
|
||||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||||
const d = r.status;
|
const d = r.status;
|
||||||
|
if (d.state === 'error') {
|
||||||
|
// Kept in the list with the reason, rather than silently left out.
|
||||||
|
const row = el('div', 'git-status-repo git-status-repo--error');
|
||||||
|
row.append(el('span', 'git-status-repo-name', r.name));
|
||||||
|
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
|
||||||
|
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
|
||||||
|
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
|
||||||
|
row.append(why);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
const section = el('details', 'git-status-repo');
|
const section = el('details', 'git-status-repo');
|
||||||
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
|
||||||
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
|
||||||
@@ -582,7 +623,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
|
||||||
this._gitDiffView = view;
|
this._gitDiffView = view;
|
||||||
this._renderGitStatusPanel();
|
this._renderGitStatusPanel();
|
||||||
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
|
const qs = new URLSearchParams({
|
||||||
|
repo: repoRoot,
|
||||||
|
path: file.path,
|
||||||
|
kind: file.kind,
|
||||||
|
...this.gitStatusLimits(),
|
||||||
|
});
|
||||||
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
|
||||||
// Back, another file or another session while this was in flight: drop the answer.
|
// Back, another file or another session while this was in flight: drop the answer.
|
||||||
if (this._gitDiffView !== view) return;
|
if (this._gitDiffView !== view) return;
|
||||||
|
|||||||
+21
-1
@@ -45,6 +45,15 @@
|
|||||||
// Exact English-source translations. Technical names, command examples, model
|
// Exact English-source translations. Technical names, command examples, model
|
||||||
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
// names, keyboard chords, and user-authored content intentionally stay unchanged.
|
||||||
const ZH_CN = Object.freeze({
|
const ZH_CN = Object.freeze({
|
||||||
|
'Default Codex model': 'Codex 默认模型',
|
||||||
|
'Default Codex reasoning effort': 'Codex 默认思考强度',
|
||||||
|
'Use Codex configuration': '使用 Codex 配置',
|
||||||
|
'Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.':
|
||||||
|
'新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。',
|
||||||
|
'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.':
|
||||||
|
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
|
||||||
|
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
|
||||||
|
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
|
||||||
'Skip to terminal': '跳转到终端',
|
'Skip to terminal': '跳转到终端',
|
||||||
'Go to main page': '返回主页',
|
'Go to main page': '返回主页',
|
||||||
'Session tabs': '会话标签页',
|
'Session tabs': '会话标签页',
|
||||||
@@ -448,7 +457,6 @@
|
|||||||
'Remote Access': '远程访问',
|
'Remote Access': '远程访问',
|
||||||
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
||||||
'Tunnel URL': '隧道地址',
|
'Tunnel URL': '隧道地址',
|
||||||
'Upload URL': '上传地址',
|
|
||||||
Updates: '更新',
|
Updates: '更新',
|
||||||
'Current Version': '当前版本',
|
'Current Version': '当前版本',
|
||||||
'Check for Updates': '检查更新',
|
'Check for Updates': '检查更新',
|
||||||
@@ -850,6 +858,18 @@
|
|||||||
'Wrap lines': '自动换行',
|
'Wrap lines': '自动换行',
|
||||||
'Unsaved changes': '未保存的更改',
|
'Unsaved changes': '未保存的更改',
|
||||||
Saved: '已保存',
|
Saved: '已保存',
|
||||||
|
'Loading spreadsheet…': '正在加载电子表格…',
|
||||||
|
'This workbook is too large to preview (10 MB limit).': '此工作簿太大,无法预览(上限 10 MB)。',
|
||||||
|
'This workbook has no visible worksheets.': '此工作簿没有可见的工作表。',
|
||||||
|
'This worksheet is empty.': '此工作表为空。',
|
||||||
|
'Some workbook features are not shown': '部分工作簿功能未显示',
|
||||||
|
'Spreadsheet preview timed out.': '电子表格预览超时。',
|
||||||
|
'Spreadsheet preview failed': '电子表格预览失败',
|
||||||
|
'Spreadsheet parser failed.': '电子表格解析器出错。',
|
||||||
|
'Spreadsheet parser failed to start': '电子表格解析器启动失败',
|
||||||
|
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
|
||||||
|
'Spreadsheet parser message failed': '电子表格解析器消息出错',
|
||||||
|
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
|
||||||
'Export as JSON': '导出为 JSON',
|
'Export as JSON': '导出为 JSON',
|
||||||
'Export as Markdown': '导出为 Markdown',
|
'Export as Markdown': '导出为 Markdown',
|
||||||
'Mark all read': '全部标为已读',
|
'Mark all read': '全部标为已读',
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
<span>Admin Panel</span>
|
<span>Admin Panel</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="app._closeSoloWindow()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||||
<span class="tunnel-dot"></span>
|
<span class="tunnel-dot"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2009,6 +2009,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="git status maximum repositories folder many repos limit">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status: max repositories <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="appSettingsGitStatusMaxRepos" class="set-num" value="12" min="1" max="50" step="1">
|
||||||
|
</div>
|
||||||
|
<div class="set-row" data-search="git status timeout seconds slow share network">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span>
|
||||||
|
<span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2586,6 +2600,30 @@
|
|||||||
<div class="set-group" id="appSettingsCodexGroup">
|
<div class="set-group" id="appSettingsCodexGroup">
|
||||||
<div class="set-group-head"><h4>Codex</h4><span class="set-scope">synced</span></div>
|
<div class="set-group-head"><h4>Codex</h4><span class="set-scope">synced</span></div>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
|
<div class="set-row has-field" data-search="codex default model">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Default Codex model</span>
|
||||||
|
<span class="set-row-desc">Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.</span>
|
||||||
|
</div>
|
||||||
|
<input id="appSettingsCodexModel" class="set-input" type="text" maxlength="100" aria-label="Default Codex model" placeholder="Use Codex configuration" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="codex default reasoning effort thinking">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Default Codex reasoning effort</span>
|
||||||
|
<span class="set-row-desc">Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsCodexReasoningEffort" class="set-select" aria-label="Default Codex reasoning effort">
|
||||||
|
<option value="">Use Codex configuration</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
<option value="minimal">minimal</option>
|
||||||
|
<option value="low">low</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="xhigh">xhigh</option>
|
||||||
|
<option value="max">max</option>
|
||||||
|
<option value="ultra">ultra</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="set-row" data-search="codex bypass approvals sandbox">
|
<div class="set-row" data-search="codex bypass approvals sandbox">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Bypass approvals and sandbox</span>
|
<span class="set-row-label">Bypass approvals and sandbox</span>
|
||||||
@@ -2992,10 +3030,6 @@
|
|||||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="tunnelUploadUrlRow" style="display:none">
|
|
||||||
<div class="set-row-text"><span class="set-row-label">Upload URL</span></div>
|
|
||||||
<span id="tunnelUploadUrlDisplay" class="set-copy" title="Click to copy"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -4005,5 +4039,6 @@
|
|||||||
<script defer src="ultracode-windows.js"></script>
|
<script defer src="ultracode-windows.js"></script>
|
||||||
<script defer src="session-lineage.js"></script>
|
<script defer src="session-lineage.js"></script>
|
||||||
<script defer src="image-input.js"></script>
|
<script defer src="image-input.js"></script>
|
||||||
|
<script defer src="spreadsheet-preview.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel {
|
|||||||
|
|
||||||
/* No "open in new window" (detach) on phones/tablets — popped-out browser
|
/* No "open in new window" (detach) on phones/tablets — popped-out browser
|
||||||
windows aren't usable there. !important beats the hover/detached reveal
|
windows aren't usable there. !important beats the hover/detached reveal
|
||||||
rules in styles.css */
|
rules in styles.css. A native wrapper that opens windows of its own (side
|
||||||
.session-tab .tab-detach {
|
by side on a foldable) keeps it at tablet widths: app.js sets
|
||||||
|
html.host-windows. Phone widths hide it again in the 599px block. */
|
||||||
|
html:not(.host-windows) .session-tab .tab-detach {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -871,6 +873,13 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones),
|
||||||
|
so the pop-out icon stays off phone tabs even under a window-opening host,
|
||||||
|
which offers the pop-out from its own chrome (app.detachSession). */
|
||||||
|
.session-tab .tab-detach {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Gear icon on active tab - tiny, subtle */
|
/* Gear icon on active tab - tiny, subtle */
|
||||||
.session-tab.active .tab-gear {
|
.session-tab.active .tab-gear {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -4037,3 +4046,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
|||||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
|
||||||
|
touch-scrollable grid on phones. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.spreadsheet-sheet-tabs {
|
||||||
|
padding-inline: 4px;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-sheet-tab {
|
||||||
|
min-width: 96px;
|
||||||
|
min-height: 40px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-grid {
|
||||||
|
min-height: 55vh;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4178,6 +4178,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
} else if (ext === 'docx' || ext === 'pptx') {
|
} else if (ext === 'docx' || ext === 'pptx') {
|
||||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||||
|
} else if (ext === 'xlsx') {
|
||||||
|
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// Bounded like the workspace text preview: a Range for the first
|
// Bounded like the workspace text preview: a Range for the first
|
||||||
@@ -4274,6 +4276,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} else if (data.type === 'audio') {
|
} else if (data.type === 'audio') {
|
||||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
|
} else if (data.type === 'spreadsheet') {
|
||||||
|
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
|
||||||
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
} else if (data.type === 'binary') {
|
} else if (data.type === 'binary') {
|
||||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
||||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||||
@@ -4329,6 +4334,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
*/
|
*/
|
||||||
detachFilePreview() {
|
detachFilePreview() {
|
||||||
if (!this.filePreviewDetachUrl) return;
|
if (!this.filePreviewDetachUrl) return;
|
||||||
|
const hosted = this.openInHostWindow?.(this.filePreviewDetachUrl) ?? null;
|
||||||
|
if (hosted !== null) {
|
||||||
|
if (hosted) this.closeFilePreview();
|
||||||
|
else this.showToast('Could not open a new window for this preview', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const win = window.open(this.filePreviewDetachUrl, '_blank');
|
const win = window.open(this.filePreviewDetachUrl, '_blank');
|
||||||
if (!win) {
|
if (!win) {
|
||||||
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
|
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
|
||||||
@@ -4347,6 +4358,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
||||||
*/
|
*/
|
||||||
_stopFilePreviewMedia() {
|
_stopFilePreviewMedia() {
|
||||||
|
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
|
||||||
|
// leaves both running, so tear them down with the rest of the media.
|
||||||
|
this._disposeSpreadsheetPreview();
|
||||||
const bodyEl = this.$('filePreviewBody');
|
const bodyEl = this.$('filePreviewBody');
|
||||||
if (!bodyEl) return;
|
if (!bodyEl) return;
|
||||||
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
||||||
@@ -4361,6 +4375,42 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an XLSX into the preview body via spreadsheet-preview.js, which
|
||||||
|
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
|
||||||
|
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
|
||||||
|
* so the server applies its preview size cap. Superseded by the next
|
||||||
|
* _stopFilePreviewMedia(), which runs on every open and on close.
|
||||||
|
*/
|
||||||
|
_openSpreadsheetPreview(bodyEl, url, size) {
|
||||||
|
this._disposeSpreadsheetPreview();
|
||||||
|
const renderer = window.CodemanSpreadsheetPreview;
|
||||||
|
if (!renderer?.open) {
|
||||||
|
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bodyEl.textContent = '';
|
||||||
|
const token = {};
|
||||||
|
this._spreadsheetPreviewToken = token;
|
||||||
|
this._spreadsheetPreview = renderer.open({
|
||||||
|
container: bodyEl,
|
||||||
|
url,
|
||||||
|
size,
|
||||||
|
isCurrent: () => this._spreadsheetPreviewToken === token,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_disposeSpreadsheetPreview() {
|
||||||
|
const handle = this._spreadsheetPreview;
|
||||||
|
this._spreadsheetPreview = null;
|
||||||
|
this._spreadsheetPreviewToken = null;
|
||||||
|
try {
|
||||||
|
handle?.dispose();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to dispose spreadsheet preview:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -5056,7 +5106,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||||
<div>Show a file here by running:</div>
|
<div>Show a file here by running:</div>
|
||||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ function isExternalCliRunMode(mode) {
|
|||||||
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
|
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Does this session lack the Claude-only features (Respawn, Ralph)? The registry's
|
||||||
|
// `capabilities.external`, the flag the server's isExternalCliMode() reads. Not
|
||||||
|
// isExternalCliRunMode(): that picks a launch path, and claude is `kind: 'agent'` too.
|
||||||
|
function isExternalCliSession(mode) {
|
||||||
|
return registryCliById(mode)?.external ?? isExternalCliRunMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Build envOverrides payload from case + global settings.
|
* Build envOverrides payload from case + global settings.
|
||||||
@@ -2477,7 +2484,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
const isAltMode = isExternalCliRunMode(session.mode);
|
const isAltMode = isExternalCliSession(session.mode);
|
||||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
|
|||||||
@@ -455,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||||
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
|
||||||
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
|
||||||
|
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
|
||||||
|
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
|
||||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||||
@@ -504,7 +506,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
|
||||||
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings);
|
||||||
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings);
|
||||||
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||||
|
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||||
|
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||||
document.getElementById('appSettingsSessionListLayout').value =
|
document.getElementById('appSettingsSessionListLayout').value =
|
||||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||||
@@ -530,6 +534,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
settings.codexDangerouslyBypassApprovals ?? false;
|
settings.codexDangerouslyBypassApprovals ?? false;
|
||||||
document.getElementById('appSettingsCodexAnimations').checked =
|
document.getElementById('appSettingsCodexAnimations').checked =
|
||||||
settings.codexAnimationsEnabled ?? false;
|
settings.codexAnimationsEnabled ?? false;
|
||||||
|
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
|
||||||
|
document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? '';
|
||||||
this._applyCodexSettingsVisibility();
|
this._applyCodexSettingsVisibility();
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||||
@@ -1760,17 +1766,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
_updateTunnelUrlDisplay(url) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById('tunnelUrlRow');
|
||||||
const display = document.getElementById(displayId);
|
const display = document.getElementById('tunnelUrlDisplay');
|
||||||
if (!row || !display) return;
|
if (!row || !display) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url + suffix;
|
|
||||||
row.style.display = '';
|
row.style.display = '';
|
||||||
display.textContent = fullUrl;
|
display.textContent = url;
|
||||||
display.onclick = () => {
|
display.onclick = () => {
|
||||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
this.showToast('Tunnel URL copied', 'success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -1780,11 +1785,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlDisplay(url) {
|
|
||||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
|
||||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
|
||||||
},
|
|
||||||
|
|
||||||
showTunnelQR() {
|
showTunnelQR() {
|
||||||
// Close existing popup if open
|
// Close existing popup if open
|
||||||
this.closeTunnelQR();
|
this.closeTunnelQR();
|
||||||
@@ -2556,6 +2556,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
|
||||||
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
|
||||||
|
// Clamped here and again on the server; an empty or odd value falls back to the default.
|
||||||
|
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
|
||||||
|
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
|
||||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||||
@@ -2590,6 +2593,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
// Codex CLI settings
|
// Codex CLI settings
|
||||||
|
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
|
||||||
|
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
|
||||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
@@ -2609,6 +2614,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SettingsUpdateSchema is .strict() and checks codexModel with this same
|
||||||
|
// pattern, so one bad character 400s the WHOLE settings PUT while the toast
|
||||||
|
// still says "Settings saved". Refuse it here, before anything is persisted.
|
||||||
|
if (!/^[A-Za-z0-9._\/-]*$/.test(settings.codexModel)) {
|
||||||
|
this.showToast('Default Codex model may only contain letters, digits, ".", "_", "-" and "/"', 'error');
|
||||||
|
document.getElementById('appSettingsCodexModel')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
|
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
|
||||||
// UI, but their features still read settings.showTokenCount / settings.showCost
|
// UI, but their features still read settings.showTokenCount / settings.showCost
|
||||||
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
||||||
@@ -2816,6 +2830,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
|
||||||
showGitStatus: _sgs,
|
showGitStatus: _sgs,
|
||||||
gitStatusTree: _gst,
|
gitStatusTree: _gst,
|
||||||
|
gitStatusMaxRepos: _gsm,
|
||||||
|
gitStatusTimeoutSeconds: _gst2,
|
||||||
showTabDetachButton: _tdb,
|
showTabDetachButton: _tdb,
|
||||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
mobileOverviewEnabled: _mov,
|
mobileOverviewEnabled: _mov,
|
||||||
@@ -3226,12 +3242,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
||||||
_syncCliLaunchCatalog() {
|
_syncCliLaunchCatalog() {
|
||||||
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
|
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
|
||||||
|
// /api/clis rows carry no capabilities, so keep the served catalog's `external`
|
||||||
|
// (isExternalCliSession() reads it). A new custom CLI has none and falls back to `kind`.
|
||||||
|
const previous = new Map(
|
||||||
|
(Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []).map((cli) => [cli.id, cli])
|
||||||
|
);
|
||||||
window.__codemanCliCatalog = this._cliList.map((cli) => ({
|
window.__codemanCliCatalog = this._cliList.map((cli) => ({
|
||||||
id: cli.id,
|
id: cli.id,
|
||||||
label: cli.label,
|
label: cli.label,
|
||||||
shortBadge: cli.shortBadge,
|
shortBadge: cli.shortBadge,
|
||||||
order: cli.order,
|
order: cli.order,
|
||||||
kind: cli.kind,
|
kind: cli.kind,
|
||||||
|
external: previous.get(cli.id)?.external,
|
||||||
enabled: cli.enabled,
|
enabled: cli.enabled,
|
||||||
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
||||||
}));
|
}));
|
||||||
@@ -3701,7 +3723,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
|
// default OFF, per-device). Mirrored as a class on <html>: styles.css hides
|
||||||
// .tab-detach without it (a tab that is already detached keeps its icon as
|
// .tab-detach without it (a tab that is already detached keeps its icon as
|
||||||
// the re-focus affordance for the popped-out window).
|
// the re-focus affordance for the popped-out window).
|
||||||
const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
|
// Under a host that opens windows (see hasHostWindows) popping out is the
|
||||||
|
// way to get two panes side by side, so the button defaults on there.
|
||||||
|
const showTabDetach =
|
||||||
|
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||||
|
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||||
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
|
||||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||||
@@ -4215,7 +4241,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'autoCopySelection', 'copyStripMargin',
|
'autoCopySelection', 'copyStripMargin',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
'sessionLineageLines',
|
'sessionLineageLines',
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
|
||||||
|
*
|
||||||
|
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
|
||||||
|
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
|
||||||
|
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
|
||||||
|
* given a STORE-only archive rebuilt from the entries admission inflated, never
|
||||||
|
* the fetched bytes, so it can only parse what admission counted. The page never
|
||||||
|
* loads either vendor file. Cell values are sent back as plain strings; the
|
||||||
|
* renderer writes them with `textContent`. Formulas are never evaluated (the
|
||||||
|
* cached result is shown, else the formula text), and nothing here fetches:
|
||||||
|
* external links, images and drawings are reported as unsupported features.
|
||||||
|
*
|
||||||
|
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
|
||||||
|
* keeps a reverse-proxy `--base-url` mount working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
|
||||||
|
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
|
||||||
|
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
|
||||||
|
|
||||||
|
const core = self.CodemanSpreadsheetXlsxCore;
|
||||||
|
let workbook = null;
|
||||||
|
let sheetsById = new Map();
|
||||||
|
// Per sheet: its populated rows in order, each with its populated cells in
|
||||||
|
// column order, built once at load from the keys that exist (`populatedRowIndex`).
|
||||||
|
let populatedRowsById = new Map();
|
||||||
|
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
|
||||||
|
// which is far too much to pay on every tile.
|
||||||
|
let mergesById = new Map();
|
||||||
|
let normalizedStyles = [];
|
||||||
|
let styleIds = new Map();
|
||||||
|
let themePalette = core.DEFAULT_THEME_PALETTE;
|
||||||
|
|
||||||
|
function postError(error) {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
code: error?.code || 'parse-failed',
|
||||||
|
message: error?.message || 'Spreadsheet preview failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum cells in one tile reply; the renderer draws at most this many too.
|
||||||
|
const MAX_TILE_CELLS = 2500;
|
||||||
|
|
||||||
|
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
|
||||||
|
// entry is the fallback and the default Office palette is the last resort.
|
||||||
|
function readThemeXml(loadedWorkbook, admittedEntries) {
|
||||||
|
const stashed = loadedWorkbook?._themes?.theme1;
|
||||||
|
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
|
||||||
|
const theme = admittedEntries?.['xl/theme/theme1.xml'];
|
||||||
|
return theme ? new TextDecoder().decode(theme) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStyle(cell) {
|
||||||
|
// Colours are resolved and contrast-checked as a PAIR. Emitting a
|
||||||
|
// font colour without its background lets workbook text land on the skin's
|
||||||
|
// `var(--bg-primary)` and disappear.
|
||||||
|
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
|
||||||
|
const style = {
|
||||||
|
font: {
|
||||||
|
bold: Boolean(cell.font?.bold),
|
||||||
|
italic: Boolean(cell.font?.italic),
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
fill: colors.background,
|
||||||
|
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
|
||||||
|
wrapText: Boolean(cell.alignment?.wrapText),
|
||||||
|
};
|
||||||
|
const key = JSON.stringify(style);
|
||||||
|
if (styleIds.has(key)) return styleIds.get(key);
|
||||||
|
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
|
||||||
|
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
|
||||||
|
}
|
||||||
|
const id = normalizedStyles.length;
|
||||||
|
normalizedStyles.push(style);
|
||||||
|
styleIds.set(key, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
|
||||||
|
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
|
||||||
|
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
|
||||||
|
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
|
||||||
|
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
|
||||||
|
function presentIndices(sparse) {
|
||||||
|
const indices = [];
|
||||||
|
for (const key of Object.keys(sparse || [])) {
|
||||||
|
const index = Number(key);
|
||||||
|
if (Number.isInteger(index) && index >= 0) indices.push(index);
|
||||||
|
}
|
||||||
|
return indices.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
|
||||||
|
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
|
||||||
|
// cell counts when it exists and its type is not `ValueType.Null`, and a row
|
||||||
|
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
|
||||||
|
function populatedRowIndex(sheet) {
|
||||||
|
const nullType = self.ExcelJS.ValueType.Null;
|
||||||
|
const rows = [];
|
||||||
|
for (const rowIndex of presentIndices(sheet._rows)) {
|
||||||
|
const row = sheet._rows[rowIndex];
|
||||||
|
if (!row) continue;
|
||||||
|
const cells = [];
|
||||||
|
for (const cellIndex of presentIndices(row._cells)) {
|
||||||
|
const cell = row._cells[cellIndex];
|
||||||
|
if (cell && cell.type !== nullType) cells.push(cell);
|
||||||
|
}
|
||||||
|
if (cells.length > 0) rows.push({ number: row.number, row, cells });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worksheetMetadata(sheet) {
|
||||||
|
const cellRefs = [];
|
||||||
|
const rowOverrides = [];
|
||||||
|
const populatedRows = populatedRowIndex(sheet);
|
||||||
|
for (const { number, row, cells } of populatedRows) {
|
||||||
|
for (const cell of cells) {
|
||||||
|
cellRefs.push(cell.address);
|
||||||
|
normalizeStyle(cell);
|
||||||
|
}
|
||||||
|
if (row.hidden) rowOverrides.push([number, 0]);
|
||||||
|
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
|
||||||
|
}
|
||||||
|
// `sheet.model` rebuilds every row and cell model, so merges come straight
|
||||||
|
// from ExcelJS's own merge map, in the order the model getter would list them.
|
||||||
|
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
|
||||||
|
const extent = core.deriveExtent(cellRefs, merges);
|
||||||
|
const columnOverrides = [];
|
||||||
|
for (let col = 1; col <= extent.cols; col += 1) {
|
||||||
|
const column = sheet.getColumn(col);
|
||||||
|
if (column.hidden) columnOverrides.push([col, 0]);
|
||||||
|
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
populatedRows,
|
||||||
|
merges,
|
||||||
|
metadata: {
|
||||||
|
id: String(sheet.id),
|
||||||
|
name: sheet.name,
|
||||||
|
rows: extent.rows,
|
||||||
|
cols: extent.cols,
|
||||||
|
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
|
||||||
|
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
|
||||||
|
rowOverrides,
|
||||||
|
columnOverrides,
|
||||||
|
merges,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellDisplay(cell, date1904, warnings) {
|
||||||
|
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
|
||||||
|
if (formatted.warning) warnings.add(formatted.warning);
|
||||||
|
return formatted.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkbook(bytes) {
|
||||||
|
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
|
||||||
|
const admitted = core.buildAdmittedArchive(admission, self.fflate);
|
||||||
|
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
|
||||||
|
const nextWorkbook = new self.ExcelJS.Workbook();
|
||||||
|
// ExcelJS's DefinedNames model setter expands every range into one object per
|
||||||
|
// cell (a whole-sheet name exhausts the heap), and admission reads only the
|
||||||
|
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
|
||||||
|
// stored at all; print areas and titles are split off before this setter runs.
|
||||||
|
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||||
|
// than silently expanding again.
|
||||||
|
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||||
|
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||||
|
// object (a whole-column dropdown is a million), and the preview never shows
|
||||||
|
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||||
|
// backstop behind admission's row count, which also caps the workbook total.
|
||||||
|
await nextWorkbook.xlsx.load(admitted, {
|
||||||
|
ignoreNodes: ['dataValidations'],
|
||||||
|
maxRows: core.LIMITS.maxRowsPerSheet,
|
||||||
|
});
|
||||||
|
const nextSheets = new Map();
|
||||||
|
const nextRows = new Map();
|
||||||
|
const nextMerges = new Map();
|
||||||
|
normalizedStyles = [];
|
||||||
|
styleIds = new Map();
|
||||||
|
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||||
|
const sheets = [];
|
||||||
|
for (const sheet of nextWorkbook.worksheets) {
|
||||||
|
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
|
||||||
|
const sheetResult = worksheetMetadata(sheet);
|
||||||
|
const metadata = sheetResult.metadata;
|
||||||
|
nextSheets.set(metadata.id, sheet);
|
||||||
|
nextRows.set(metadata.id, sheetResult.populatedRows);
|
||||||
|
nextMerges.set(metadata.id, sheetResult.merges);
|
||||||
|
sheets.push(metadata);
|
||||||
|
}
|
||||||
|
workbook = nextWorkbook;
|
||||||
|
sheetsById = nextSheets;
|
||||||
|
populatedRowsById = nextRows;
|
||||||
|
mergesById = nextMerges;
|
||||||
|
self.postMessage({
|
||||||
|
type: 'metadata',
|
||||||
|
sheets,
|
||||||
|
styles: normalizedStyles,
|
||||||
|
date1904: Boolean(workbook.properties?.date1904),
|
||||||
|
empty: sheets.length === 0,
|
||||||
|
warnings: admission.features,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTile(message) {
|
||||||
|
if (!workbook) throw new Error('Workbook is not loaded');
|
||||||
|
const sheet = sheetsById.get(String(message.sheetId));
|
||||||
|
if (!sheet) throw new Error('Worksheet is unavailable');
|
||||||
|
const range = message.range;
|
||||||
|
const warnings = new Set();
|
||||||
|
const cells = [];
|
||||||
|
const seenCells = new Set();
|
||||||
|
let truncated = false;
|
||||||
|
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
|
||||||
|
// (a filtered sheet); they are never drawn, so never sent.
|
||||||
|
const hiddenColumns = new Map();
|
||||||
|
const columnHidden = (col) => {
|
||||||
|
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
|
||||||
|
return hiddenColumns.get(col);
|
||||||
|
};
|
||||||
|
const addCell = (cell) => {
|
||||||
|
const key = `${cell.row}:${cell.col}`;
|
||||||
|
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
|
||||||
|
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
|
||||||
|
if (cells.length >= MAX_TILE_CELLS) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenCells.add(key);
|
||||||
|
cells.push({
|
||||||
|
row: cell.row,
|
||||||
|
col: cell.col,
|
||||||
|
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
|
||||||
|
styleId: normalizeStyle(cell),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
|
||||||
|
for (const populated of populatedRows) {
|
||||||
|
if (truncated) break;
|
||||||
|
if (populated.number < range.r1) continue;
|
||||||
|
if (populated.number > range.r2) break;
|
||||||
|
if (populated.row.hidden) continue;
|
||||||
|
for (const cell of populated.cells) {
|
||||||
|
if (cell.col < range.c1) continue;
|
||||||
|
if (cell.col > range.c2) break;
|
||||||
|
addCell(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
|
||||||
|
for (const merge of merges) {
|
||||||
|
const anchor = core.parseRange(merge);
|
||||||
|
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
|
||||||
|
}
|
||||||
|
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
|
||||||
|
self.postMessage({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: message.requestId,
|
||||||
|
sheetId: String(message.sheetId),
|
||||||
|
cells,
|
||||||
|
merges,
|
||||||
|
// One counted entry for many unsupported number formats keeps the notice bar short.
|
||||||
|
warnings: core.foldWarnings(Array.from(warnings)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
try {
|
||||||
|
const message = event.data || {};
|
||||||
|
if (message.type === 'load') await loadWorkbook(message.bytes);
|
||||||
|
else if (message.type === 'tile') sendTile(message);
|
||||||
|
else if (message.type === 'dispose') {
|
||||||
|
workbook = null;
|
||||||
|
sheetsById = new Map();
|
||||||
|
populatedRowsById = new Map();
|
||||||
|
mergesById = new Map();
|
||||||
|
themePalette = core.DEFAULT_THEME_PALETTE;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
postError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.postMessage({ type: 'ready' });
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
|
||||||
|
*
|
||||||
|
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
|
||||||
|
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
|
||||||
|
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
|
||||||
|
* the visible tile of cells. Parsing happens entirely in the browser worker; the
|
||||||
|
* server just streams the file through its existing confined raw routes.
|
||||||
|
*
|
||||||
|
* Every workbook string (cell text, sheet names) is written with `textContent`,
|
||||||
|
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
|
||||||
|
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
|
||||||
|
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
|
||||||
|
* or closed.
|
||||||
|
*
|
||||||
|
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
|
||||||
|
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function initSpreadsheetPreview(global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||||
|
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||||
|
const DEFAULT_TIMEOUT_MS = 20000;
|
||||||
|
const MAX_SCROLL_PX = 8000000;
|
||||||
|
const ROW_HEADING_WIDTH = 36;
|
||||||
|
const COLUMN_HEADING_HEIGHT = 20;
|
||||||
|
const assets = Object.freeze({
|
||||||
|
version: SPREADSHEET_ASSET_VERSION,
|
||||||
|
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
function message(container, text, kind) {
|
||||||
|
container.textContent = '';
|
||||||
|
const state = document.createElement('div');
|
||||||
|
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
|
||||||
|
state.textContent = text;
|
||||||
|
container.appendChild(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePreviewUrl(candidate) {
|
||||||
|
const url = new URL(candidate, global.location.href);
|
||||||
|
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
|
||||||
|
url.searchParams.set('preview', 'true');
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(options) {
|
||||||
|
const container = options.container;
|
||||||
|
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
|
||||||
|
let disposed = false;
|
||||||
|
let worker = null;
|
||||||
|
let controller = null;
|
||||||
|
let timer = null;
|
||||||
|
let metadata = null;
|
||||||
|
let activeSheetId = null;
|
||||||
|
let latestRequestId = 0;
|
||||||
|
let grid = null;
|
||||||
|
let spacer = null;
|
||||||
|
let cellsLayer = null;
|
||||||
|
let headingsLayer = null;
|
||||||
|
let emptySheetState = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
let latestRange = null;
|
||||||
|
let latestAxes = null;
|
||||||
|
let scrollFrame = null;
|
||||||
|
|
||||||
|
const current = () => !disposed && isCurrent();
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer !== null) global.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
const fail = (text) => {
|
||||||
|
if (!current()) return;
|
||||||
|
clearTimer();
|
||||||
|
message(container, text || 'Spreadsheet preview failed', 'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
function sheetMetadata() {
|
||||||
|
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix sums per override list, built once per sheet's axis: renderTile
|
||||||
|
// asks for several offsets per cell, so a linear walk over every override
|
||||||
|
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
|
||||||
|
const axisDeltas = new WeakMap();
|
||||||
|
|
||||||
|
function overrideDeltas(overrides, defaultSize) {
|
||||||
|
let entry = axisDeltas.get(overrides);
|
||||||
|
if (!entry || entry.defaultSize !== defaultSize) {
|
||||||
|
const deltas = [];
|
||||||
|
let delta = 0;
|
||||||
|
for (const [, size] of overrides) {
|
||||||
|
delta += size - defaultSize;
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
entry = { defaultSize, deltas };
|
||||||
|
axisDeltas.set(overrides, entry);
|
||||||
|
}
|
||||||
|
return entry.deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisOffset(count, defaultSize, overrides, index) {
|
||||||
|
const bounded = Math.max(1, Math.min(count + 1, index));
|
||||||
|
const list = overrides || [];
|
||||||
|
let low = 0;
|
||||||
|
let high = list.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (list[mid][0] < bounded) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisIndex(count, defaultSize, overrides, offset) {
|
||||||
|
let low = 1;
|
||||||
|
let high = Math.max(1, count);
|
||||||
|
while (low < high) {
|
||||||
|
const mid = Math.floor((low + high + 1) / 2);
|
||||||
|
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
|
||||||
|
else high = mid - 1;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
|
||||||
|
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
|
||||||
|
// range, so the last row stays reachable) and the tile is laid out at real
|
||||||
|
// sizes from there. `shift` is the logical offset minus the scroll offset,
|
||||||
|
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
|
||||||
|
function scrollAxis(logical, scroll, viewport, heading) {
|
||||||
|
const shown = Math.min(MAX_SCROLL_PX, logical);
|
||||||
|
const scrollRange = Math.max(0, heading + shown - viewport);
|
||||||
|
const logicalRange = Math.max(0, heading + logical - viewport);
|
||||||
|
const virtual =
|
||||||
|
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
|
||||||
|
return { virtual, shift: virtual - scroll, end: heading + shown };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTile() {
|
||||||
|
if (!current() || !worker || !grid) return;
|
||||||
|
const sheet = sheetMetadata();
|
||||||
|
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
||||||
|
const viewHeight = grid.clientHeight || 500;
|
||||||
|
const viewWidth = grid.clientWidth || 800;
|
||||||
|
const y = scrollAxis(
|
||||||
|
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
|
||||||
|
grid.scrollTop,
|
||||||
|
viewHeight,
|
||||||
|
COLUMN_HEADING_HEIGHT
|
||||||
|
);
|
||||||
|
const x = scrollAxis(
|
||||||
|
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
|
||||||
|
grid.scrollLeft,
|
||||||
|
viewWidth,
|
||||||
|
ROW_HEADING_WIDTH
|
||||||
|
);
|
||||||
|
const r1 = Math.max(
|
||||||
|
1,
|
||||||
|
axisIndex(
|
||||||
|
sheet.rows,
|
||||||
|
sheet.defaultRowHeight,
|
||||||
|
sheet.rowOverrides,
|
||||||
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
|
||||||
|
) - 2
|
||||||
|
);
|
||||||
|
const c1 = Math.max(
|
||||||
|
1,
|
||||||
|
axisIndex(
|
||||||
|
sheet.cols,
|
||||||
|
sheet.defaultColumnWidth,
|
||||||
|
sheet.columnOverrides,
|
||||||
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
|
||||||
|
) - 2
|
||||||
|
);
|
||||||
|
const r2 = Math.min(
|
||||||
|
sheet.rows,
|
||||||
|
axisIndex(
|
||||||
|
sheet.rows,
|
||||||
|
sheet.defaultRowHeight,
|
||||||
|
sheet.rowOverrides,
|
||||||
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
|
||||||
|
) + 2
|
||||||
|
);
|
||||||
|
const c2 = Math.min(
|
||||||
|
sheet.cols,
|
||||||
|
axisIndex(
|
||||||
|
sheet.cols,
|
||||||
|
sheet.defaultColumnWidth,
|
||||||
|
sheet.columnOverrides,
|
||||||
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
|
||||||
|
) + 2
|
||||||
|
);
|
||||||
|
latestRequestId += 1;
|
||||||
|
latestRange = { r1, c1, r2, c2 };
|
||||||
|
latestAxes = { y, x };
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: latestRequestId,
|
||||||
|
sheetId: String(activeSheetId),
|
||||||
|
range: { r1, c1, r2, c2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWarnings(tileWarnings) {
|
||||||
|
const notice = container.querySelector('.spreadsheet-preview-notice');
|
||||||
|
if (!notice) return;
|
||||||
|
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
|
||||||
|
notice.hidden = warnings.length === 0;
|
||||||
|
const warningLabel =
|
||||||
|
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
|
||||||
|
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinHeadings() {
|
||||||
|
if (!grid || !headingsLayer) return;
|
||||||
|
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
|
||||||
|
heading.style.left = `${grid.scrollLeft}px`;
|
||||||
|
});
|
||||||
|
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
|
||||||
|
heading.style.top = `${grid.scrollTop}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTile(tile) {
|
||||||
|
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
|
||||||
|
const sheet = sheetMetadata();
|
||||||
|
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
|
||||||
|
const { y, x } = latestAxes;
|
||||||
|
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
|
||||||
|
// it never grows the scroll area.
|
||||||
|
const rowTop = (row) =>
|
||||||
|
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
|
||||||
|
const colLeft = (col) =>
|
||||||
|
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
|
||||||
|
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
|
||||||
|
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
|
||||||
|
cellsLayer.textContent = '';
|
||||||
|
headingsLayer.textContent = '';
|
||||||
|
const mergeByAnchor = new Map();
|
||||||
|
for (const merge of tile.merges || []) {
|
||||||
|
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
|
||||||
|
if (!match) continue;
|
||||||
|
const column = (letters) =>
|
||||||
|
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
|
||||||
|
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
|
||||||
|
r2: Number(match[4]),
|
||||||
|
c2: column(match[3]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const cell of tile.cells.slice(0, 2500)) {
|
||||||
|
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
||||||
|
const height = rowSpan(cell.row, merge?.r2 || cell.row);
|
||||||
|
const width = colSpan(cell.col, merge?.c2 || cell.col);
|
||||||
|
// A cell clipped to nothing at the spacer's edge (or sized 0 px) is
|
||||||
|
// skipped like its heading: padding and border would still draw it as a
|
||||||
|
// small box below the spacer and grow the scroll area.
|
||||||
|
if (height <= 0 || width <= 0) continue;
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
|
||||||
|
element.dataset.row = String(cell.row);
|
||||||
|
element.dataset.col = String(cell.col);
|
||||||
|
element.textContent = String(cell.text ?? '');
|
||||||
|
element.style.top = `${rowTop(cell.row)}px`;
|
||||||
|
element.style.left = `${colLeft(cell.col)}px`;
|
||||||
|
element.style.height = `${height}px`;
|
||||||
|
element.style.width = `${width}px`;
|
||||||
|
cellsLayer.appendChild(element);
|
||||||
|
}
|
||||||
|
// Headings take their size from the same axis math as the cells, so custom
|
||||||
|
// widths/heights line up; hidden (0 px) rows and columns get no heading and
|
||||||
|
// do not count against the heading caps.
|
||||||
|
let rowHeadings = 0;
|
||||||
|
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
||||||
|
const height = rowSpan(row, row);
|
||||||
|
if (height <= 0) continue;
|
||||||
|
rowHeadings += 1;
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'spreadsheet-row-heading';
|
||||||
|
heading.textContent = String(row);
|
||||||
|
heading.style.top = `${rowTop(row)}px`;
|
||||||
|
heading.style.height = `${height}px`;
|
||||||
|
heading.style.left = `${grid.scrollLeft}px`;
|
||||||
|
headingsLayer.appendChild(heading);
|
||||||
|
}
|
||||||
|
let columnHeadings = 0;
|
||||||
|
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||||
|
const width = colSpan(col, col);
|
||||||
|
if (width <= 0) continue;
|
||||||
|
columnHeadings += 1;
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'spreadsheet-column-heading';
|
||||||
|
let label = '';
|
||||||
|
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
||||||
|
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
|
||||||
|
heading.textContent = label;
|
||||||
|
heading.style.left = `${colLeft(col)}px`;
|
||||||
|
heading.style.width = `${width}px`;
|
||||||
|
heading.style.top = `${grid.scrollTop}px`;
|
||||||
|
headingsLayer.appendChild(heading);
|
||||||
|
}
|
||||||
|
renderWarnings(tile.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSheet(sheetId) {
|
||||||
|
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
|
||||||
|
activeSheetId = String(sheetId);
|
||||||
|
latestRequestId += 1;
|
||||||
|
latestRange = null;
|
||||||
|
latestAxes = null;
|
||||||
|
if (cellsLayer) cellsLayer.textContent = '';
|
||||||
|
if (headingsLayer) headingsLayer.textContent = '';
|
||||||
|
container.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||||
|
const selected = tab.dataset.sheetId === activeSheetId;
|
||||||
|
tab.setAttribute('aria-selected', String(selected));
|
||||||
|
tab.tabIndex = selected ? 0 : -1;
|
||||||
|
});
|
||||||
|
if (grid) {
|
||||||
|
grid.scrollTop = 0;
|
||||||
|
grid.scrollLeft = 0;
|
||||||
|
}
|
||||||
|
const sheet = sheetMetadata();
|
||||||
|
if (sheet && spacer) {
|
||||||
|
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
|
||||||
|
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
|
||||||
|
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
|
||||||
|
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
|
||||||
|
}
|
||||||
|
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
|
||||||
|
renderWarnings([]);
|
||||||
|
requestTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetadata(nextMetadata) {
|
||||||
|
if (!current()) return;
|
||||||
|
metadata = nextMetadata;
|
||||||
|
container.textContent = '';
|
||||||
|
if (!metadata.sheets?.length) {
|
||||||
|
message(container, 'This workbook has no visible worksheets.', 'empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
shell.className = 'spreadsheet-preview-shell';
|
||||||
|
const styleSheet = document.createElement('style');
|
||||||
|
styleSheet.textContent = (metadata.styles || [])
|
||||||
|
.map((style, id) => {
|
||||||
|
const declarations = [];
|
||||||
|
if (style.font?.bold) declarations.push('font-weight:700');
|
||||||
|
if (style.font?.italic) declarations.push('font-style:italic');
|
||||||
|
// Colour and background are emitted together or not at all.
|
||||||
|
// The worker already contrast-checked them as a pair; contributing
|
||||||
|
// one half would drop the cell back onto the skin's own background.
|
||||||
|
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
|
||||||
|
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
|
||||||
|
}
|
||||||
|
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
|
||||||
|
if (style.wrapText) declarations.push('white-space:normal');
|
||||||
|
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const tabs = document.createElement('div');
|
||||||
|
tabs.className = 'spreadsheet-sheet-tabs';
|
||||||
|
tabs.setAttribute('role', 'tablist');
|
||||||
|
tabs.setAttribute('data-i18n-skip', '');
|
||||||
|
for (const sheet of metadata.sheets) {
|
||||||
|
const tab = document.createElement('button');
|
||||||
|
tab.type = 'button';
|
||||||
|
tab.className = 'spreadsheet-sheet-tab';
|
||||||
|
tab.setAttribute('role', 'tab');
|
||||||
|
tab.dataset.sheetId = String(sheet.id);
|
||||||
|
tab.textContent = sheet.name;
|
||||||
|
tab.addEventListener('click', () => selectSheet(sheet.id));
|
||||||
|
tabs.appendChild(tab);
|
||||||
|
}
|
||||||
|
const notice = document.createElement('div');
|
||||||
|
notice.className = 'spreadsheet-preview-notice';
|
||||||
|
notice.hidden = true;
|
||||||
|
emptySheetState = document.createElement('div');
|
||||||
|
emptySheetState.className = 'spreadsheet-empty-sheet';
|
||||||
|
emptySheetState.textContent = 'This worksheet is empty.';
|
||||||
|
emptySheetState.hidden = true;
|
||||||
|
grid = document.createElement('div');
|
||||||
|
grid.className = 'spreadsheet-grid';
|
||||||
|
grid.setAttribute('data-i18n-skip', '');
|
||||||
|
spacer = document.createElement('div');
|
||||||
|
spacer.className = 'spreadsheet-grid-spacer';
|
||||||
|
cellsLayer = document.createElement('div');
|
||||||
|
cellsLayer.className = 'spreadsheet-cells';
|
||||||
|
headingsLayer = document.createElement('div');
|
||||||
|
headingsLayer.className = 'spreadsheet-headings';
|
||||||
|
grid.append(spacer, cellsLayer, headingsLayer);
|
||||||
|
grid.addEventListener(
|
||||||
|
'scroll',
|
||||||
|
() => {
|
||||||
|
pinHeadings();
|
||||||
|
if (scrollFrame !== null) return;
|
||||||
|
scrollFrame = global.requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
|
requestTile();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
|
||||||
|
container.appendChild(shell);
|
||||||
|
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
|
||||||
|
resizeObserver?.observe(grid);
|
||||||
|
selectSheet(metadata.sheets[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
clearTimer();
|
||||||
|
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
|
||||||
|
controller?.abort();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
try {
|
||||||
|
worker?.postMessage({ type: 'dispose' });
|
||||||
|
worker?.terminate();
|
||||||
|
} catch {
|
||||||
|
// A worker that failed during startup may already be unavailable.
|
||||||
|
}
|
||||||
|
worker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (Number(options.size) > MAX_PREVIEW_BYTES) {
|
||||||
|
fail('This workbook is too large to preview (10 MB limit).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message(container, 'Loading spreadsheet…', 'loading');
|
||||||
|
let previewUrl;
|
||||||
|
try {
|
||||||
|
previewUrl = safePreviewUrl(options.url);
|
||||||
|
controller = new AbortController();
|
||||||
|
// Root-absolute paths ignore <base href>; route through the mount prefix.
|
||||||
|
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
|
||||||
|
const ready = new Promise((resolve, reject) => {
|
||||||
|
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
|
||||||
|
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
if (event.data?.type === 'ready') resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const responsePromise = fetch(previewUrl, { signal: controller.signal });
|
||||||
|
const [response] = await Promise.all([responsePromise, ready]);
|
||||||
|
if (!current()) return;
|
||||||
|
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
|
||||||
|
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
if (!current()) return;
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
if (!current()) return;
|
||||||
|
const payload = event.data || {};
|
||||||
|
if (payload.type === 'metadata') {
|
||||||
|
clearTimer();
|
||||||
|
renderMetadata(payload);
|
||||||
|
} else if (payload.type === 'tile') renderTile(payload);
|
||||||
|
else if (payload.type === 'error') fail(payload.message);
|
||||||
|
};
|
||||||
|
worker.onerror = () => fail('Spreadsheet parser failed.');
|
||||||
|
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
|
||||||
|
timer = global.setTimeout(() => {
|
||||||
|
worker?.terminate();
|
||||||
|
fail('Spreadsheet preview timed out.');
|
||||||
|
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
worker.postMessage({ type: 'load', bytes }, [bytes]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start();
|
||||||
|
return Object.freeze({ dispose, selectSheet, resize: requestTile });
|
||||||
|
}
|
||||||
|
|
||||||
|
global.CodemanSpreadsheetPreviewAssets = assets;
|
||||||
|
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
|
||||||
|
})(window);
|
||||||
File diff suppressed because it is too large
Load Diff
+176
-1
@@ -5355,7 +5355,8 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
z-index inside the toolbar. When the popover is open, raise the toolbar
|
z-index inside the toolbar. When the popover is open, raise the toolbar
|
||||||
above the CJK input (z-index 52) so the popover is interactable. */
|
above the CJK input (z-index 52) so the popover is interactable. */
|
||||||
.toolbar:has(.case-settings-popover:not(.hidden)),
|
.toolbar:has(.case-settings-popover:not(.hidden)),
|
||||||
.toolbar:has(.case-combobox-list:not(.hidden)) {
|
.toolbar:has(.case-combobox-list:not(.hidden)),
|
||||||
|
.toolbar:has(.run-mode-menu.active) {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5647,6 +5648,32 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
timestamp recent-session row still fits whole, which is what that list is for. */
|
timestamp recent-session row still fits whole, which is what that list is for. */
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
/* The menu grows UPWARD from the toolbar (`bottom: 100%`), so with every CLI, the custom
|
||||||
|
endpoint entries, Terminal and the saved URLs it can be taller than the room above the
|
||||||
|
toolbar: its top then sits off-screen with nothing to scroll, and the entries up there
|
||||||
|
cannot be reached (worst on a phone). Cap it to the space between the header and the
|
||||||
|
toolbar and scroll inside it. The cap follows --app-height, not `dvh`: the viewport meta
|
||||||
|
has no interactive-widget, so `dvh` does not shrink for an on-screen keyboard, while
|
||||||
|
MobileDetection always sets --app-height to the visual viewport height and KeyboardHandler
|
||||||
|
keeps it there while the keyboard is up. Both safe areas come off too, because the phone
|
||||||
|
header and toolbar grow by them in the iPhone home-screen app. The second line is the one
|
||||||
|
that applies (a declaration carrying var() is never dropped at parse time, so the first
|
||||||
|
line is not a working fallback for it; it stays only as the plain-viewport form). */
|
||||||
|
max-height: calc(100vh - var(--header-height) - var(--safe-area-top) - var(--toolbar-height) - var(--safe-area-bottom) - 16px);
|
||||||
|
max-height: calc(var(--app-height, 100dvh) - var(--header-height) - var(--safe-area-top) - var(--toolbar-height) - var(--safe-area-bottom) - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
/* overflow-y: auto computes overflow-x to auto as well, so a long nowrap custom-endpoint
|
||||||
|
label would show a horizontal scrollbar that touch-action: pan-y cannot pan. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
/* Children of a scrolling flex column must keep their natural height. Without this the ones
|
||||||
|
that have their own `overflow` (the history list, the saved-URL list, whose min-height is 0)
|
||||||
|
are squashed to nothing as the menu fills, instead of the menu scrolling. */
|
||||||
|
.run-mode-menu > * {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Above phone width the menu becomes a full-width drawer across the bottom of the
|
/* Above phone width the menu becomes a full-width drawer across the bottom of the
|
||||||
@@ -20161,6 +20188,138 @@ body.tile-grid-resizing--row * {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
|
||||||
|
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
|
||||||
|
.spreadsheet-preview-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-sheet-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-sheet-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-sheet-tab[aria-selected='true'] {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-preview-notice {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
/* A long warning list scrolls inside the bar instead of pushing the grid out of view. */
|
||||||
|
max-height: 4.5em;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--warning, #f59e0b);
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-grid {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-grid-spacer,
|
||||||
|
.spreadsheet-cells,
|
||||||
|
.spreadsheet-headings {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-row-heading,
|
||||||
|
.spreadsheet-column-heading {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the fixed gutter dimension lives here; the per-row height and per-column
|
||||||
|
width come inline from spreadsheet-preview.js's axis math. */
|
||||||
|
.spreadsheet-row-heading {
|
||||||
|
left: 0;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-column-heading {
|
||||||
|
top: 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-empty-sheet {
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
left: 36px;
|
||||||
|
z-index: 3;
|
||||||
|
width: max-content;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-cell {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-preview-message {
|
||||||
|
display: grid;
|
||||||
|
min-height: 220px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-preview-message.error {
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||||
|
|
||||||
@@ -20559,6 +20718,22 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Git status panel: a repository git could not read stays in the list, with the reason */
|
||||||
|
.git-status-repo--error {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-repo-unreadable {
|
||||||
|
color: #e0a030;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||||
Discussion #426 option C)
|
Discussion #426 option C)
|
||||||
|
|||||||
@@ -329,7 +329,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||||
|
this.detachedSessions?.has(sessionId)
|
||||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||||
: []),
|
: []),
|
||||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
* the case this module exists for, and the case its browser test asserts by
|
* the case this module exists for, and the case its browser test asserts by
|
||||||
* checking WHO delivered the byte rather than merely that one arrived.
|
* checking WHO delivered the byte rather than merely that one arrived.
|
||||||
*
|
*
|
||||||
|
* A SECOND failure lives in that same self-rescue: xterm diffs `newValue.replace(oldValue, '')`,
|
||||||
|
* which only works when the keyboard APPENDED. A soft keyboard that autocorrects on space
|
||||||
|
* (SwiftKey, Gboard) rewrites the tail instead: it deletes the word and inserts the corrected
|
||||||
|
* one, and xterm answers by sending the WHOLE new textarea value (the old value is not a
|
||||||
|
* substring of it), then sends the inserted text AGAIN from the second keydown's timer. One
|
||||||
|
* autocorrect turned `testing the peompt` + <space> into
|
||||||
|
* `testing the peompttesting the prompt rompt `. A multi-character delete is also sent as ONE
|
||||||
|
* DEL. `installEditSync` below replaces that diff with an edit-based one.
|
||||||
|
*
|
||||||
* The recovery never guesses the character: the `input` event already carries
|
* The recovery never guesses the character: the `input` event already carries
|
||||||
* the real committed text in `ev.data`, which is exactly what xterm itself
|
* the real committed text in `ev.data`, which is exactly what xterm itself
|
||||||
* would have forwarded. We only decide WHETHER to forward it, by asking
|
* would have forwarded. We only decide WHETHER to forward it, by asking
|
||||||
@@ -39,6 +48,21 @@
|
|||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What turns `previous` into `next`, as a terminal sees it: how many characters to delete from
|
||||||
|
* the END of the line, then what to type. Everything after the common prefix is treated as
|
||||||
|
* replaced, because a terminal can only edit at its cursor. Counts are code points, so an
|
||||||
|
* emoji is one DEL, as it is one backspace.
|
||||||
|
*/
|
||||||
|
function editBetween(previous, next) {
|
||||||
|
const a = Array.from(previous);
|
||||||
|
const b = Array.from(next);
|
||||||
|
let prefix = 0;
|
||||||
|
const max = Math.min(a.length, b.length);
|
||||||
|
while (prefix < max && a[prefix] === b[prefix]) prefix += 1;
|
||||||
|
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
|
||||||
|
}
|
||||||
|
|
||||||
function create(options) {
|
function create(options) {
|
||||||
const textarea = options?.textarea;
|
const textarea = options?.textarea;
|
||||||
const emitRecovered = options?.emitRecovered;
|
const emitRecovered = options?.emitRecovered;
|
||||||
@@ -65,6 +89,9 @@
|
|||||||
let keydownSnapshot = 0;
|
let keydownSnapshot = 0;
|
||||||
let composing = false;
|
let composing = false;
|
||||||
const pending = [];
|
const pending = [];
|
||||||
|
// Applies any edit-sync diff still waiting on its timer. Assigned by installEditSync() below; a
|
||||||
|
// no-op when xterm's internals are not available.
|
||||||
|
let settleEdit = () => {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve every candidate still pending, right now, instead of waiting for
|
* Resolve every candidate still pending, right now, instead of waiting for
|
||||||
@@ -147,6 +174,13 @@
|
|||||||
// reach the PTY — see flushPending(). This runs from xterm's custom key
|
// reach the PTY — see flushPending(). This runs from xterm's custom key
|
||||||
// handler, i.e. before xterm processes the key, so a recovered character
|
// handler, i.e. before xterm processes the key, so a recovered character
|
||||||
// is always ordered ahead of the bytes this keydown produces.
|
// is always ordered ahead of the bytes this keydown produces.
|
||||||
|
// ORDER MATTERS. Settle the edit-sync diff first: it bumps `canonicalCount` for the
|
||||||
|
// keystroke it belongs to, so flushPending() then stands that keystroke's orphan candidate
|
||||||
|
// down. Swapped, the candidate would resolve first and the character would be sent twice.
|
||||||
|
// It also has to happen BEFORE xterm handles THIS key: for Enter, xterm clears the textarea
|
||||||
|
// in its own keydown, and a timer left pending would then diff the whole line against ''
|
||||||
|
// and send one DEL per character ahead of the submitted line.
|
||||||
|
settleEdit(event);
|
||||||
flushPending();
|
flushPending();
|
||||||
keydownSnapshot = canonicalCount;
|
keydownSnapshot = canonicalCount;
|
||||||
}
|
}
|
||||||
@@ -176,6 +210,92 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace xterm's `_handleAnyTextareaChanges` (see the header) with an edit-based diff against
|
||||||
|
* the value the PTY side has already been told about. `synced` is that value; it is shared by
|
||||||
|
* every keydown's timer, so two timers that both see the final textarea value cannot both
|
||||||
|
* send it. Returns an uninstall function, or null when xterm's internals are not as expected
|
||||||
|
* (then xterm's own, flawed, behaviour is left in place).
|
||||||
|
*/
|
||||||
|
function installEditSync() {
|
||||||
|
let helper;
|
||||||
|
try {
|
||||||
|
helper = options.getCompositionHelper?.();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const original = helper?._handleAnyTextareaChanges;
|
||||||
|
const coreService = helper?._coreService;
|
||||||
|
if (typeof original !== 'function' || typeof coreService?.triggerDataEvent !== 'function') return null;
|
||||||
|
|
||||||
|
let synced = textarea.value;
|
||||||
|
const waiting = new Set();
|
||||||
|
|
||||||
|
/** Send what changed since `synced`, once, and remember it. */
|
||||||
|
function applyEdit() {
|
||||||
|
if (destroyed || helper._isComposing) return; // xterm's composition path owns this one
|
||||||
|
const current = textarea.value;
|
||||||
|
if (current === synced) return;
|
||||||
|
const { deleted, inserted } = editBetween(synced, current);
|
||||||
|
synced = current;
|
||||||
|
try {
|
||||||
|
// One DEL per character, like repeated backspace presses: the local-echo composer and
|
||||||
|
// the PTY both treat each as a single edit.
|
||||||
|
for (let i = 0; i < deleted; i += 1) coreService.triggerDataEvent('\x7f', true);
|
||||||
|
if (inserted) {
|
||||||
|
helper._dataAlreadySent = inserted;
|
||||||
|
coreService.triggerDataEvent(inserted, true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Delivery is best effort; never throw into the browser's timer queue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
helper._handleAnyTextareaChanges = function handleAnyTextareaChanges() {
|
||||||
|
if (destroyed) return original.call(this);
|
||||||
|
// No edit in flight and the value is not what we last sent: something outside the IME
|
||||||
|
// changed it (xterm clears it after Enter, a composition committed). Nothing to send;
|
||||||
|
// resynchronise.
|
||||||
|
if (waiting.size === 0 && synced !== textarea.value) synced = textarea.value;
|
||||||
|
const entry = { id: null };
|
||||||
|
waiting.add(entry);
|
||||||
|
entry.id = setTimer(() => {
|
||||||
|
waiting.delete(entry);
|
||||||
|
applyEdit();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply the pending edit NOW instead of on its timer (see handleKeyEvent).
|
||||||
|
settleEdit = (event) => {
|
||||||
|
if (waiting.size === 0) return;
|
||||||
|
for (const entry of waiting) {
|
||||||
|
try {
|
||||||
|
clearTimer(entry.id);
|
||||||
|
} catch {
|
||||||
|
// A broken timer host must not break input handling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cleared BEFORE the return below, on purpose: left pending, the timer would fire after
|
||||||
|
// Enter clears the textarea and send one DEL per character ahead of the submitted line.
|
||||||
|
waiting.clear();
|
||||||
|
// A composition just ended and this key makes xterm finalize it SYNCHRONOUSLY, through
|
||||||
|
// `_finalizeComposition(false)`, which ignores `_dataAlreadySent`: that text is xterm's, and
|
||||||
|
// sending the edit too would deliver it twice. On 229, CapsLock and the modifiers xterm keeps
|
||||||
|
// the composition on its async path, which honours `_dataAlreadySent`, so the edit still
|
||||||
|
// applies there. Only this settle path is guarded: on the timer path xterm always finalizes
|
||||||
|
// asynchronously, and skipping the edit there would drop a byte master delivers.
|
||||||
|
if (helper._isSendingComposition && ![229, 20, 16, 17, 18].includes(event?.keyCode)) return;
|
||||||
|
applyEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
settleEdit = () => {};
|
||||||
|
if (helper._handleAnyTextareaChanges !== original) helper._handleAnyTextareaChanges = original;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const uninstallEditSync = installEditSync();
|
||||||
|
|
||||||
function onCompositionStart() {
|
function onCompositionStart() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
composing = true;
|
composing = true;
|
||||||
@@ -191,6 +311,11 @@
|
|||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
cancelPending();
|
cancelPending();
|
||||||
|
try {
|
||||||
|
uninstallEditSync?.();
|
||||||
|
} catch {
|
||||||
|
// Best effort.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
textarea.removeEventListener('input', onInput, true);
|
textarea.removeEventListener('input', onInput, true);
|
||||||
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||||
@@ -228,5 +353,5 @@
|
|||||||
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
|
||||||
}
|
}
|
||||||
|
|
||||||
global.CodemanKeyCode229Recovery = Object.freeze({ create });
|
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -1273,7 +1273,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// A real mouse click normally reaches the PTY through xterm's own mouse
|
// A real mouse click normally reaches the PTY through xterm's own mouse
|
||||||
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
|
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
|
||||||
// the server strips the enabling DECSETs from claude/codex/gemini output
|
// the server strips the enabling DECSETs from claude/codex/gemini output
|
||||||
// (isAltScreenStripMode, session.ts) so the wheel keeps scrolling
|
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
|
||||||
|
// so a drag selects text) so the wheel keeps scrolling
|
||||||
// scrollback. Desktop clicks therefore stopped reporting entirely (the
|
// scrollback. Desktop clicks therefore stopped reporting entirely (the
|
||||||
// same breakage the mobile touchend tap branch above works around).
|
// same breakage the mobile touchend tap branch above works around).
|
||||||
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
||||||
@@ -1809,6 +1810,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||||
textarea: this.terminal.textarea,
|
textarea: this.terminal.textarea,
|
||||||
emitRecovered: (data) => handleTerminalData(data),
|
emitRecovered: (data) => handleTerminalData(data),
|
||||||
|
getCompositionHelper: () => this.terminal?._core?._compositionHelper,
|
||||||
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -5303,9 +5305,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// follows the same path as a desktop click.
|
// follows the same path as a desktop click.
|
||||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||||
} else if (shouldActivate && this._shouldReportMouseToCli()) {
|
} else if (shouldActivate && this._shouldReportMouseToCli()) {
|
||||||
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
|
// This session's mouse DECSETs are stripped from the browser stream
|
||||||
// report directly to the PTY while retaining local touch scrollback. Only
|
// (strip-full or strip-mux-and-mouse), so report directly to the PTY
|
||||||
// while the CLI actually has tracking on (see _shouldReportMouseToCli).
|
// while retaining local touch scrollback, and only while the CLI
|
||||||
|
// actually has tracking on (see _shouldReportMouseToCli).
|
||||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5369,36 +5372,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mirror of the server's isAltScreenStripMode (session.ts): session modes whose
|
|
||||||
// output stream has mouse-tracking DECSET sequences stripped before reaching the
|
|
||||||
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
|
|
||||||
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
|
|
||||||
/**
|
/**
|
||||||
* True when the browser has to hand-encode a click report for the CLI.
|
* True when the browser has to hand-encode a click report for the CLI: the
|
||||||
|
* server stripped this session's mouse-tracking DECSETs out of the stream (so
|
||||||
|
* xterm's own encoder is permanently idle here and something has to stand in
|
||||||
|
* for it) AND the CLI has a tracking mode on right now.
|
||||||
*
|
*
|
||||||
* Two conditions, and dropping either one is a bug that has already happened:
|
* One flag answers both. The server sets `cliMouseTracking` only as it strips a
|
||||||
|
* tracking DECSET (`_recordStrippedMouseMode` in session.ts, called from the
|
||||||
|
* mouse-strip branch of `_handleTerminalOutput` and nowhere else), so it can
|
||||||
|
* only ever be true for a mode whose DECSETs are stripped: whichever modes the
|
||||||
|
* registry decides to strip, the browser follows, with no mode list here to
|
||||||
|
* keep in step. For a `preserve` / `strip-mux-only` mode the flag stays false
|
||||||
|
* and xterm keeps encoding its own reports. That invariant is pinned server-side
|
||||||
|
* in test/claude-scrollback-strip.test.ts.
|
||||||
*
|
*
|
||||||
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
|
* Without the flag the browser reported EVERY click, so a CLI sitting at its
|
||||||
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
|
* composer with no dialog open, or a pane that has fallen back to a shell
|
||||||
* xterm's own encoder is permanently idle here and something has to stand
|
* prompt, received mouse reports it never asked for. A shell prints those as
|
||||||
* in for it.
|
* literal text (`[<0;88;20M`) and they garble the next line typed.
|
||||||
* 2. The CLI actually has a mouse-tracking mode on right now. The server
|
|
||||||
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
|
|
||||||
* publishes it as `cliMouseTracking`. Without this half the browser
|
|
||||||
* reported EVERY click, so a CLI sitting at its composer with no dialog
|
|
||||||
* open, or a pane that has fallen back to a shell prompt, received mouse
|
|
||||||
* reports it never asked for. A shell prints those as literal text
|
|
||||||
* (`[<0;88;20M`) and they garble the next line typed.
|
|
||||||
*
|
*
|
||||||
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
||||||
* injecting bytes. After a server restart the flag is false until the CLI
|
* injecting bytes. After a server restart the flag is false until the CLI
|
||||||
* re-emits its DECSET, which closing and reopening a dialog does.
|
* re-emits its DECSET, which closing and reopening a dialog does.
|
||||||
*/
|
*/
|
||||||
_shouldReportMouseToCli() {
|
_shouldReportMouseToCli() {
|
||||||
const session = this.sessions?.get(this.activeSessionId);
|
return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true;
|
||||||
const mode = session?.mode || 'claude';
|
|
||||||
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
|
|
||||||
return session?.cliMouseTracking === true;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
||||||
@@ -5583,15 +5582,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this session's LOCAL scrollback is structurally empty: a Claude
|
* True when this session's LOCAL scrollback is structurally empty: a pane whose
|
||||||
* pane in repaint mode, where tmux reports `history_size≈0` and every frame
|
* TUI repaints one full screen in place, so tmux keeps no history for it
|
||||||
* overwrites the last, so xterm's normal buffer never grows past one screen
|
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
|
||||||
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
|
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
|
||||||
* is routed — the "wheel does nothing at all" half of the #205 retest.
|
* is routed — the "wheel does nothing at all" half of the #205 retest.
|
||||||
|
*
|
||||||
|
* Two shapes, measured separately:
|
||||||
|
* - `claude` in repaint mode (the original, #205 round 2), and
|
||||||
|
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
|
||||||
|
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
|
||||||
|
* terminal's scrollback at all. It pages its own transcript with the same
|
||||||
|
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
|
||||||
|
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
|
||||||
|
* byte-identical — so paging is the only gesture that reaches it. Without
|
||||||
|
* this the wheel was silently dead in every opencode tab.
|
||||||
|
*
|
||||||
|
* Every other mode is deliberately absent: shell/pi own real terminal
|
||||||
|
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
|
||||||
|
* is unverified (docs/scrollback-fix-plan.md).
|
||||||
*/
|
*/
|
||||||
_localScrollbackIsHollow() {
|
_localScrollbackIsHollow() {
|
||||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
||||||
if (mode !== 'claude') return false;
|
if (mode !== 'claude' && mode !== 'opencode') return false;
|
||||||
const buf = this.terminal?.buffer?.active;
|
const buf = this.terminal?.buffer?.active;
|
||||||
if (!buf || buf.type === 'alternate') return false;
|
if (!buf || buf.type === 'alternate') return false;
|
||||||
return (buf.baseY || 0) === 0;
|
return (buf.baseY || 0) === 0;
|
||||||
@@ -5602,18 +5615,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
|
* coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript.
|
||||||
*
|
*
|
||||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
||||||
* on a Claude session that has no local history to fall back on: the CLI
|
* on a session that has no local history to fall back on: the CLI version probe
|
||||||
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
|
* failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is
|
||||||
* tracking flag is unset (the inline renderer, or fullscreen right after a
|
* unset (the inline renderer, or fullscreen right after a server restart), the
|
||||||
* server restart), or the user turned on "Wheel scrolls local history" (which
|
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
|
||||||
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
|
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
|
||||||
* setting's footgun). Before this, all of those produced a completely dead
|
* opencode, which never fills the buffer and never accepts the wheel. Before
|
||||||
* gesture; the #205 reporter proved the keyboard route works by paging back
|
* this, all of those produced a completely dead gesture; the #205 reporter
|
||||||
* through intact text with Fn+Up.
|
* proved the keyboard route works by paging back through intact text with Fn+Up.
|
||||||
*
|
*
|
||||||
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
|
||||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
* session with real local scrollback is never touched. Shift is excluded on
|
||||||
* the explicit "give me local scrollback" gesture and must keep that meaning.
|
* purpose: it is the explicit "give me local scrollback" gesture and must keep
|
||||||
|
* that meaning.
|
||||||
*
|
*
|
||||||
* @returns true when the gesture was consumed here (the caller must not also
|
* @returns true when the gesture was consumed here (the caller must not also
|
||||||
* scroll locally).
|
* scroll locally).
|
||||||
@@ -5714,7 +5728,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* The reason is that the habit and xterm's Shift mean different things once
|
* The reason is that the habit and xterm's Shift mean different things once
|
||||||
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
|
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
|
||||||
* the app actually has mouse tracking on; the server strips those DECSETs for
|
* the app actually has mouse tracking on; the server strips those DECSETs for
|
||||||
* claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is
|
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
|
||||||
|
* so xterm's mouseTrackingMode is
|
||||||
* permanently `none`, that branch is unreachable, and Shift instead falls into
|
* permanently `none`, that branch is unreachable, and Shift instead falls into
|
||||||
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
|
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
|
||||||
* when `selectionStart` is null, so the drag never anchors and no selection is
|
* when `selectionStart` is null, so the drag never anchors and no selection is
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en"><head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
||||||
<title>Upload Screenshot - Codeman</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
||||||
background: #0d1117; color: #c9d1d9;
|
|
||||||
min-height: 100vh; min-height: 100dvh;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
.container { max-width: 480px; width: 100%; padding: 24px; }
|
|
||||||
h1 { font-size: 1.4em; margin-bottom: 16px; color: #58a6ff; }
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed #30363d; border-radius: 12px;
|
|
||||||
padding: 48px 20px; text-align: center;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: border-color 0.2s, background 0.2s;
|
|
||||||
}
|
|
||||||
.drop-zone.active { border-color: #58a6ff; background: #161b22; }
|
|
||||||
.drop-zone p { margin-bottom: 12px; font-size: 1.1em; }
|
|
||||||
.drop-zone small { color: #8b949e; }
|
|
||||||
input[type=file] { display: none; }
|
|
||||||
.preview { margin-top: 16px; text-align: center; display: none; }
|
|
||||||
.preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #30363d; }
|
|
||||||
.preview .name { margin-top: 6px; font-size: 0.85em; color: #8b949e; word-break: break-all; }
|
|
||||||
button {
|
|
||||||
width: 100%; padding: 14px; margin-top: 16px;
|
|
||||||
background: #238636; color: #fff; border: none;
|
|
||||||
border-radius: 8px; font-size: 1.05em;
|
|
||||||
font-weight: 600; -webkit-appearance: none;
|
|
||||||
opacity: 0.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
button.ready { opacity: 1; pointer-events: auto; }
|
|
||||||
button:active { background: #2ea043; }
|
|
||||||
.status { margin-top: 12px; padding: 10px; border-radius: 6px; text-align: center; display: none; }
|
|
||||||
.status.ok { display: block; background: #1a3a2a; color: #3fb950; }
|
|
||||||
.status.err { display: block; background: #3a1a1a; color: #f85149; }
|
|
||||||
.files { margin-top: 24px; }
|
|
||||||
.files h2 { font-size: 0.95em; color: #8b949e; margin-bottom: 8px; }
|
|
||||||
.files a { display: block; color: #58a6ff; text-decoration: none; padding: 6px 0; font-size: 0.9em; word-break: break-all; }
|
|
||||||
.files a:active { color: #79c0ff; }
|
|
||||||
.back { display: inline-block; margin-bottom: 12px; color: #8b949e; text-decoration: none; font-size: 0.9em; }
|
|
||||||
.back:active { color: #c9d1d9; }
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="container">
|
|
||||||
<a class="back" href="/">← Back to Codeman</a>
|
|
||||||
<h1>Upload Screenshot</h1>
|
|
||||||
<div class="drop-zone" id="drop">
|
|
||||||
<p>Tap to select image</p>
|
|
||||||
<small>PNG, JPG, WebP — up to 10 MB</small>
|
|
||||||
</div>
|
|
||||||
<input type="file" id="file" accept="image/*">
|
|
||||||
<div class="preview" id="preview">
|
|
||||||
<img id="previewImg" alt="Preview">
|
|
||||||
<div class="name" id="previewName"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn">Upload</button>
|
|
||||||
<div class="status" id="status"></div>
|
|
||||||
<div class="files" id="files"></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var drop = document.getElementById('drop');
|
|
||||||
var fileInput = document.getElementById('file');
|
|
||||||
var preview = document.getElementById('preview');
|
|
||||||
var previewImg = document.getElementById('previewImg');
|
|
||||||
var previewName = document.getElementById('previewName');
|
|
||||||
var btn = document.getElementById('btn');
|
|
||||||
var status = document.getElementById('status');
|
|
||||||
var filesDiv = document.getElementById('files');
|
|
||||||
var selectedFile = null;
|
|
||||||
|
|
||||||
drop.addEventListener('click', function() { fileInput.click(); });
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (fileInput.files && fileInput.files[0]) pick(fileInput.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag-and-drop (desktop fallback)
|
|
||||||
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('active'); });
|
|
||||||
drop.addEventListener('dragleave', function() { drop.classList.remove('active'); });
|
|
||||||
drop.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault(); drop.classList.remove('active');
|
|
||||||
if (e.dataTransfer && e.dataTransfer.files[0]) pick(e.dataTransfer.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function pick(f) {
|
|
||||||
selectedFile = f;
|
|
||||||
previewImg.src = URL.createObjectURL(f);
|
|
||||||
previewName.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
|
|
||||||
preview.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
status.className = 'status';
|
|
||||||
status.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
btn.classList.remove('ready');
|
|
||||||
btn.textContent = 'Uploading\u2026';
|
|
||||||
var form = new FormData();
|
|
||||||
form.append('file', selectedFile);
|
|
||||||
fetch('/api/screenshots', { method: 'POST', body: form })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(j) {
|
|
||||||
if (j.success) {
|
|
||||||
status.className = 'status ok';
|
|
||||||
status.textContent = 'Saved: ' + j.filename;
|
|
||||||
status.style.display = 'block';
|
|
||||||
selectedFile = null;
|
|
||||||
preview.style.display = 'none';
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
loadFiles();
|
|
||||||
} else {
|
|
||||||
status.className = 'status err';
|
|
||||||
status.textContent = j.error || 'Upload failed';
|
|
||||||
status.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
status.className = 'status err';
|
|
||||||
status.textContent = e.message;
|
|
||||||
status.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadFiles() {
|
|
||||||
fetch('/api/screenshots')
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(j) {
|
|
||||||
if (j.files && j.files.length) {
|
|
||||||
var html = '<h2>Recent uploads</h2>';
|
|
||||||
j.files.forEach(function(f) {
|
|
||||||
html += '<a href="/api/screenshots/' + encodeURIComponent(f.name) + '" target="_blank">' + f.name + '</a>';
|
|
||||||
});
|
|
||||||
filesDiv.innerHTML = html;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFiles();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body></html>
|
|
||||||
@@ -514,7 +514,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
openWebviewExternal(id) {
|
openWebviewExternal(id) {
|
||||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
if (!webview) return;
|
||||||
|
// A host that refuses must not fall through to window.open, which in a
|
||||||
|
// WebView can replace the dashboard page.
|
||||||
|
const hosted = this.openInHostWindow?.(webview.url) ?? null;
|
||||||
|
if (hosted !== null) {
|
||||||
|
if (!hosted) this.showToast('Could not open a new window for this dashboard', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(webview.url, '_blank', 'noopener');
|
||||||
},
|
},
|
||||||
|
|
||||||
closeWebviewTab(id) {
|
closeWebviewTab(id) {
|
||||||
|
|||||||
@@ -312,7 +312,9 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void {
|
|||||||
// admin/settings surface; every SPAWN-time caller elsewhere uses
|
// admin/settings surface; every SPAWN-time caller elsewhere uses
|
||||||
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
|
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
|
||||||
// overlays/discovery — the same rule every other catalogue-export surface in
|
// overlays/discovery — the same rule every other catalogue-export surface in
|
||||||
// this codebase follows.
|
// this codebase follows, with one deliberate exception: the page catalog
|
||||||
|
// (`window.__codemanCliCatalog`, server.ts) carries `capabilities.external`,
|
||||||
|
// which Session Options reads to keep Claude's Respawn and Ralph tabs.
|
||||||
//
|
//
|
||||||
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
|
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
|
||||||
// the risky part. The Settings UI section simply never fetches this while
|
// the risky part. The Settings UI section simply never fetches this while
|
||||||
|
|||||||
@@ -73,6 +73,35 @@ import {
|
|||||||
isEditableFileName,
|
isEditableFileName,
|
||||||
} from '../../config/file-editing.js';
|
} from '../../config/file-editing.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
|
||||||
|
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
|
||||||
|
* what a single preview can hand the worker; the renderer refuses the same size
|
||||||
|
* before fetching. An explicit download is unaffected (global download cap).
|
||||||
|
*/
|
||||||
|
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Whether a raw request is an XLSX browser preview over the preview cap. */
|
||||||
|
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
|
||||||
|
return (
|
||||||
|
extension === 'xlsx' &&
|
||||||
|
query.preview === 'true' &&
|
||||||
|
query.download !== 'true' &&
|
||||||
|
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
|
||||||
|
reply
|
||||||
|
.code(413)
|
||||||
|
.send(
|
||||||
|
createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
json: 'application/json',
|
json: 'application/json',
|
||||||
md: 'text/markdown',
|
md: 'text/markdown',
|
||||||
txt: 'text/plain',
|
txt: 'text/plain',
|
||||||
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||||
|
|
||||||
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
||||||
// Return metadata for media/binary files (no text body)
|
// Return metadata for media/binary files (no text body). XLSX is still a
|
||||||
|
// binary here (no text body); `spreadsheet` tells the overlay it can parse
|
||||||
|
// it client-side from `url`. xls/ods stay plain binary (download only).
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
type: mediaType ?? 'binary',
|
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
|
||||||
extension: ext,
|
extension: ext,
|
||||||
url: fileRawUrl,
|
url: fileRawUrl,
|
||||||
},
|
},
|
||||||
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
// Serve raw file content (for images/binary files)
|
// Serve raw file content (for images/binary files)
|
||||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
|
||||||
|
sendXlsxPreviewTooLarge(reply, size);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mimeTypes: Record<string, string> = {
|
const mimeTypes: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
@@ -2217,7 +2253,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||||
const { download } = req.query as { download?: string };
|
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
@@ -2237,6 +2273,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||||
|
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||||
|
sendXlsxPreviewTooLarge(reply, size);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await serveRawFile(
|
await serveRawFile(
|
||||||
reply,
|
reply,
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
emptyOverview,
|
emptyOverview,
|
||||||
findWorkspaceRepo,
|
findWorkspaceRepo,
|
||||||
getGitFileDiff,
|
getGitFileDiff,
|
||||||
|
resolveOverviewLimits,
|
||||||
getGitWorkspaceOverview,
|
getGitWorkspaceOverview,
|
||||||
getGitWorkspaceStatus,
|
getGitWorkspaceStatus,
|
||||||
type GitFileDiff,
|
type GitFileDiff,
|
||||||
@@ -33,6 +34,20 @@ import type { SessionPort } from '../ports/index.js';
|
|||||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
|
||||||
|
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
|
||||||
|
* can never cost more than the module's own ceiling.
|
||||||
|
*/
|
||||||
|
function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
|
||||||
|
// A repeated key arrives as an array: it is not a number, so it means "default" like '' and absent.
|
||||||
|
const timeout = typeof query.timeout === 'string' && query.timeout.trim() ? query.timeout : undefined;
|
||||||
|
return resolveOverviewLimits({
|
||||||
|
maxRepos: query.maxRepos,
|
||||||
|
timeoutMs: timeout !== undefined ? Number(timeout) * 1000 : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerGitStatusRoutes(
|
export function registerGitStatusRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort,
|
ctx: SessionPort,
|
||||||
@@ -41,7 +56,9 @@ export function registerGitStatusRoutes(
|
|||||||
): void {
|
): void {
|
||||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { fresh } = req.query as { fresh?: string };
|
const query = req.query as { fresh?: string; maxRepos?: unknown; timeout?: unknown };
|
||||||
|
const { fresh } = query;
|
||||||
|
const limits = limitsFrom(query);
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||||
@@ -50,6 +67,7 @@ export function registerGitStatusRoutes(
|
|||||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||||
git,
|
git,
|
||||||
fresh: fresh === '1',
|
fresh: fresh === '1',
|
||||||
|
...limits,
|
||||||
dockerWorkspaces: await dockerWorkspaces(),
|
dockerWorkspaces: await dockerWorkspaces(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -62,16 +80,24 @@ export function registerGitStatusRoutes(
|
|||||||
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
// repository's status is refreshed: a click must not re-read every repository in the folder.
|
||||||
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
|
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: unknown; timeout?: unknown };
|
||||||
|
const { repo, path, kind } = query;
|
||||||
|
const limits = limitsFrom(query);
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
if (session.remote || session.docker) {
|
if (session.remote || session.docker) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||||
}
|
}
|
||||||
const repoRoot = repo
|
const repoRoot = repo
|
||||||
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
|
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||||
|
git,
|
||||||
|
...limits,
|
||||||
|
dockerWorkspaces: await dockerWorkspaces(),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const status = repoRoot
|
||||||
|
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
|
||||||
: null;
|
: null;
|
||||||
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
|
|
||||||
const entry =
|
const entry =
|
||||||
status?.state === 'ok'
|
status?.state === 'ok'
|
||||||
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
||||||
@@ -81,7 +107,10 @@ export function registerGitStatusRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reply.code(500);
|
reply.code(500);
|
||||||
return createErrorResponse(
|
return createErrorResponse(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
isAltScreenStripMode,
|
isAltScreenStripMode,
|
||||||
isExternalCliMode,
|
isExternalCliMode,
|
||||||
isMuxAltScreenOnlyStripMode,
|
isMuxAltScreenOnlyStripMode,
|
||||||
|
isMuxMouseStripMode,
|
||||||
} from '../../session.js';
|
} from '../../session.js';
|
||||||
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
@@ -115,6 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
|||||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||||
|
import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js';
|
||||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
@@ -224,6 +226,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
|
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts)
|
||||||
|
* removes from the live stream, removed again from a stored buffer before it is replayed,
|
||||||
|
* because a buffer recorded before the live-side strip existed (or by an older server)
|
||||||
|
* still carries the sequences, and one replayed enable is enough to re-park xterm.
|
||||||
|
*
|
||||||
|
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
|
||||||
|
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
|
||||||
|
* saved lines, so history disappeared on tab switch.
|
||||||
|
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
|
||||||
|
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
|
||||||
|
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
|
||||||
|
* `3J` stays: a TUI is not a `clear` consumer.
|
||||||
|
* - every other mode, tmux-backed: tmux's own client smcup only (#205).
|
||||||
|
*
|
||||||
|
* Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts).
|
||||||
|
*/
|
||||||
|
export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string {
|
||||||
|
if (isAltScreenStripMode(mode)) {
|
||||||
|
return buffer
|
||||||
|
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||||||
|
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||||||
|
.replace(MOUSE_TRACKING_PATTERN, '');
|
||||||
|
}
|
||||||
|
if (isMuxMouseStripMode(mode, usesMux)) {
|
||||||
|
return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, '');
|
||||||
|
}
|
||||||
|
if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
||||||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
||||||
@@ -1117,6 +1150,8 @@ export function registerSessionRoutes(
|
|||||||
const globalNice = await ctx.getGlobalNiceConfig();
|
const globalNice = await ctx.getGlobalNiceConfig();
|
||||||
const modelConfig = await ctx.getModelConfig();
|
const modelConfig = await ctx.getModelConfig();
|
||||||
const mode = body.mode || 'claude';
|
const mode = body.mode || 'claude';
|
||||||
|
const launchCodexConfig =
|
||||||
|
mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig;
|
||||||
// Where a model override comes from is a capability, and the three answers are
|
// Where a model override comes from is a capability, and the three answers are
|
||||||
// genuinely different mechanisms:
|
// genuinely different mechanisms:
|
||||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||||
@@ -1131,9 +1166,10 @@ export function registerSessionRoutes(
|
|||||||
const modelSource = getCli(mode)?.capabilities.model;
|
const modelSource = getCli(mode)?.capabilities.model;
|
||||||
const model =
|
const model =
|
||||||
modelSource?.source === 'flag'
|
modelSource?.source === 'flag'
|
||||||
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
|
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record<
|
||||||
| string
|
string,
|
||||||
| undefined)
|
unknown
|
||||||
|
>)?.[modelSource.param ?? 'model'] as string | undefined)
|
||||||
: modelSource?.source === 'claude-settings-file'
|
: modelSource?.source === 'claude-settings-file'
|
||||||
? body.model || modelConfig?.defaultModel || undefined
|
? body.model || modelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1150,7 +1186,7 @@ export function registerSessionRoutes(
|
|||||||
deepSeekConfig: gatedDeepSeekConfig,
|
deepSeekConfig: gatedDeepSeekConfig,
|
||||||
} = await _clampExternalCliBypassForOwner(
|
} = await _clampExternalCliBypassForOwner(
|
||||||
owner,
|
owner,
|
||||||
body.codexConfig,
|
launchCodexConfig,
|
||||||
body.geminiConfig,
|
body.geminiConfig,
|
||||||
body.antigravityConfig,
|
body.antigravityConfig,
|
||||||
body.piConfig,
|
body.piConfig,
|
||||||
@@ -3107,21 +3143,9 @@ export function registerSessionRoutes(
|
|||||||
? rawBuffer
|
? rawBuffer
|
||||||
: stripInkRedrawBloat(rawBuffer);
|
: stripInkRedrawBloat(rawBuffer);
|
||||||
|
|
||||||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
// Same strip as the live stream (session.ts), so a buffer recorded before the
|
||||||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
// live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer.
|
||||||
// buffer and wiping saved lines, so conversation history disappears on tab
|
strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux);
|
||||||
// switch. Same gate as the live-stream strip in session.ts.
|
|
||||||
if (isAltScreenStripMode(session.mode)) {
|
|
||||||
strippedBuffer = strippedBuffer
|
|
||||||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
|
||||||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
|
||||||
.replace(MOUSE_TRACKING_PATTERN, '');
|
|
||||||
} else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) {
|
|
||||||
// tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only.
|
|
||||||
// A byte buffer recorded before the live-side strip existed can still carry
|
|
||||||
// it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205).
|
|
||||||
strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||||||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||||||
@@ -3851,13 +3875,17 @@ export function registerSessionRoutes(
|
|||||||
// Apply global Nice priority config and model config from settings
|
// Apply global Nice priority config and model config from settings
|
||||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||||
const qsModelConfig = await ctx.getModelConfig();
|
const qsModelConfig = await ctx.getModelConfig();
|
||||||
|
const qsLaunchCodexConfig =
|
||||||
|
mode === 'codex' && !remote && !docker
|
||||||
|
? await resolveCodexLaunchDefaults(codexConfig, !!customModel)
|
||||||
|
: codexConfig;
|
||||||
// See the create path for why this is a capability rather than a mode ladder.
|
// See the create path for why this is a capability rather than a mode ladder.
|
||||||
const qsModelSource = getCli(mode)?.capabilities.model;
|
const qsModelSource = getCli(mode)?.capabilities.model;
|
||||||
const qsModel =
|
const qsModel =
|
||||||
qsModelSource?.source === 'flag'
|
qsModelSource?.source === 'flag'
|
||||||
? (legacyConfigForMode(mode, {
|
? (legacyConfigForMode(mode, {
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
codexConfig,
|
codexConfig: qsLaunchCodexConfig,
|
||||||
geminiConfig,
|
geminiConfig,
|
||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
@@ -3879,7 +3907,7 @@ export function registerSessionRoutes(
|
|||||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||||
} = await _clampExternalCliBypassForOwner(
|
} = await _clampExternalCliBypassForOwner(
|
||||||
owner,
|
owner,
|
||||||
codexConfig,
|
qsLaunchCodexConfig,
|
||||||
geminiConfig,
|
geminiConfig,
|
||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
|
|||||||
@@ -1295,8 +1295,20 @@ export function registerSystemRoutes(
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ========== Screenshots ==========
|
// ========== Screenshots ==========
|
||||||
|
// Deprecated (the upload page is gone): removed in a later MAJOR per
|
||||||
|
// docs/versioning-policy.md. Warns once per process on first use.
|
||||||
|
let screenshotsDeprecationWarned = false;
|
||||||
|
const warnScreenshotsDeprecated = (): void => {
|
||||||
|
if (screenshotsDeprecationWarned) return;
|
||||||
|
screenshotsDeprecationWarned = true;
|
||||||
|
console.warn(
|
||||||
|
'[deprecated] /api/screenshots is deprecated and will be removed in a future major release. ' +
|
||||||
|
'Use POST /api/sessions/:id/paste-image to hand a file to a session.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
app.post('/api/screenshots', async (req, reply) => {
|
app.post('/api/screenshots', async (req, reply) => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
const contentType = req.headers['content-type'] ?? '';
|
const contentType = req.headers['content-type'] ?? '';
|
||||||
if (!contentType.includes('multipart/form-data')) {
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||||
@@ -1383,6 +1395,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/screenshots', async () => {
|
app.get('/api/screenshots', async () => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||||
return { files: [] };
|
return { files: [] };
|
||||||
}
|
}
|
||||||
@@ -1396,6 +1409,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/screenshots/:name', async (req, reply) => {
|
app.get('/api/screenshots/:name', async (req, reply) => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
// Prevent path traversal
|
// Prevent path traversal
|
||||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||||
|
|||||||
@@ -1484,6 +1484,12 @@ export const SettingsUpdateSchema = z
|
|||||||
claudeMode: z.string().max(50).optional(),
|
claudeMode: z.string().max(50).optional(),
|
||||||
allowedTools: z.string().max(2000).optional(),
|
allowedTools: z.string().max(2000).optional(),
|
||||||
// Codex CLI settings
|
// Codex CLI settings
|
||||||
|
codexModel: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9._\-/]*$/)
|
||||||
|
.optional(),
|
||||||
|
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
|
||||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||||
codexAnimationsEnabled: z.boolean().optional(),
|
codexAnimationsEnabled: z.boolean().optional(),
|
||||||
// Terminal history and retention
|
// Terminal history and retention
|
||||||
|
|||||||
+28
-10
@@ -323,6 +323,14 @@ export class WebServer extends EventEmitter {
|
|||||||
private store = getStore();
|
private store = getStore();
|
||||||
private tabLayouts!: TabLayoutService;
|
private tabLayouts!: TabLayoutService;
|
||||||
private port: number;
|
private port: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port the server is actually listening on once `start()` has resolved — the
|
||||||
|
* OS-assigned one for `new WebServer(0, …)` — and the constructor's port before that.
|
||||||
|
*/
|
||||||
|
get boundPort(): number {
|
||||||
|
return this.port;
|
||||||
|
}
|
||||||
private host: string;
|
private host: string;
|
||||||
private https: boolean;
|
private https: boolean;
|
||||||
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
|
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
|
||||||
@@ -746,7 +754,11 @@ export class WebServer extends EventEmitter {
|
|||||||
saveRespawnConfig: this.saveRespawnConfig.bind(this),
|
saveRespawnConfig: this.saveRespawnConfig.bind(this),
|
||||||
// ConfigPort
|
// ConfigPort
|
||||||
store: this.store,
|
store: this.store,
|
||||||
port: this.port,
|
// A getter, not a snapshot: this context is built in setupRoutes(), BEFORE
|
||||||
|
// listen() resolves an ephemeral `port: 0`, and the tunnel start reads it later.
|
||||||
|
get port() {
|
||||||
|
return self.port;
|
||||||
|
},
|
||||||
https: this.https,
|
https: this.https,
|
||||||
testMode: this.testMode,
|
testMode: this.testMode,
|
||||||
serverStartTime: this.serverStartTime,
|
serverStartTime: this.serverStartTime,
|
||||||
@@ -920,7 +932,9 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
||||||
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys, and every
|
||||||
|
// HTML page has its own route that says so (/, /index.html, /session/:id). ⚠️ A new static .html
|
||||||
|
// needs such a route too: this plugin would hand it a year of `immutable`.
|
||||||
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
|
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
|
||||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||||
await this.app.register(fastifyStatic, {
|
await this.app.register(fastifyStatic, {
|
||||||
@@ -932,7 +946,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
||||||
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
||||||
// request, which is every page load. See the v10.0.0 release notes.
|
// request, which is every page load. See the v10.0.0 release notes.
|
||||||
setHeaders: (reply, path) => {
|
setHeaders: (reply) => {
|
||||||
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
||||||
// callback wrote to the raw response and Fastify's staged reply headers then
|
// callback wrote to the raw response and Fastify's staged reply headers then
|
||||||
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
||||||
@@ -941,12 +955,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// no-store` its route asks for — a service worker that can never update.
|
// no-store` its route asks for — a service worker that can never update.
|
||||||
// So: a route that already decided keeps its answer.
|
// So: a route that already decided keeps its answer.
|
||||||
if (reply.getHeader('Cache-Control') !== undefined) return;
|
if (reply.getHeader('Cache-Control') !== undefined) return;
|
||||||
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
if (path.includes('.html')) {
|
|
||||||
reply.header('Cache-Control', 'no-cache');
|
|
||||||
} else {
|
|
||||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1154,7 +1163,9 @@ export class WebServer extends EventEmitter {
|
|||||||
// due times for any persisted jobs, then expose it to its routes.
|
// due times for any persisted jobs, then expose it to its routes.
|
||||||
this.cronService = new CronService(ctx);
|
this.cronService = new CronService(ctx);
|
||||||
this.cronService.init();
|
this.cronService.init();
|
||||||
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
|
// Only what CronPort declares: a spread of ctx would copy `port` by value (the
|
||||||
|
// pre-listen 0 of an ephemeral bind) into an object nothing keeps in sync.
|
||||||
|
registerCronRoutes(this.app, { cron: this.cronService });
|
||||||
|
|
||||||
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||||
registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy());
|
registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy());
|
||||||
@@ -1713,6 +1724,7 @@ export class WebServer extends EventEmitter {
|
|||||||
shortBadge: entry.shortBadge,
|
shortBadge: entry.shortBadge,
|
||||||
order: entry.order,
|
order: entry.order,
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
|
external: entry.capabilities.external,
|
||||||
enabled,
|
enabled,
|
||||||
available: enabled && installed,
|
available: enabled && installed,
|
||||||
};
|
};
|
||||||
@@ -2909,6 +2921,12 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.app.listen({ port: this.port, host: this.host });
|
await this.app.listen({ port: this.port, host: this.host });
|
||||||
|
// A `port: 0` bind gets its number from the OS. Everything below and every later
|
||||||
|
// reader (the banner, CODEMAN_API_URL, the docker bridge listener, the
|
||||||
|
// unauthenticated-bind warnings, the route context's getter) must see that number,
|
||||||
|
// not the 0 that was asked for.
|
||||||
|
const address = this.app.server.address();
|
||||||
|
if (address !== null && typeof address === 'object') this.port = address.port;
|
||||||
const protocol = this.https ? 'https' : 'http';
|
const protocol = this.https ? 'https' : 'http';
|
||||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||||
// The only startup banner: `codeman web` used to print its own copy of this
|
// The only startup banner: `codeman web` used to print its own copy of this
|
||||||
|
|||||||
+13
-18
@@ -8,7 +8,7 @@
|
|||||||
* 6. Logout endpoint invalidates session
|
* 6. Logout endpoint invalidates session
|
||||||
* 7. Settings schema rejects unknown fields
|
* 7. Settings schema rejects unknown fields
|
||||||
*
|
*
|
||||||
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
|
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -16,11 +16,6 @@ import { TmuxManager } from '../src/tmux-manager.js';
|
|||||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||||
|
|
||||||
const AUTH_PORT = 3160;
|
|
||||||
const NOAUTH_PORT = 3161;
|
|
||||||
const NETWORK_OVERRIDE_PORT = 3162;
|
|
||||||
const AUTH_RATE_LIMIT_PORT = 3220;
|
|
||||||
const NOAUTH_NETWORK_PORT = 3221;
|
|
||||||
const TEST_USER = 'admin';
|
const TEST_USER = 'admin';
|
||||||
const TEST_PASS = 'test-password-12345';
|
const TEST_PASS = 'test-password-12345';
|
||||||
|
|
||||||
@@ -30,12 +25,12 @@ function basicAuthHeader(user: string, pass: string): string {
|
|||||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
|
async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> {
|
||||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
const server = new WebServer(port, false, true);
|
const server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
return { server, baseUrl: `http://localhost:${port}` };
|
return { server, baseUrl: `http://localhost:${server.boundPort}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSessionCookie(baseUrl: string): Promise<string> {
|
async function getSessionCookie(baseUrl: string): Promise<string> {
|
||||||
@@ -66,9 +61,9 @@ describe('Auth Security', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
server = new WebServer(AUTH_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${AUTH_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -194,7 +189,7 @@ describe('Auth Security', () => {
|
|||||||
let rateBaseUrl: string;
|
let rateBaseUrl: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
|
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -347,7 +342,7 @@ describe('No-Auth Server Startup Policy', () => {
|
|||||||
delete process.env.CODEMAN_PASSWORD;
|
delete process.env.CODEMAN_PASSWORD;
|
||||||
delete process.env.CODEMAN_USERNAME;
|
delete process.env.CODEMAN_USERNAME;
|
||||||
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
||||||
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
|
server = new WebServer(0, false, true, '127.0.0.1');
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,7 +354,7 @@ describe('No-Auth Server Startup Policy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows loopback requests without auth when no password is configured', async () => {
|
it('allows loopback requests without auth when no password is configured', async () => {
|
||||||
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
|
const res = await fetch(`http://localhost:${server.boundPort}/api/status`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,10 +363,10 @@ describe('No-Auth Server Startup Policy', () => {
|
|||||||
// bind without a password no longer refuses to start — it starts and warns,
|
// bind without a password no longer refuses to start — it starts and warns,
|
||||||
// pointing at how to secure it. See docs/security-architecture.md.
|
// pointing at how to secure it. See docs/security-architecture.md.
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
|
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
||||||
|
|
||||||
await expect(networkServer.start()).resolves.toBeUndefined();
|
await expect(networkServer.start()).resolves.toBeUndefined();
|
||||||
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
|
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const warned = warnSpy.mock.calls.flat().join('\n');
|
const warned = warnSpy.mock.calls.flat().join('\n');
|
||||||
@@ -393,10 +388,10 @@ describe('No-Auth Server Startup Policy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
|
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
|
||||||
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
|
const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true);
|
||||||
|
|
||||||
await networkServer.start();
|
await networkServer.start();
|
||||||
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
|
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
await networkServer.stop();
|
await networkServer.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js';
|
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
import { stripReplayBuffer } from '../src/web/routes/session-routes.js';
|
||||||
|
|
||||||
type SessionInternals = {
|
type SessionInternals = {
|
||||||
_handleTerminalOutput(data: string): void;
|
_handleTerminalOutput(data: string): void;
|
||||||
@@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n
|
|||||||
|
|
||||||
describe('isMuxAltScreenOnlyStripMode', () => {
|
describe('isMuxAltScreenOnlyStripMode', () => {
|
||||||
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
||||||
for (const mode of ['shell', 'opencode', 'antigravity'] as const) {
|
for (const mode of ['shell', 'antigravity'] as const) {
|
||||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
|
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
|
||||||
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
|
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
|
||||||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||||||
}
|
}
|
||||||
// The full strip already owns these; never double-gate them here.
|
// The full strip and the mouse strip already own their modes; never double-gate.
|
||||||
for (const mode of ['claude', 'codex', 'gemini'] as const) {
|
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
|
||||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs,
|
||||||
|
* tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then
|
||||||
|
* reports DRAGS to the TUI instead of selecting locally. That killed "mark text,
|
||||||
|
* copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's
|
||||||
|
* `app_exit`, so the failure also ended sessions.
|
||||||
|
*
|
||||||
|
* It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a
|
||||||
|
* TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground
|
||||||
|
* between the full strip and the narrow one.
|
||||||
|
*/
|
||||||
|
describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => {
|
||||||
|
it('is a mouse-strip mode under tmux, and only there', () => {
|
||||||
|
expect(isMuxMouseStripMode('opencode', true)).toBe(true);
|
||||||
|
// Direct-PTY fallback: the pane's own alt screen really does reach xterm.
|
||||||
|
expect(isMuxMouseStripMode('opencode', false)).toBe(false);
|
||||||
|
for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) {
|
||||||
|
expect(isMuxMouseStripMode(mode, true)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops mouse tracking so xterm keeps local text selection', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||||
|
const emitted: string[] = [];
|
||||||
|
session.on('terminal', (data) => emitted.push(data));
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l');
|
||||||
|
|
||||||
|
expect(emitted[0]).toBe('TUI');
|
||||||
|
expect(session.terminalBuffer).toBe('TUI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still drops tmux’s attach-time smcup', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('KEEPS 3J, unlike the full strip', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[3Jtext');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[3Jtext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes cliMouseTracking so the browser can hand-encode clicks', () => {
|
||||||
|
// xterm can never see the DECSETs once they are stripped, so the click path
|
||||||
|
// (_sendSyntheticSgrTap) is the only way a click still reaches opencode.
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1003h\x1b[?1006h');
|
||||||
|
|
||||||
|
expect(session.toState().cliMouseTracking).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassembles a mouse DECSET split across PTY chunks', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||||
|
|
||||||
|
handleOutput(session, 'before\x1b[?100');
|
||||||
|
handleOutput(session, '3h after');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('before after');
|
||||||
|
expect(session.toState().cliMouseTracking).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a direct-PTY opencode pane untouched', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false });
|
||||||
|
const out = '\x1b[?1049h\x1b[?1003h';
|
||||||
|
|
||||||
|
handleOutput(session, out);
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe(out);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
|
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
|
||||||
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
|
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||||
@@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
|||||||
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => {
|
||||||
|
// The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode
|
||||||
|
// list: a flag set for a non-stripping mode would make it hand-encode a second
|
||||||
|
// report on top of xterm's own. Only the mouse-strip branch may set it.
|
||||||
|
for (const mode of ['shell', 'antigravity'] as const) {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||||||
|
handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app');
|
||||||
|
expect(session.toState().cliMouseTracking, mode).toBeFalsy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
||||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||||
const emitted: string[] = [];
|
const emitted: string[] = [];
|
||||||
@@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
|||||||
expect(emitted).toEqual(['before', ' after']);
|
expect(emitted).toEqual(['before', ' after']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies to opencode and antigravity too', () => {
|
it('applies to antigravity too (opencode has its own strip — see below)', () => {
|
||||||
for (const mode of ['opencode', 'antigravity'] as const) {
|
const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true });
|
||||||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
||||||
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
||||||
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => {
|
|||||||
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI
|
||||||
|
* with and without tmux, on both halves of the parity triangle that can drift apart: the
|
||||||
|
* live stream (`_handleTerminalOutput`) and the replay of a stored buffer
|
||||||
|
* (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published
|
||||||
|
* `cliMouseTracking`, so the last column is what keeps it right.
|
||||||
|
*/
|
||||||
|
describe('strip decision table: live stream = replay, for every stock CLI', () => {
|
||||||
|
const ALT = '\x1b[?1049h';
|
||||||
|
const ERASE = '\x1b[3J';
|
||||||
|
const MOUSE = '\x1b[?1002h\x1b[?1006h';
|
||||||
|
const input = `A${ALT}B${ERASE}C${MOUSE}D`;
|
||||||
|
|
||||||
|
/** Read straight off the table, not off the predicates under test. */
|
||||||
|
function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } {
|
||||||
|
const strip = { alt: false, erase: false, mouse: false };
|
||||||
|
if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true });
|
||||||
|
else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true });
|
||||||
|
else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row
|
||||||
|
return {
|
||||||
|
out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`,
|
||||||
|
tracking: strip.mouse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of STOCK_CLIS) {
|
||||||
|
for (const useMux of [true, false]) {
|
||||||
|
it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => {
|
||||||
|
const want = expected(entry.capabilities.altScreen, useMux);
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux });
|
||||||
|
handleOutput(session, input);
|
||||||
|
expect(session.terminalBuffer).toBe(want.out);
|
||||||
|
expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out);
|
||||||
|
expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* - tunnel NOT running + good secret → not 401 (allowed)
|
* - tunnel NOT running + good secret → not 401 (allowed)
|
||||||
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
||||||
*
|
*
|
||||||
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
|
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -28,9 +28,6 @@ import { TunnelManager } from '../src/tunnel-manager.js';
|
|||||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||||
|
|
||||||
const TUNNEL_UP_PORT = 3230;
|
|
||||||
const TUNNEL_DOWN_PORT = 3231;
|
|
||||||
const RATE_LIMIT_PORT = 3232;
|
|
||||||
const TEST_USER = 'admin';
|
const TEST_USER = 'admin';
|
||||||
const TEST_PASS = 'cod54-test-password';
|
const TEST_PASS = 'cod54-test-password';
|
||||||
|
|
||||||
@@ -58,9 +55,9 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
|
|||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
// Force the middleware's tunnel check to report "running".
|
// Force the middleware's tunnel check to report "running".
|
||||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
||||||
server = new WebServer(TUNNEL_UP_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -97,9 +94,9 @@ describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () =
|
|||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
// Tunnel NOT running — loopback-only normal prod case.
|
// Tunnel NOT running — loopback-only normal prod case.
|
||||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
|
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
|
||||||
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -130,9 +127,9 @@ describe('COD-54 hook-event auth — rate limiting', () => {
|
|||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
|
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
|
||||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
||||||
server = new WebServer(RATE_LIMIT_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -46,10 +46,17 @@ function lockedVersions(lock: PackageLock, packageName: string): string[] {
|
|||||||
return [...versions].sort();
|
return [...versions].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void {
|
function expectEveryLockedVersionAtLeast(
|
||||||
|
lock: PackageLock,
|
||||||
|
packageName: string,
|
||||||
|
minimum: string,
|
||||||
|
/** Exact versions deliberately outside this policy; each call site says why. */
|
||||||
|
exempt: string[] = []
|
||||||
|
): void {
|
||||||
const versions = lockedVersions(lock, packageName);
|
const versions = lockedVersions(lock, packageName);
|
||||||
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
|
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
|
if (exempt.includes(version)) continue;
|
||||||
expect(
|
expect(
|
||||||
compareVersions(version, minimum),
|
compareVersions(version, minimum),
|
||||||
`${packageName}@${version} should be >= ${minimum}`
|
`${packageName}@${version} should be >= ${minimum}`
|
||||||
@@ -136,7 +143,11 @@ describe('dependency security policy', () => {
|
|||||||
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
|
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
|
||||||
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
|
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
|
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
|
// Our own uuid stays >= 14. exceljs@4.4.0 (devDependency, vendored into the
|
||||||
|
// XLSX preview worker at build time) pins uuid@8.3.2 and only calls v4();
|
||||||
|
// GHSA-w5hq-g745-h8pq is MODERATE and covers v3/v5/v6 with a caller buffer,
|
||||||
|
// so it is outside this CRITICAL/HIGH policy and unreachable from exceljs.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0', ['8.3.2']);
|
||||||
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
|
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
|
||||||
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
|
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
|
||||||
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
|
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
|
||||||
@@ -149,6 +160,10 @@ describe('dependency security policy', () => {
|
|||||||
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
||||||
|
// GHSA-px8p-9vwx-vf98 (unbounded loop on a ZIP64 marker in a local header)
|
||||||
|
// covers <=0.8.2. The XLSX preview worker streams untrusted files through
|
||||||
|
// fflate's Unzip before any admission callback runs.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3');
|
||||||
expectNoVulnerableBraceExpansion(lock);
|
expectNoVulnerableBraceExpansion(lock);
|
||||||
expectNoVulnerableVite(lock);
|
expectNoVulnerableVite(lock);
|
||||||
expectNoVulnerablePicomatch(lock);
|
expectNoVulnerablePicomatch(lock);
|
||||||
|
|||||||
@@ -128,6 +128,30 @@ describe('file viewer detach button', () => {
|
|||||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error');
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hands the URL to a host window opener instead of window.open', () => {
|
||||||
|
const { app, overlay, windowStub } = loadApp();
|
||||||
|
app.openInHostWindow = vi.fn().mockReturnValue(true);
|
||||||
|
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
|
||||||
|
|
||||||
|
app.detachFilePreview();
|
||||||
|
|
||||||
|
expect(app.openInHostWindow).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf');
|
||||||
|
expect(windowStub.open).not.toHaveBeenCalled();
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the overlay and toasts when the host could not open a window', () => {
|
||||||
|
const { app, overlay, windowStub } = loadApp();
|
||||||
|
app.openInHostWindow = vi.fn().mockReturnValue(false);
|
||||||
|
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
|
||||||
|
|
||||||
|
app.detachFilePreview();
|
||||||
|
|
||||||
|
expect(windowStub.open).not.toHaveBeenCalled();
|
||||||
|
expect(overlay.classList.contains('visible')).toBe(true);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing when no preview is armed', () => {
|
it('does nothing when no preview is armed', () => {
|
||||||
const { app, windowStub } = loadApp();
|
const { app, windowStub } = loadApp();
|
||||||
app.filePreviewDetachUrl = '';
|
app.filePreviewDetachUrl = '';
|
||||||
|
|||||||
@@ -398,6 +398,123 @@ describe('Git status indicator in a real browser', () => {
|
|||||||
await page.waitForSelector('#gitStatusPanel.visible');
|
await page.waitForSelector('#gitStatusPanel.visible');
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
it('sends the max-repositories and git-timeout settings, and lists an unreadable repository with its reason', async () => {
|
||||||
|
const setLimits = async (maxRepos: string, timeout: string) => {
|
||||||
|
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||||
|
await page.fill('#appSettingsGitStatusMaxRepos', maxRepos);
|
||||||
|
await page.fill('#appSettingsGitStatusTimeout', timeout);
|
||||||
|
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||||
|
};
|
||||||
|
await setLimits('7', '45');
|
||||||
|
// Per-device keys must never reach the strict PUT /api/settings.
|
||||||
|
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
|
||||||
|
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 7, timeout: 45 });
|
||||||
|
await refresh();
|
||||||
|
expect(gitStatusRequests.at(-1)).toMatch(/maxRepos=7/);
|
||||||
|
expect(gitStatusRequests.at(-1)).toMatch(/timeout=45/);
|
||||||
|
|
||||||
|
// Out-of-range values are clamped when saved, not sent as typed.
|
||||||
|
await setLimits('9999', '1');
|
||||||
|
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 50, timeout: 5 });
|
||||||
|
|
||||||
|
// A folder with more repositories than the limit, one of which git could not read.
|
||||||
|
const emptyCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
|
||||||
|
const status = (over: Record<string, unknown>) => ({
|
||||||
|
state: 'ok',
|
||||||
|
branch: 'main',
|
||||||
|
detached: false,
|
||||||
|
upstream: 'origin/main',
|
||||||
|
upstreamGone: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
hasRemote: true,
|
||||||
|
counts: emptyCounts,
|
||||||
|
files: [],
|
||||||
|
filesTruncated: false,
|
||||||
|
unpushedCount: 0,
|
||||||
|
unpushed: [],
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
await page.route('**/api/sessions/*/git-status*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
state: 'ok',
|
||||||
|
reposTruncated: true,
|
||||||
|
repoLimit: 2,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
repos: [
|
||||||
|
{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) },
|
||||||
|
{ name: 'slow', path: 'slow', status: status({ state: 'error', error: 'git timed out' }) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
/could not read/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
|
||||||
|
);
|
||||||
|
const body = (await page.textContent('#gitStatusBody')) ?? '';
|
||||||
|
expect(body).toContain('slow');
|
||||||
|
expect(body).toContain('could not read: git timed out');
|
||||||
|
expect(body).toContain('Showing the first 2 of more than 2 repositories');
|
||||||
|
expect(body).toContain('Raise “Git status: max repositories”');
|
||||||
|
// The unreadable repository must keep the indicator from claiming everything is fine.
|
||||||
|
expect(await label()).toContain('? 1');
|
||||||
|
expect(await label()).not.toContain('✓');
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/);
|
||||||
|
|
||||||
|
const mockOverview = async (data: Record<string, unknown>) => {
|
||||||
|
await page.unroute('**/api/sessions/*/git-status*');
|
||||||
|
await page.route('**/api/sessions/*/git-status*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ success: true, data: { state: 'ok', checkedAt: Date.now(), ...data } }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
// The ONLY repository git could not read: still the error row, never a clean, empty repository.
|
||||||
|
await mockOverview({
|
||||||
|
reposTruncated: false,
|
||||||
|
repoLimit: 12,
|
||||||
|
repos: [
|
||||||
|
{
|
||||||
|
name: 'slow',
|
||||||
|
path: 'slow',
|
||||||
|
status: status({ state: 'error', error: 'git timed out', branch: null, hasRemote: false, upstream: null }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => document.getElementById('gitStatusBranch')?.textContent === '1 repository');
|
||||||
|
const lone = (await page.textContent('#gitStatusBody')) ?? '';
|
||||||
|
expect(lone).toContain('could not read: git timed out');
|
||||||
|
expect(lone).not.toContain('Nothing uncommitted');
|
||||||
|
expect(await label()).toContain('? 1');
|
||||||
|
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(slow\)/);
|
||||||
|
|
||||||
|
// A limit of 1 in a folder of several: the one row shown keeps the "Showing the first" notice.
|
||||||
|
await mockOverview({
|
||||||
|
reposTruncated: true,
|
||||||
|
repoLimit: 1,
|
||||||
|
repos: [{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) }],
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
/Showing the first 1 /.test(document.getElementById('gitStatusBody')?.textContent ?? '')
|
||||||
|
);
|
||||||
|
expect(await page.textContent('#gitStatusBranch')).toBe('1 repository');
|
||||||
|
|
||||||
|
await page.unroute('**/api/sessions/*/git-status*');
|
||||||
|
await setLimits('12', '30');
|
||||||
|
await refresh();
|
||||||
|
}, 40000);
|
||||||
|
|
||||||
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
|
||||||
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
await page.click('.git-status-actions button[aria-label="Close git status"]');
|
||||||
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
|
||||||
|
|||||||
@@ -896,3 +896,99 @@ describe('Docker case workspaces are never inspected', () => {
|
|||||||
expect(o.state).toBe('ok');
|
expect(o.state).toBe('ok');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
clampInt,
|
||||||
|
DEFAULT_GIT_TIMEOUT_MS,
|
||||||
|
MAX_GIT_TIMEOUT_MS,
|
||||||
|
MAX_REPOS_LIMIT,
|
||||||
|
MIN_GIT_TIMEOUT_MS,
|
||||||
|
resolveOverviewLimits,
|
||||||
|
} from '../src/git-workspace-status.js';
|
||||||
|
|
||||||
|
describe('configurable repository limit and git timeout', () => {
|
||||||
|
let top: string;
|
||||||
|
let home: string;
|
||||||
|
const repoAt = (p: string): string => {
|
||||||
|
mkdir(p, { recursive: true });
|
||||||
|
git(p, 'init', '-q', '-b', 'main');
|
||||||
|
writeFileSync(join(p, 'f.txt'), '1\n');
|
||||||
|
git(p, 'add', '-A');
|
||||||
|
git(p, 'commit', '-q', '-m', 'c');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
top = mkdtempSync(join(tmpdir(), 'git-limits-'));
|
||||||
|
home = join(top, 'home');
|
||||||
|
mkdir(home, { recursive: true });
|
||||||
|
clearGitStatusCache();
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(top, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('clampInt keeps untrusted values inside the range, and falls back for anything that is not a number', () => {
|
||||||
|
expect(clampInt('7', 1, 50, 12)).toBe(7);
|
||||||
|
expect(clampInt(7.9, 1, 50, 12)).toBe(7);
|
||||||
|
expect(clampInt(0, 1, 50, 12)).toBe(1);
|
||||||
|
expect(clampInt(9999, 1, 50, 12)).toBe(50);
|
||||||
|
for (const bad of [undefined, null, '', 'abc', NaN, Infinity, {}]) expect(clampInt(bad, 1, 50, 12)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveOverviewLimits defaults to 12 repositories and a 30 s timeout, and clamps both ends', () => {
|
||||||
|
expect(resolveOverviewLimits({})).toEqual({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
|
||||||
|
expect(DEFAULT_GIT_TIMEOUT_MS).toBe(30_000);
|
||||||
|
expect(resolveOverviewLimits({ maxRepos: 500, timeoutMs: 10 ** 9 })).toEqual({
|
||||||
|
maxRepos: MAX_REPOS_LIMIT,
|
||||||
|
timeoutMs: MAX_GIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
expect(resolveOverviewLimits({ maxRepos: -3, timeoutMs: 1 })).toEqual({
|
||||||
|
maxRepos: 1,
|
||||||
|
timeoutMs: MIN_GIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists up to maxRepos, says what the limit was, and a different limit is not answered from the old cache', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
for (const n of ['a', 'b', 'c', 'd', 'e']) repoAt(join(ws, n));
|
||||||
|
const three = await getGitWorkspaceOverview(ws, { home, maxRepos: 3 });
|
||||||
|
expect(three.repos.map((r) => r.name)).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(three).toMatchObject({ reposTruncated: true, repoLimit: 3 });
|
||||||
|
// No `fresh`: the 30 s discovery cache must be keyed by the limit.
|
||||||
|
const ten = await getGitWorkspaceOverview(ws, { home, maxRepos: 10 });
|
||||||
|
expect(ten.repos).toHaveLength(5);
|
||||||
|
expect(ten).toMatchObject({ reposTruncated: false, repoLimit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a repository git could not read in the list, with the reason, instead of dropping it', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
for (const n of ['fast', 'slow']) repoAt(join(ws, n));
|
||||||
|
const flaky: GitRunner = (cwd, args, opts) => {
|
||||||
|
if (cwd.endsWith('slow')) return Promise.reject(Object.assign(new Error('timed out'), { killed: true }));
|
||||||
|
return runGit(cwd, args, opts);
|
||||||
|
};
|
||||||
|
const o = await getGitWorkspaceOverview(ws, { home, git: flaky });
|
||||||
|
expect(o.repos.map((r) => [r.name, r.status.state])).toEqual([
|
||||||
|
['fast', 'ok'],
|
||||||
|
['slow', 'error'],
|
||||||
|
]);
|
||||||
|
expect(o.repos[1].status.error).toBe('git timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the timeout to every git command, clamped', async () => {
|
||||||
|
const ws = join(home, 'case');
|
||||||
|
repoAt(join(ws, 'a'));
|
||||||
|
const seen: Array<number | undefined> = [];
|
||||||
|
const spy: GitRunner = (cwd, args, opts) => {
|
||||||
|
seen.push(opts?.timeoutMs);
|
||||||
|
return runGit(cwd, args, opts);
|
||||||
|
};
|
||||||
|
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 45_000, fresh: true });
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(seen)).toEqual(new Set([45_000]));
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
seen.length = 0;
|
||||||
|
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 10 ** 9, fresh: true });
|
||||||
|
expect(new Set(seen)).toEqual(new Set([MAX_GIT_TIMEOUT_MS]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pop-out through a native host's window opener.
|
||||||
|
*
|
||||||
|
* An Android WebView wrapper has no browser pop-ups: `window.open` there either
|
||||||
|
* does nothing or replaces the page. On a foldable the app can still put a page
|
||||||
|
* in a window of its own beside the dashboard, and says so by exposing
|
||||||
|
* `window.CodemanHost.openWindow(absoluteUrl)`. When it does:
|
||||||
|
* 1. `detachSession` hands the solo URL to the host instead of `window.open`,
|
||||||
|
* marks the tab detached and announces it on the window channel (there is
|
||||||
|
* no WindowProxy, so liveness is the roll-call path a reloaded dashboard
|
||||||
|
* already uses),
|
||||||
|
* 2. a host that refuses leaves the tab docked and toasts,
|
||||||
|
* 3. without a host nothing changes (`openInHostWindow` returns null),
|
||||||
|
* 4. a solo window closes and raises itself through the host when it can,
|
||||||
|
* 5. without a window channel (no BroadcastChannel) the host is never asked,
|
||||||
|
* since a hosted tab could not re-dock without the roll-call.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
|
||||||
|
function load(host?: Record<string, unknown>) {
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
focus: vi.fn(),
|
||||||
|
};
|
||||||
|
if (host) windowStub.CodemanHost = host;
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
URL,
|
||||||
|
location: { href: 'http://10.0.0.2:8095/' },
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
const constants = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||||
|
const source = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
|
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||||
|
app.isSoloWindow = false;
|
||||||
|
app.sessions = new Map([['s1', {}]]);
|
||||||
|
app.detachedSessions = new Set();
|
||||||
|
app.detachedWindows = new Map();
|
||||||
|
// A live window channel, as _initWindowChannel would open in a browser.
|
||||||
|
app.windowChannel = {};
|
||||||
|
app.$ = () => null;
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app._postWindowMessage = vi.fn();
|
||||||
|
app._watchDetachedWindow = vi.fn();
|
||||||
|
return { app, windowStub };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detach through a host window opener', () => {
|
||||||
|
it('opens the solo URL in a host window and tracks the tab over the channel', () => {
|
||||||
|
const openWindow = vi.fn().mockReturnValue(true);
|
||||||
|
const { app, windowStub } = load({ openWindow });
|
||||||
|
|
||||||
|
app.detachSession('s1');
|
||||||
|
|
||||||
|
expect(openWindow).toHaveBeenCalledWith('http://10.0.0.2:8095/session/s1');
|
||||||
|
expect(windowStub.open).not.toHaveBeenCalled();
|
||||||
|
expect(app.detachedSessions.has('s1')).toBe(true);
|
||||||
|
expect(app.detachedWindows.size).toBe(0);
|
||||||
|
expect(app._watchDetachedWindow).not.toHaveBeenCalled();
|
||||||
|
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'detached', id: 's1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the tab docked and toasts when the host opens nothing', () => {
|
||||||
|
const { app, windowStub } = load({ openWindow: vi.fn().mockReturnValue(false) });
|
||||||
|
|
||||||
|
app.detachSession('s1');
|
||||||
|
|
||||||
|
expect(windowStub.open).not.toHaveBeenCalled();
|
||||||
|
expect(app.detachedSessions.has('s1')).toBe(false);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses without a window channel, so the tab is never stuck detached', () => {
|
||||||
|
const openWindow = vi.fn().mockReturnValue(true);
|
||||||
|
const { app, windowStub } = load({ openWindow });
|
||||||
|
app.windowChannel = null;
|
||||||
|
|
||||||
|
app.detachSession('s1');
|
||||||
|
|
||||||
|
expect(openWindow).not.toHaveBeenCalled();
|
||||||
|
expect(windowStub.open).not.toHaveBeenCalled();
|
||||||
|
expect(app.detachedSessions.size).toBe(0);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a throwing host as a failed open', () => {
|
||||||
|
const { app } = load({
|
||||||
|
openWindow: () => {
|
||||||
|
throw new Error('bridge gone');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.openInHostWindow('/session/s1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps window.open when there is no host', () => {
|
||||||
|
const { app, windowStub } = load();
|
||||||
|
|
||||||
|
expect(app.hasHostWindows()).toBe(false);
|
||||||
|
expect(app.openInHostWindow('/session/s1')).toBeNull();
|
||||||
|
app.detachSession('s1');
|
||||||
|
expect(windowStub.open).toHaveBeenCalledWith('/session/s1', 'codeman-session-s1', expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes and raises a solo window through the host', () => {
|
||||||
|
const closeWindow = vi.fn();
|
||||||
|
const focusWindow = vi.fn();
|
||||||
|
const { app, windowStub } = load({ openWindow: vi.fn(), closeWindow, focusWindow });
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
app.soloSessionId = 's1';
|
||||||
|
|
||||||
|
app._onWindowMessage({ type: 'focus-request', id: 's1' });
|
||||||
|
app._onWindowMessage({ type: 'close-request', id: 's1' });
|
||||||
|
|
||||||
|
expect(focusWindow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeWindow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(windowStub.close).not.toHaveBeenCalled();
|
||||||
|
expect(windowStub.focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
|
* @fileoverview Phase 2 multi-user auth integration tests (live server, ephemeral port).
|
||||||
*
|
*
|
||||||
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
|
||||||
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
* identity, wrong-password / disabled-user rejection, the mustChangePassword
|
||||||
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
* lockbox + self-service change, per-account rate limiting, and QR identity binding
|
||||||
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
|
||||||
*
|
*
|
||||||
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
|
* Ports: ephemeral (`new WebServer(0, …)`, read back through `boundPort`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -21,9 +21,6 @@ import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
|||||||
|
|
||||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||||
|
|
||||||
const PORT = 3170;
|
|
||||||
const RATE_PORT = 3171;
|
|
||||||
|
|
||||||
function basic(user: string, pass: string): string {
|
function basic(user: string, pass: string): string {
|
||||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||||
}
|
}
|
||||||
@@ -68,7 +65,7 @@ beforeAll(async () => {
|
|||||||
const { updateUser } = await import('../src/user-store.js');
|
const { updateUser } = await import('../src/user-store.js');
|
||||||
await updateUser('carol', { disabled: true });
|
await updateUser('carol', { disabled: true });
|
||||||
|
|
||||||
server = new WebServer(PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +81,7 @@ afterAll(async () => {
|
|||||||
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||||
|
|
||||||
describe('multi-user auth', () => {
|
describe('multi-user auth', () => {
|
||||||
it('rejects unauthenticated requests', async () => {
|
it('rejects unauthenticated requests', async () => {
|
||||||
@@ -161,9 +158,9 @@ describe('multi-user auth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
|
||||||
rateServer = new WebServer(RATE_PORT, false, true);
|
rateServer = new WebServer(0, false, true);
|
||||||
await rateServer.start();
|
await rateServer.start();
|
||||||
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
|
const rurl = (p: string) => `http://localhost:${rateServer.boundPort}${p}`;
|
||||||
|
|
||||||
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
|
||||||
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* Run: npx vitest run test/opencode-resize.test.ts
|
* Run: npx vitest run test/opencode-resize.test.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -18,6 +19,15 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
const PORT = 3211;
|
const PORT = 3211;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
const HAS_OPENCODE = (() => {
|
||||||
|
try {
|
||||||
|
execSync('command -v opencode', { stdio: 'ignore', shell: '/bin/bash' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
|
|
||||||
@@ -103,6 +113,18 @@ describe('OpenCode session initial resize', () => {
|
|||||||
|
|
||||||
// Intercept resize API calls to track when they happen
|
// Intercept resize API calls to track when they happen
|
||||||
const resizeCalls: Array<{ url: string; cols: number; rows: number }> = [];
|
const resizeCalls: Array<{ url: string; cols: number; rows: number }> = [];
|
||||||
|
// While the WebSocket is connected, resizes go out as {t:'z',c,r} frames
|
||||||
|
// instead of POST /resize, so record both transports.
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
ws.on('framesent', (frame) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(String(frame.payload));
|
||||||
|
if (msg.t === 'z') resizeCalls.push({ url: ws.url(), cols: msg.c, rows: msg.r });
|
||||||
|
} catch {
|
||||||
|
/* not JSON */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route('**/api/sessions/*/resize', async (route) => {
|
await page.route('**/api/sessions/*/resize', async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const body = request.postDataJSON();
|
const body = request.postDataJSON();
|
||||||
@@ -123,7 +145,8 @@ describe('OpenCode session initial resize', () => {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }),
|
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionId).toBeTruthy();
|
expect(sessionId).toBeTruthy();
|
||||||
@@ -168,7 +191,8 @@ describe('OpenCode session initial resize', () => {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }),
|
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionId).toBeTruthy();
|
expect(sessionId).toBeTruthy();
|
||||||
@@ -219,7 +243,8 @@ describe('OpenCode session initial resize', () => {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }),
|
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionId).toBeTruthy();
|
expect(sessionId).toBeTruthy();
|
||||||
@@ -230,7 +255,23 @@ describe('OpenCode session initial resize', () => {
|
|||||||
await app.selectSession(sid);
|
await app.selectSession(sid);
|
||||||
}, sessionId);
|
}, sessionId);
|
||||||
|
|
||||||
await page.waitForTimeout(300);
|
// The handler only resizes after it has replayed a NON-EMPTY terminal
|
||||||
|
// buffer, so give the session a real PTY with some output first.
|
||||||
|
await page.evaluate(async (sid: string) => {
|
||||||
|
await fetch(`/api/sessions/${sid}/shell`, { method: 'POST' });
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
for (;;) {
|
||||||
|
await fetch(`/api/sessions/${sid}/input`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ input: 'echo needs-refresh-seed\r', useMux: false }),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
const res = await fetch(`/api/sessions/${sid}/terminal?full=1`);
|
||||||
|
if ((((await res.json())?.data?.terminalBuffer as string) ?? '').includes('needs-refresh-seed')) break;
|
||||||
|
if (Date.now() > deadline) throw new Error('seed output never appeared');
|
||||||
|
}
|
||||||
|
}, sessionId);
|
||||||
|
|
||||||
// Intercept resize calls
|
// Intercept resize calls
|
||||||
const resizeCalls: Array<{ url: string }> = [];
|
const resizeCalls: Array<{ url: string }> = [];
|
||||||
@@ -275,7 +316,7 @@ describe('OpenCode close modal text', () => {
|
|||||||
await context?.close();
|
await context?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Kill Tmux & OpenCode" for opencode sessions', async () => {
|
it.skipIf(!HAS_OPENCODE)('shows "Kill Tmux & OpenCode" for opencode sessions', async () => {
|
||||||
({ context, page } = await freshPage());
|
({ context, page } = await freshPage());
|
||||||
await navigateAndWait(page);
|
await navigateAndWait(page);
|
||||||
|
|
||||||
@@ -287,7 +328,8 @@ describe('OpenCode close modal text', () => {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }),
|
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionId).toBeTruthy();
|
expect(sessionId).toBeTruthy();
|
||||||
@@ -329,7 +371,8 @@ describe('OpenCode close modal text', () => {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }),
|
body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionId).toBeTruthy();
|
expect(sessionId).toBeTruthy();
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ async function createSession(page: Page, name: string): Promise<string> {
|
|||||||
body: JSON.stringify({ workingDir: '/tmp', name: n }),
|
body: JSON.stringify({ workingDir: '/tmp', name: n }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.id ?? data.session?.id;
|
// POST /api/sessions answers in the { success, data: { session } } envelope.
|
||||||
|
return data.data?.session?.id;
|
||||||
}, name);
|
}, name);
|
||||||
return result as string;
|
return result as string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,12 @@
|
|||||||
* 12. QR auth bypass in auth middleware
|
* 12. QR auth bypass in auth middleware
|
||||||
* 13. GET /api/tunnel/qr SVG endpoint (auth/no-auth, caching, errors)
|
* 13. GET /api/tunnel/qr SVG endpoint (auth/no-auth, caching, errors)
|
||||||
*
|
*
|
||||||
* Port: 3162 (qr-auth tests), 3163 (qr-svg endpoint tests)
|
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
import { TunnelManager } from '../src/tunnel-manager.js';
|
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
const QR_AUTH_PORT = 3162;
|
|
||||||
const QR_SVG_PORT = 3163;
|
|
||||||
const TEST_PASS = 'qr-test-pass-xyz';
|
const TEST_PASS = 'qr-test-pass-xyz';
|
||||||
const TEST_USER = 'admin';
|
const TEST_USER = 'admin';
|
||||||
|
|
||||||
@@ -312,9 +310,9 @@ describe('QR Auth Integration', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
server = new WebServer(QR_AUTH_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${QR_AUTH_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -608,9 +606,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||||
server = new WebServer(QR_SVG_PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
baseUrl = `http://localhost:${QR_SVG_PORT}`;
|
baseUrl = `http://localhost:${server.boundPort}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -559,9 +559,14 @@ describe('RemoteWakeRegistry', () => {
|
|||||||
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
|
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
|
||||||
).resolves.toBe(true);
|
).resolves.toBe(true);
|
||||||
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
|
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
|
||||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
timeoutMs: expect.any(Number),
|
||||||
signal: expect.any(AbortSignal),
|
signal: expect.any(AbortSignal),
|
||||||
});
|
});
|
||||||
|
// Not asserted to the millisecond: the wake's own elapsed time is subtracted, so a
|
||||||
|
// slow runner lands a few ms under the budget.
|
||||||
|
const [, readyOpts] = h.waitUntilReady.mock.calls[0] as [unknown, { timeoutMs: number; signal: AbortSignal }];
|
||||||
|
expect(readyOpts.timeoutMs).toBeLessThanOrEqual(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS);
|
||||||
|
expect(readyOpts.timeoutMs).toBeGreaterThan(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS - 1_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -248,16 +248,23 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
shortBadge: string;
|
shortBadge: string;
|
||||||
order: number;
|
order: number;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
external: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
}>;
|
}>;
|
||||||
expect(catalog.map((entry) => entry.id)).toEqual(STOCK_CLIS.map((entry) => entry.id));
|
expect(catalog.map((entry) => entry.id)).toEqual(STOCK_CLIS.map((entry) => entry.id));
|
||||||
expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({ label: 'Codex', kind: 'agent' });
|
expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({
|
||||||
|
label: 'Codex',
|
||||||
|
kind: 'agent',
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
// claude is `kind: 'agent'` too; `external` is what keeps its Respawn/Ralph options visible.
|
||||||
|
expect(catalog.find((entry) => entry.id === 'claude')).toMatchObject({ kind: 'agent', external: false });
|
||||||
expect(catalog.find((entry) => entry.id === 'shell')).toMatchObject({ enabled: true, available: true });
|
expect(catalog.find((entry) => entry.id === 'shell')).toMatchObject({ enabled: true, available: true });
|
||||||
expect(
|
expect(
|
||||||
catalog.every((entry) =>
|
catalog.every((entry) =>
|
||||||
Object.keys(entry).every((key) =>
|
Object.keys(entry).every((key) =>
|
||||||
['id', 'label', 'shortBadge', 'order', 'kind', 'enabled', 'available'].includes(key)
|
['id', 'label', 'shortBadge', 'order', 'kind', 'external', 'enabled', 'available'].includes(key)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
@@ -556,6 +556,52 @@ describe('file-routes attachment path guard (COD-53)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== XLSX: client-side preview, same guard and routes =====
|
||||||
|
// xlsx joins the extension allowlist so the overlay can preview a workbook
|
||||||
|
// outside the workspace by id. Nothing else about the pipeline changes; only
|
||||||
|
// `?preview=true` adds a tighter size cap because the browser parses it.
|
||||||
|
describe('xlsx attachments', () => {
|
||||||
|
async function registerXlsx(size: number) {
|
||||||
|
mockedStat.mockResolvedValue({ size, isFile: () => true, mtimeMs: 5 } as never);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path: '/tmp/report.xlsx', notify: false },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return JSON.parse(res.body).data as { attachmentId: string; attachmentType: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers an xlsx as a spreadsheet attachment', async () => {
|
||||||
|
const data = await registerXlsx(2048);
|
||||||
|
expect(data.attachmentType).toBe('spreadsheet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses xls and ods (download-only, no preview)', async () => {
|
||||||
|
for (const path of ['/tmp/legacy.xls', '/tmp/open.ods']) {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
||||||
|
payload: { path, notify: false },
|
||||||
|
});
|
||||||
|
expect(res.statusCode, path).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a ?preview=true fetch at 10 MB but still serves a plain download', async () => {
|
||||||
|
const { attachmentId } = await registerXlsx(11 * 1024 * 1024);
|
||||||
|
const base = `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`;
|
||||||
|
|
||||||
|
const preview = await harness.app.inject({ method: 'GET', url: `${base}?preview=true` });
|
||||||
|
expect(preview.statusCode).toBe(413);
|
||||||
|
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
|
||||||
|
|
||||||
|
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('PK')]) as never);
|
||||||
|
const download = await harness.app.inject({ method: 'GET', url: `${base}?preview=true&download=true` });
|
||||||
|
expect(download.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ===== Quiet registration (click-to-preview) =====
|
// ===== Quiet registration (click-to-preview) =====
|
||||||
// The file-preview overlay registers a clicked out-of-workspace path to mint
|
// The file-preview overlay registers a clicked out-of-workspace path to mint
|
||||||
// an id it can render by. It is already putting the file on screen, so the
|
// an id it can render by. It is already putting the file on screen, so the
|
||||||
|
|||||||
@@ -707,12 +707,12 @@ describe('file-routes', () => {
|
|||||||
expect(body.data.url).toContain('file-raw');
|
expect(body.data.url).toContain('file-raw');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
|
it('flags known-binary extensions (e.g. xls) instead of dumping mojibake', async () => {
|
||||||
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xls`,
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
@@ -721,6 +721,30 @@ describe('file-routes', () => {
|
|||||||
expect(body.data.content).toBeUndefined();
|
expect(body.data.content).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('classifies xlsx as a client-side spreadsheet preview, never a text body', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.type).toBe('spreadsheet');
|
||||||
|
expect(body.data.url).toBe(`/api/sessions/${harness.ctx._sessionId}/file-raw?path=sheet.xlsx`);
|
||||||
|
expect(body.data.content).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ods (and xls) download-only binaries', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.ods`,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(res.body).data.type).toBe('binary');
|
||||||
|
});
|
||||||
|
|
||||||
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
|
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
|
||||||
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
|
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
|
||||||
mockedReadFile.mockResolvedValue(binary as never);
|
mockedReadFile.mockResolvedValue(binary as never);
|
||||||
@@ -893,6 +917,22 @@ describe('file-routes', () => {
|
|||||||
expect(res.statusCode).toBe(413);
|
expect(res.statusCode).toBe(413);
|
||||||
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
|
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps an xlsx ?preview=true fetch at 10 MB, leaving downloads and small previews alone', async () => {
|
||||||
|
mockedStat.mockResolvedValue({ size: 11 * 1024 * 1024 } as never);
|
||||||
|
const url = `/api/sessions/${harness.ctx._sessionId}/file-raw?path=book.xlsx`;
|
||||||
|
|
||||||
|
const preview = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
|
||||||
|
expect(preview.statusCode).toBe(413);
|
||||||
|
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
|
||||||
|
|
||||||
|
const download = await harness.app.inject({ method: 'GET', url: `${url}&preview=true&download=true` });
|
||||||
|
expect(download.statusCode).toBe(200);
|
||||||
|
|
||||||
|
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||||
|
const small = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
|
||||||
|
expect(small.statusCode).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
|
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
|
||||||
|
|||||||
@@ -298,3 +298,59 @@ describe('GET /api/sessions/:id/git-diff in a folder of several repositories', (
|
|||||||
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/git-status limits', () => {
|
||||||
|
it('honours maxRepos and timeout (seconds), clamped, and reports the limit it used', async () => {
|
||||||
|
for (const n of ['a', 'b', 'c']) {
|
||||||
|
mkdirSync(join(dir, n));
|
||||||
|
git(join(dir, n), 'init', '-q', '-b', 'main');
|
||||||
|
}
|
||||||
|
const seen: Array<number | undefined> = [];
|
||||||
|
const spy: GitRunner = (cwd, args, opts) => {
|
||||||
|
seen.push(opts?.timeoutMs);
|
||||||
|
return execFileSyncGit(cwd, args);
|
||||||
|
};
|
||||||
|
const { app } = await setup({ git: spy });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=2&timeout=7',
|
||||||
|
});
|
||||||
|
const data = res.json().data;
|
||||||
|
expect(data.repos.map((r: { name: string }) => r.name)).toEqual(['a', 'b']);
|
||||||
|
expect(data).toMatchObject({ reposTruncated: true, repoLimit: 2 });
|
||||||
|
expect(new Set(seen)).toEqual(new Set([7000]));
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
const wild = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=9999&timeout=1&fresh=1',
|
||||||
|
});
|
||||||
|
expect(wild.json().data.repoLimit).toBe(50);
|
||||||
|
expect(seen.at(-1)).toBe(5000);
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
const junk = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?maxRepos=abc&timeout=xyz&fresh=1',
|
||||||
|
});
|
||||||
|
expect(junk.json().data.repoLimit).toBe(12);
|
||||||
|
expect(seen.at(-1)).toBe(30_000);
|
||||||
|
|
||||||
|
clearGitStatusCache();
|
||||||
|
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?maxRepos=&timeout=&fresh=1' });
|
||||||
|
expect(seen.at(-1)).toBe(30_000); // empty means "not given", not 0
|
||||||
|
|
||||||
|
// A repeated key reaches the route as an array: it means "default", never a 500.
|
||||||
|
clearGitStatusCache();
|
||||||
|
const repeated = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/sessions/test-session-1/git-status?timeout=5&timeout=6&fresh=1',
|
||||||
|
});
|
||||||
|
expect(repeated.statusCode).toBe(200);
|
||||||
|
expect(seen.at(-1)).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
|
||||||
|
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||||
|
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||||
|
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js';
|
||||||
|
import { buildCodexCommand } from '../../src/tmux-manager.js';
|
||||||
|
import { Session } from '../../src/session.js';
|
||||||
|
import { safeRmHomeTree } from '../mocks/index.js';
|
||||||
|
import { getDataDir } from '../../src/config/instance.js';
|
||||||
|
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
|
||||||
|
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Codex launch defaults', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
const workingDir = join(homedir(), 'codex-default-test');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const name of ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json']) {
|
||||||
|
await rm(join(getDataDir(), name), { force: true });
|
||||||
|
}
|
||||||
|
await mkdir(workingDir, { recursive: true });
|
||||||
|
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
|
||||||
|
await writeFile(SETTINGS_PATH, JSON.stringify({ codexModel: 'gpt-6.1', codexReasoningEffort: 'high' }));
|
||||||
|
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||||
|
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
safeRmHomeTree(CASES_DIR);
|
||||||
|
safeRmHomeTree(workingDir);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createdConfig(url: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
const payload =
|
||||||
|
url === '/api/sessions'
|
||||||
|
? { workingDir, mode: 'codex', ...overrides }
|
||||||
|
: { caseName: 'codex-default-test', mode: 'codex', ...overrides };
|
||||||
|
const res = await harness.app.inject({ method: 'POST', url, payload });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().success, res.body).not.toBe(false);
|
||||||
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session | undefined;
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
return session!.codexConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of ['/api/sessions', '/api/quick-start']) {
|
||||||
|
it(`applies persisted defaults at ${url} and emits the CLI options`, async () => {
|
||||||
|
const config = await createdConfig(url);
|
||||||
|
expect(config).toMatchObject({ model: 'gpt-6.1', reasoningEffort: 'high' });
|
||||||
|
expect(buildCodexCommand(config)).toContain('--model gpt-6.1');
|
||||||
|
expect(buildCodexCommand(config)).toContain('--config model_reasoning_effort=high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`preserves explicit model and effort at ${url}`, async () => {
|
||||||
|
expect(await createdConfig(url, { codexConfig: { model: 'gpt-other', reasoningEffort: 'low' } })).toMatchObject({
|
||||||
|
model: 'gpt-other',
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('leaves empty defaults to Codex and ignores malformed persisted values', async () => {
|
||||||
|
for (const settings of [
|
||||||
|
{ codexModel: '', codexReasoningEffort: '' },
|
||||||
|
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
|
||||||
|
]) {
|
||||||
|
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
|
||||||
|
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
|
||||||
|
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps defaults out of custom endpoint launches', async () => {
|
||||||
|
const config = { model: 'local-model', animations: false };
|
||||||
|
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record unused defaults for a Docker quick-start', async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(getDataDir(), 'docker-hosts.json'),
|
||||||
|
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(getDataDir(), 'docker-cases.json'),
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'codex-default-test',
|
||||||
|
type: 'docker',
|
||||||
|
hostId: 'docker1',
|
||||||
|
hostWorkspacePath: workingDir,
|
||||||
|
container: 'codeman-default-test',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(await createdConfig('/api/quick-start')).toBeUndefined();
|
||||||
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
||||||
|
expect(session.docker?.containerName).toBe('codeman-default-test');
|
||||||
|
expect(session.toState().model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record unused defaults for a remote attach', async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(getDataDir(), 'remote-hosts.json'),
|
||||||
|
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await createdConfig('/api/sessions', {
|
||||||
|
workingDir: undefined,
|
||||||
|
attachRemoteSession: { hostId: 'remote1', remoteSessionName: 'codeman-existing' },
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
||||||
|
expect(session.toState().remote?.hostId).toBe('remote1');
|
||||||
|
expect(session.toState().model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not record unused defaults for a remote quick-start', async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(getDataDir(), 'remote-hosts.json'),
|
||||||
|
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(getDataDir(), 'remote-cases.json'),
|
||||||
|
JSON.stringify([{ name: 'codex-default-test', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
|
||||||
|
);
|
||||||
|
expect(await createdConfig('/api/quick-start')).toBeUndefined();
|
||||||
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
||||||
|
expect(session.toState().remote?.hostId).toBe('remote1');
|
||||||
|
expect(session.toState().model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts, persists, clears and validates synced settings via HTTP', async () => {
|
||||||
|
const system = await createRouteTestHarness(registerSystemRoutes);
|
||||||
|
try {
|
||||||
|
const put = (payload: unknown) => system.app.inject({ method: 'PUT', url: '/api/settings', payload });
|
||||||
|
expect((await put({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' })).statusCode).toBe(200);
|
||||||
|
const read = await system.app.inject({ method: 'GET', url: '/api/settings' });
|
||||||
|
expect(read.json()).toMatchObject({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' });
|
||||||
|
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
|
||||||
|
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
|
||||||
|
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
|
||||||
|
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await system.app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the App Settings client check mirrors SettingsUpdateSchema.codexModel and runs before the local write', () => {
|
||||||
|
// SettingsUpdateSchema is .strict(), so a codexModel the schema refuses 400s the
|
||||||
|
// WHOLE settings PUT while the toast still says "Settings saved". saveAppSettings()
|
||||||
|
// refuses it client-side with a copy of the schema's pattern; a looser copy brings
|
||||||
|
// that silent 400 back, a stricter one refuses valid model ids. Length is left
|
||||||
|
// out on purpose: the input's maxlength="100" covers .max(100).
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../../src/web/public/settings-ui.js'), 'utf8');
|
||||||
|
const start = src.indexOf('async saveAppSettings() {');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = src.slice(start);
|
||||||
|
const m = body.match(/if \(!\/(\^\[[^\]\n]+\]\*\$)\/\.test\(settings\.codexModel\)\)/);
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
const client = new RegExp(m![1]);
|
||||||
|
for (const v of ['', 'gpt-5.1', 'org/model_1-x', 'gpt-oss:20b', 'a b', 'bad;cmd', '-x', 'é']) {
|
||||||
|
expect(client.test(v), v).toBe(SettingsUpdateSchema.safeParse({ codexModel: v }).success);
|
||||||
|
}
|
||||||
|
const guardAt = body.indexOf(m![0]);
|
||||||
|
const writeAt = body.indexOf('this.saveAppSettingsToStorage(settings);');
|
||||||
|
expect(writeAt).toBeGreaterThan(-1);
|
||||||
|
expect(guardAt).toBeLessThan(writeAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2238,10 +2238,15 @@ describe('session-routes', () => {
|
|||||||
// The request budget, not the 90 s session default: the reverse proxy would
|
// The request budget, not the 90 s session default: the reverse proxy would
|
||||||
// cut the request at 60 s while the session was still being built.
|
// cut the request at 60 s while the session was still being built.
|
||||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
timeoutMs: expect.any(Number),
|
||||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||||
signal: expect.any(AbortSignal),
|
signal: expect.any(AbortSignal),
|
||||||
});
|
});
|
||||||
|
// Not asserted to the millisecond: the wake's own elapsed time is subtracted,
|
||||||
|
// so a slow runner lands a few ms under the budget.
|
||||||
|
const [, readyOpts] = wakeWaitUntilReady.mock.calls[0] as unknown as [unknown, { timeoutMs: number }];
|
||||||
|
expect(readyOpts.timeoutMs).toBeLessThanOrEqual(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS);
|
||||||
|
expect(readyOpts.timeoutMs).toBeGreaterThan(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS - 1_000);
|
||||||
} finally {
|
} finally {
|
||||||
startShell.mockRestore();
|
startShell.mockRestore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -680,6 +680,49 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/api/screenshots deprecation', () => {
|
||||||
|
it('warns once across requests and leaves the response unchanged', async () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
mockedExistsSync.mockReturnValue(false);
|
||||||
|
const first = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||||
|
const second = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||||
|
expect(JSON.parse(first.body)).toEqual({ files: [] });
|
||||||
|
expect(JSON.parse(second.body)).toEqual({ files: [] });
|
||||||
|
const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots'));
|
||||||
|
expect(deprecations).toHaveLength(1);
|
||||||
|
expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// One request per route on the fresh per-test harness, so each warn call is pinned on its own
|
||||||
|
// (the once-flag would let the first route's warning mask a missing call on the others).
|
||||||
|
it.each([
|
||||||
|
{ label: 'GET /api/screenshots', method: 'GET' as const, url: '/api/screenshots' },
|
||||||
|
{
|
||||||
|
label: 'POST /api/screenshots',
|
||||||
|
method: 'POST' as const,
|
||||||
|
url: '/api/screenshots',
|
||||||
|
payload: { file: 'data' },
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
{ label: 'GET /api/screenshots/:name', method: 'GET' as const, url: '/api/screenshots/nonexistent.png' },
|
||||||
|
])('$label warns that it is deprecated', async ({ method, url, payload, headers }) => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
mockedExistsSync.mockReturnValue(false);
|
||||||
|
await harness.app.inject({ method, url, payload, headers });
|
||||||
|
const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots'));
|
||||||
|
expect(deprecations).toHaveLength(1);
|
||||||
|
expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== GET /api/screenshots/:name ==========
|
// ========== GET /api/screenshots/:name ==========
|
||||||
|
|
||||||
describe('GET /api/screenshots/:name', () => {
|
describe('GET /api/screenshots/:name', () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - the socket refuses exactly what the status endpoint calls unavailable,
|
* - the socket refuses exactly what the status endpoint calls unavailable,
|
||||||
* - a cross-site upgrade cannot open a stream on the operator's subscription.
|
* - a cross-site upgrade cannot open a stream on the operator's subscription.
|
||||||
*
|
*
|
||||||
* Port: 3230 (routes), 3231 (mock upstream)
|
* Port: ephemeral (`port: 0` for the routes and the mock upstream)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
@@ -20,6 +20,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
|||||||
import WebSocket, { WebSocketServer } from 'ws';
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
|
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
|
||||||
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
|
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
|
||||||
@@ -47,8 +48,9 @@ function removeCredentials(): void {
|
|||||||
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
|
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = 3230;
|
/** Both assigned by the OS on every listen (`port: 0`); see beforeEach. */
|
||||||
const UPSTREAM_PORT = 3231;
|
let PORT = 0;
|
||||||
|
let UPSTREAM_PORT = 0;
|
||||||
const TOKEN = 'sk-ant-oat01-voice-route-test';
|
const TOKEN = 'sk-ant-oat01-voice-route-test';
|
||||||
|
|
||||||
/** State captured by the mock upstream, so tests can assert what Codeman sent. */
|
/** State captured by the mock upstream, so tests can assert what Codeman sent. */
|
||||||
@@ -118,7 +120,7 @@ describe('voice-routes', () => {
|
|||||||
voiceEnabled = true;
|
voiceEnabled = true;
|
||||||
capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null };
|
capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null };
|
||||||
|
|
||||||
upstream = new WebSocketServer({ port: UPSTREAM_PORT, host: '127.0.0.1' });
|
upstream = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
||||||
upstream.on('connection', (socket, req) => {
|
upstream.on('connection', (socket, req) => {
|
||||||
capture.headers = req.headers;
|
capture.headers = req.headers;
|
||||||
capture.url = req.url ?? '';
|
capture.url = req.url ?? '';
|
||||||
@@ -129,6 +131,7 @@ describe('voice-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => upstream.once('listening', resolve));
|
await new Promise<void>((resolve) => upstream.once('listening', resolve));
|
||||||
|
UPSTREAM_PORT = (upstream.address() as AddressInfo).port;
|
||||||
process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`;
|
process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`;
|
||||||
|
|
||||||
writeCredentials(Date.now() + 3_600_000);
|
writeCredentials(Date.now() + 3_600_000);
|
||||||
@@ -138,7 +141,8 @@ describe('voice-routes', () => {
|
|||||||
ctx = createMockRouteContext();
|
ctx = createMockRouteContext();
|
||||||
ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled;
|
ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled;
|
||||||
registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
|
registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
|
||||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||||
|
PORT = (app.server.address() as AddressInfo).port;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
*
|
*
|
||||||
* @dependency test/mocks/mock-route-context.ts (createMockRouteContext)
|
* @dependency test/mocks/mock-route-context.ts (createMockRouteContext)
|
||||||
* @dependency src/web/routes/ws-routes.ts (registerWsRoutes)
|
* @dependency src/web/routes/ws-routes.ts (registerWsRoutes)
|
||||||
* Port: 3170 (ws-routes tests)
|
* Port: ephemeral (`listen({ port: 0 })`)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
@@ -18,7 +19,8 @@ import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js
|
|||||||
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
|
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
|
||||||
import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js';
|
import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js';
|
||||||
|
|
||||||
const PORT = 3170;
|
/** Assigned by the OS on every listen (`port: 0`); see beforeEach. */
|
||||||
|
let PORT = 0;
|
||||||
|
|
||||||
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
|
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
|
||||||
function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> {
|
function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> {
|
||||||
@@ -86,7 +88,8 @@ describe('ws-routes', () => {
|
|||||||
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
|
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
|
||||||
registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
|
registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
|
||||||
|
|
||||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||||
|
PORT = (app.server.address() as AddressInfo).port;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Run dropdown on a phone, mixing the stock CLIs with custom-endpoint entries: it must fit the
|
||||||
|
* screen and scroll by touch, so the first and the last entry can both be reached.
|
||||||
|
*
|
||||||
|
* Before the fix the menu (anchored `bottom: 100%`) had no max-height and no overflow, so with
|
||||||
|
* enough entries its top ran off the top of the screen and could not be scrolled to.
|
||||||
|
*
|
||||||
|
* Browser-driven, so excluded from `npm run test:ci` (config/test-suites.ts). Run locally:
|
||||||
|
* npm run test:browser -- test/run-mode-menu-scroll.browser.test.ts
|
||||||
|
*
|
||||||
|
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
describe('Run dropdown on a phone', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(0, false, true);
|
||||||
|
await server.start();
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 640 },
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
page = await context.newPage();
|
||||||
|
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
if (server) await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
/** Open the menu with `extra` generated custom-endpoint entries, as _refreshCustomModelRunOptions builds them. */
|
||||||
|
async function openWithEntries(extra: number) {
|
||||||
|
await page.evaluate((count) => {
|
||||||
|
const menu = document.getElementById('runModeMenu')!;
|
||||||
|
const box = document.getElementById('runModeCustomModels')!;
|
||||||
|
box.innerHTML = Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, i) =>
|
||||||
|
`<button class="run-mode-option" data-test-entry="${i}"><span class="run-mode-dot claude"></span>Claude Code (endpoint ${i})</button>`
|
||||||
|
).join('');
|
||||||
|
document.getElementById('runModeCustomModelSep')!.style.display = '';
|
||||||
|
document.getElementById('runModeCustomModelHeader')!.style.display = '';
|
||||||
|
menu.classList.add('active');
|
||||||
|
}, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const menu = document.getElementById('runModeMenu')!;
|
||||||
|
const r = menu.getBoundingClientRect();
|
||||||
|
// The menu sits just above the Run button, which is inside the toolbar.
|
||||||
|
const anchor = menu.parentElement!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: r.top,
|
||||||
|
bottom: r.bottom,
|
||||||
|
anchorTop: anchor.top,
|
||||||
|
scrollTop: menu.scrollTop,
|
||||||
|
scrollHeight: menu.scrollHeight,
|
||||||
|
clientHeight: menu.clientHeight,
|
||||||
|
viewport: window.innerHeight,
|
||||||
|
overflowY: getComputedStyle(menu).overflowY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = (selector: string) =>
|
||||||
|
page.evaluate((sel) => {
|
||||||
|
const menu = document.getElementById('runModeMenu')!.getBoundingClientRect();
|
||||||
|
const el = document.querySelector(sel)!.getBoundingClientRect();
|
||||||
|
return el.top >= menu.top - 1 && el.bottom <= menu.bottom + 1 && el.top >= 0;
|
||||||
|
}, selector);
|
||||||
|
|
||||||
|
it('stays on screen however many entries there are, and becomes scrollable', async () => {
|
||||||
|
await openWithEntries(30);
|
||||||
|
const g = await geometry();
|
||||||
|
expect(g.overflowY).toBe('auto');
|
||||||
|
expect(g.top).toBeGreaterThanOrEqual(0); // the top is not off the top of the screen
|
||||||
|
expect(g.bottom).toBeLessThanOrEqual(g.anchorTop); // above the Run button, not over it
|
||||||
|
expect(g.scrollHeight).toBeGreaterThan(g.clientHeight); // there is something to scroll to
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a touch swipe scrolls it, and the last entry can be reached', async () => {
|
||||||
|
await openWithEntries(30);
|
||||||
|
expect(await visible('[data-test-entry="29"]')).toBe(false); // starts out of reach
|
||||||
|
// Real touch input (touchstart / touchmove / touchend), not scrollTo(): this is the path a
|
||||||
|
// finger takes, and the one a missing overflow or `touch-action` would block.
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const g = await geometry();
|
||||||
|
const y0 = (g.top + g.bottom) / 2 + 100;
|
||||||
|
for (let swipe = 0; swipe < 6; swipe += 1) {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 100, y: y0 }] });
|
||||||
|
for (let step = 1; step <= 12; step += 1) {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x: 100, y: y0 - step * 20 }], // finger moves up: content scrolls down
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(16);
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
const after = await geometry();
|
||||||
|
expect(after.scrollTop).toBeGreaterThan(0);
|
||||||
|
expect(await visible('[data-test-entry="29"]')).toBe(true);
|
||||||
|
// Scrolling the menu must not have scrolled the page behind it.
|
||||||
|
expect(await page.evaluate(() => window.scrollY)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a short menu is not stretched or squashed (no scrollbar when it fits)', async () => {
|
||||||
|
await openWithEntries(0);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.getElementById('runModeCustomModelSep')!.style.display = 'none';
|
||||||
|
document.getElementById('runModeCustomModelHeader')!.style.display = 'none';
|
||||||
|
});
|
||||||
|
const g = await geometry();
|
||||||
|
expect(g.scrollHeight).toBeLessThanOrEqual(g.clientHeight + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* The Run dropdown grows UPWARD from the toolbar (`bottom: 100%`). With every CLI, the custom
|
||||||
|
* endpoint entries ("Claude Code (llama.cpp)" ...), Terminal and the saved URLs it can be taller
|
||||||
|
* than the space above the toolbar, which put its top off-screen with nothing to scroll: on a
|
||||||
|
* phone the entries up there were unreachable. This pins the rule that makes it scroll, in the CI
|
||||||
|
* gate; the real touch scroll is test/run-mode-menu-scroll.browser.test.ts.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Comments stripped: the rule's own comment names `overflow-y: auto` and `touch-action: pan-y`,
|
||||||
|
// which would otherwise satisfy the assertions below after the declarations were deleted.
|
||||||
|
const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8').replace(
|
||||||
|
/\/\*[\s\S]*?\*\//g,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The declaration block of the FIRST rule whose selector is exactly `selector`. */
|
||||||
|
function rule(selector: string): string {
|
||||||
|
const start = css.search(new RegExp(`(^|\\n)${selector.replace(/[.>*]/g, '\\$&')}\\s*\\{`));
|
||||||
|
expect(start, `no rule for ${selector}`).toBeGreaterThanOrEqual(0);
|
||||||
|
const open = css.indexOf('{', start);
|
||||||
|
return css.slice(open + 1, css.indexOf('}', open));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Run dropdown scrolls when it is taller than the room above the toolbar', () => {
|
||||||
|
const menu = rule('.run-mode-menu');
|
||||||
|
|
||||||
|
it('scrolls vertically and keeps the scroll inside the menu', () => {
|
||||||
|
expect(menu).toMatch(/overflow-y:\s*auto/);
|
||||||
|
expect(menu).toMatch(/overscroll-behavior:\s*contain/);
|
||||||
|
expect(menu).toMatch(/touch-action:\s*pan-y/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is capped to the space between the header and the toolbar, following --app-height and the safe areas', () => {
|
||||||
|
const caps = [...menu.matchAll(/max-height:\s*([^;]+);/g)].map((m) => m[1]);
|
||||||
|
expect(caps).toHaveLength(2);
|
||||||
|
expect(caps[0]).toContain('100vh');
|
||||||
|
expect(caps[1]).toContain('100dvh');
|
||||||
|
// dvh does not shrink for the on-screen keyboard; --app-height (the visual viewport) does.
|
||||||
|
expect(caps[1]).toContain('var(--app-height');
|
||||||
|
for (const cap of caps) {
|
||||||
|
expect(cap).toContain('var(--header-height)');
|
||||||
|
expect(cap).toContain('var(--toolbar-height)');
|
||||||
|
// The phone header and toolbar grow by the safe areas in the iPhone home-screen app.
|
||||||
|
expect(cap).toContain('var(--safe-area-top)');
|
||||||
|
expect(cap).toContain('var(--safe-area-bottom)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never scrolls sideways (a long custom-endpoint label must not add a horizontal scrollbar)', () => {
|
||||||
|
expect(menu).toMatch(/overflow-x:\s*hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises the toolbar while the menu is open, so its last rows clear the keyboard bar and the CJK input', () => {
|
||||||
|
// The menu is trapped in the toolbar's stacking context (backdrop-filter), so only the
|
||||||
|
// toolbar's own z-index can lift it over the accessory bar (z 51) and #cjkInput (z 52).
|
||||||
|
expect(css).toMatch(/\.toolbar:has\(\.run-mode-menu\.active\)[\s\S]*?\{\s*z-index:\s*100;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let scrolling children (history, saved URLs) be squashed to nothing', () => {
|
||||||
|
expect(rule('.run-mode-menu > *')).toMatch(/flex-shrink:\s*0/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -453,7 +453,15 @@ describe('Codex quick start settings', () => {
|
|||||||
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
||||||
vm.runInContext(src, context, { filename: file });
|
vm.runInContext(src, context, { filename: file });
|
||||||
}
|
}
|
||||||
return { app: new (CodemanApp as any)(), welcomeCliActions, tunnelBtn, runModeCliOptions, modeBtns, menu };
|
return {
|
||||||
|
app: new (CodemanApp as any)(),
|
||||||
|
welcomeCliActions,
|
||||||
|
tunnelBtn,
|
||||||
|
runModeCliOptions,
|
||||||
|
modeBtns,
|
||||||
|
menu,
|
||||||
|
context,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_OFF = {
|
const ALL_OFF = {
|
||||||
@@ -651,6 +659,34 @@ describe('Codex quick start settings', () => {
|
|||||||
expect(src).not.toContain('data-mode="codex"');
|
expect(src).not.toContain('data-mode="codex"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps Claude-only session options for claude even though it is a registry agent', () => {
|
||||||
|
const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' }));
|
||||||
|
const { context } = loadUi(undefined, catalog);
|
||||||
|
expect(context.isExternalCliSession('claude')).toBe(false);
|
||||||
|
expect(context.isExternalCliSession('shell')).toBe(false);
|
||||||
|
expect(context.isExternalCliSession('codex')).toBe(true);
|
||||||
|
expect(context.isExternalCliSession('custom-agent')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the served external flag when App Settings resyncs the catalog from /api/clis', () => {
|
||||||
|
const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' }));
|
||||||
|
const { app, context } = loadUi(undefined, catalog);
|
||||||
|
// /api/clis rows: no capabilities, so no `external`.
|
||||||
|
app._cliList = CATALOG.map((cli) => ({ ...cli, installed: true }));
|
||||||
|
app.runMode = 'claude';
|
||||||
|
app._syncCliLaunchCatalog();
|
||||||
|
expect(context.isExternalCliSession('claude')).toBe(false);
|
||||||
|
expect(context.isExternalCliSession('codex')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates Session Options on isExternalCliSession, not the launch-path check', () => {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
const open = src.slice(src.indexOf('\n openSessionOptions('));
|
||||||
|
const body = open.slice(0, open.indexOf('\n },'));
|
||||||
|
expect(body).toContain('isExternalCliSession(session.mode)');
|
||||||
|
expect(body).not.toContain('isExternalCliRunMode(');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows everything when the flags were never injected', () => {
|
it('shows everything when the flags were never injected', () => {
|
||||||
// A cached page from a build without the injection, or a solo popup. Hiding
|
// A cached page from a build without the injection, or a solo popup. Hiding
|
||||||
// every run button on a doubt would leave a working install nothing to click.
|
// every run button on a doubt would leave a working install nothing to click.
|
||||||
|
|||||||
@@ -113,18 +113,21 @@ describe('TerminalTile in a real browser', () => {
|
|||||||
// own startup can race an early write and, on this box, a startup
|
// own startup can race an early write and, on this box, a startup
|
||||||
// script issues a `clear` that erases scrollback (modern ncurses
|
// script issues a `clear` that erases scrollback (modern ncurses
|
||||||
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
|
// `clear` emits \x1b[3J) if the input lands before the shell is ready.
|
||||||
await fetch(`/api/sessions/${id}/input`, {
|
// Send with useMux:false: a plain prompt otherwise goes out through
|
||||||
method: 'POST',
|
// tmux send-keys, which test mode no-ops, so the marker never reached
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// the PTY. Re-sending until the capture shows it is just belt and braces.
|
||||||
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }),
|
const deadline = Date.now() + 8000;
|
||||||
});
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
await fetch(`/api/sessions/${id}/input`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r', useMux: false }),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
|
const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`);
|
||||||
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
|
const buffer = (await res2.json())?.data?.terminalBuffer ?? '';
|
||||||
if (buffer.includes('PRE_EXISTING_MARKER')) break;
|
if (buffer.includes('PRE_EXISTING_MARKER')) break;
|
||||||
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
|
if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer));
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview XLSX preview asset delivery contracts.
|
||||||
|
*
|
||||||
|
* The parser bundles (exceljs ~950 KB, fflate ~33 KB) must never cost a page
|
||||||
|
* load: the page loads only spreadsheet-preview.js, and the vendor files are
|
||||||
|
* pulled by the worker when a spreadsheet is actually opened. These checks pin
|
||||||
|
* that shape plus the pinned versions and the dev/prod vendoring steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
|
||||||
|
|
||||||
|
describe('spreadsheet preview assets', () => {
|
||||||
|
it('pins the browser parser packages exactly, as build-time (dev) dependencies', () => {
|
||||||
|
const pkg = JSON.parse(read('package.json')) as {
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(pkg.devDependencies?.exceljs).toBe('4.4.0');
|
||||||
|
expect(pkg.devDependencies?.fflate).toBe('0.8.3');
|
||||||
|
// They are vendored into dist/ at build time; a runtime install never needs them.
|
||||||
|
expect(pkg.dependencies?.exceljs).toBeUndefined();
|
||||||
|
expect(pkg.dependencies?.fflate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepares the vendor bundles for development and production', () => {
|
||||||
|
const postinstall = read('scripts/postinstall.js');
|
||||||
|
const build = read('scripts/build.mjs');
|
||||||
|
const prepare = read('scripts/prepare-spreadsheet-assets.mjs');
|
||||||
|
|
||||||
|
expect(postinstall).toContain('prepare-spreadsheet-assets.mjs');
|
||||||
|
expect(build).toContain('node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
|
||||||
|
expect(prepare).toContain('exceljs.min.js');
|
||||||
|
expect(prepare).toContain('fflate.min.js');
|
||||||
|
expect(prepare).toContain('sourceMappingURL');
|
||||||
|
// Output lands in a gitignored dir, so the ~1 MB never gets committed.
|
||||||
|
expect(read('.gitignore')).toMatch(/^src\/web\/public\/vendor\/$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads only the small renderer on the page; the worker pulls the parser on demand', () => {
|
||||||
|
const html = read('src/web/public/index.html');
|
||||||
|
expect(html).toContain('<script defer src="spreadsheet-preview.js"></script>');
|
||||||
|
for (const lazy of ['spreadsheet-preview-worker.js', 'spreadsheet-xlsx-core.js', 'exceljs', 'fflate']) {
|
||||||
|
expect(html, lazy).not.toContain(lazy);
|
||||||
|
}
|
||||||
|
const sw = read('src/web/public/sw.js');
|
||||||
|
expect(sw).not.toContain('exceljs');
|
||||||
|
|
||||||
|
const worker = read('src/web/public/spreadsheet-preview-worker.js');
|
||||||
|
// ExcelJS loads only after admission, inside loadWorkbook().
|
||||||
|
const admit = worker.indexOf('core.admitXlsx(');
|
||||||
|
const excel = worker.indexOf('vendor/exceljs.min.js');
|
||||||
|
expect(admit).toBeGreaterThan(-1);
|
||||||
|
expect(excel).toBeGreaterThan(admit);
|
||||||
|
|
||||||
|
const build = read('scripts/build.mjs');
|
||||||
|
expect(build).toContain("'spreadsheet-preview.js',");
|
||||||
|
// The worker is a stable URL busted by SPREADSHEET_ASSET_VERSION, not a hashed name.
|
||||||
|
expect(build).not.toContain("'spreadsheet-preview-worker.js'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes an agent-printed .xlsx path to the preview overlay, but not .xls/.ods', () => {
|
||||||
|
const constants = read('src/web/public/constants.js');
|
||||||
|
const previewList = /FILE_PREVIEW_EXTENSIONS = new Set\(\s*\('([^']+)'\)/.exec(constants)?.[1].split(' ') ?? [];
|
||||||
|
expect(previewList).toContain('xlsx');
|
||||||
|
expect(previewList).not.toContain('xls');
|
||||||
|
expect(previewList).not.toContain('ods');
|
||||||
|
expect(constants).toMatch(/\|pptx\|xlsx\|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the combined size budget and the content-derived asset version', () => {
|
||||||
|
const check = read('scripts/check-public-assets.mjs');
|
||||||
|
expect(check).toContain('SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000');
|
||||||
|
expect(check).toContain('SPREADSHEET_ASSET_VERSION');
|
||||||
|
expect(check).toContain('spreadsheet-preview-worker.js');
|
||||||
|
expect(check).toContain('spreadsheet-xlsx-core.js');
|
||||||
|
expect(check).toContain("createHash('sha256')");
|
||||||
|
expect(read('src/web/public/spreadsheet-preview.js')).toMatch(/const SPREADSHEET_ASSET_VERSION = '[a-f0-9]{12}'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every accepted document type in the attachments panel help', () => {
|
||||||
|
expect(DOCUMENT_ATTACHMENT_EXTENSIONS).toContain('xlsx');
|
||||||
|
const help = /<div>Supports ([^<]+)<\/div>/.exec(read('src/web/public/panels-ui.js'))?.[1] || '';
|
||||||
|
for (const extension of DOCUMENT_ATTACHMENT_EXTENSIONS) {
|
||||||
|
expect(isSupportedAttachmentExtension(extension)).toBe(true);
|
||||||
|
expect(help).toContain(`.${extension}`);
|
||||||
|
}
|
||||||
|
// The CLI's refusal text is built from the same list rather than restating it.
|
||||||
|
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
/** @fileoverview XLSX preview in real Chromium: Worker + vendor bundles load same-origin under a strict CSP. */
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { chromium, type Browser } from 'playwright';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const publicRoot = resolve(root, 'src/web/public');
|
||||||
|
let browser: Browser;
|
||||||
|
|
||||||
|
async function workbookBytes(): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const summary = workbook.addWorksheet('Summary');
|
||||||
|
summary.getCell('A1').value = 'Local workbook';
|
||||||
|
summary.getCell('B2').value = 42;
|
||||||
|
summary.getColumn(2).width = 18;
|
||||||
|
summary.getRow(2).height = 30;
|
||||||
|
summary.mergeCells('A3:C3');
|
||||||
|
summary.getCell('A3').value = 'Merged cells';
|
||||||
|
summary.getCell('A100').value = 'Far row';
|
||||||
|
summary.getCell('Z1').value = 'Far column';
|
||||||
|
const details = workbook.addWorksheet('Details');
|
||||||
|
details.getCell('A1').value = 'Second sheet';
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('spreadsheet preview browser boundary', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders through same-origin versioned Worker assets under CSP without external requests', async () => {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 760 }, hasTouch: true });
|
||||||
|
const workbook = await workbookBytes();
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on('request', (request) => requests.push(request.url()));
|
||||||
|
await page.route('https://codeman.test/**', async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'text/html',
|
||||||
|
headers: {
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'self'; script-src 'self'; worker-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'",
|
||||||
|
},
|
||||||
|
body:
|
||||||
|
'<!doctype html><html><head><link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/mobile.css"></head>' +
|
||||||
|
'<body><div id="preview" style="width:370px;height:650px"></div>' +
|
||||||
|
'<script src="/spreadsheet-preview.js"></script><script src="/start.js"></script></body></html>',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/start.js') {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
body: `window.previewHandle = window.CodemanSpreadsheetPreview.open({container:document.querySelector('#preview'),url:'/api/book.xlsx?preview=true',size:${workbook.length}});`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/book.xlsx') {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
body: workbook,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assetPath = resolve(publicRoot, `.${url.pathname}`);
|
||||||
|
if (!assetPath.startsWith(publicRoot)) throw new Error('Unexpected asset path');
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: url.pathname.endsWith('.js')
|
||||||
|
? 'application/javascript'
|
||||||
|
: url.pathname.endsWith('.css')
|
||||||
|
? 'text/css'
|
||||||
|
: 'application/octet-stream',
|
||||||
|
body: readFileSync(assetPath),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('https://codeman.test/');
|
||||||
|
await expect.poll(() => page.locator('.spreadsheet-sheet-tab').count()).toBe(2);
|
||||||
|
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Local workbook' }).count()).toBe(1);
|
||||||
|
const initialGeometry = await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
const cell = grid.querySelector('.spreadsheet-cell');
|
||||||
|
if (!cell) throw new Error('Missing rendered spreadsheet cell');
|
||||||
|
const gridRect = grid.getBoundingClientRect();
|
||||||
|
const cellRect = cell.getBoundingClientRect();
|
||||||
|
return { left: cellRect.left - gridRect.left, top: cellRect.top - gridRect.top };
|
||||||
|
});
|
||||||
|
expect(initialGeometry.left).toBeGreaterThanOrEqual(36);
|
||||||
|
expect(initialGeometry.top).toBeGreaterThanOrEqual(20);
|
||||||
|
// Headings line up with a custom-width column and a custom-height row.
|
||||||
|
const headingFit = await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
const rect = (selector: string, text: string) => {
|
||||||
|
const element = [...grid.querySelectorAll(selector)].find((node) => node.textContent === text);
|
||||||
|
if (!element) throw new Error(`Missing ${selector} ${text}`);
|
||||||
|
return element.getBoundingClientRect();
|
||||||
|
};
|
||||||
|
const cell = rect('.spreadsheet-cell', '42');
|
||||||
|
const column = rect('.spreadsheet-column-heading', 'B');
|
||||||
|
const row = rect('.spreadsheet-row-heading', '2');
|
||||||
|
return {
|
||||||
|
cell: [cell.left, cell.width, cell.top, cell.height],
|
||||||
|
heading: [column.left, column.width, row.top, row.height],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(headingFit.cell[1]).toBe(126);
|
||||||
|
expect(headingFit.cell[3]).toBe(40);
|
||||||
|
expect(headingFit.heading).toEqual(headingFit.cell);
|
||||||
|
await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
grid.scrollTop = 400;
|
||||||
|
grid.scrollLeft = 400;
|
||||||
|
grid.dispatchEvent(new Event('scroll'));
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
const bounds = grid.getBoundingClientRect();
|
||||||
|
return [...grid.querySelectorAll('.spreadsheet-row-heading, .spreadsheet-column-heading')].filter(
|
||||||
|
(heading) => {
|
||||||
|
const rect = heading.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.right > bounds.left &&
|
||||||
|
rect.left < bounds.right &&
|
||||||
|
rect.bottom > bounds.top &&
|
||||||
|
rect.top < bounds.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).length;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await page.getByRole('tab', { name: 'Details' }).click();
|
||||||
|
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Second sheet' }).count()).toBe(1);
|
||||||
|
expect(await page.locator('.spreadsheet-grid').evaluate((element) => getComputedStyle(element).overflow)).toBe(
|
||||||
|
'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
const origins = new Set(requests.map((request) => new URL(request).origin));
|
||||||
|
expect(origins).toEqual(new Set(['https://codeman.test']));
|
||||||
|
expect(requests.some((request) => /spreadsheet-preview-worker\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
|
||||||
|
expect(requests.some((request) => /vendor\/exceljs\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
|
||||||
|
expect(requests.some((request) => /vendor\/fflate\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Spreadsheet renderer lifecycle and virtualization, plus its
|
||||||
|
* wiring into the file-preview overlay (panels-ui.js).
|
||||||
|
*
|
||||||
|
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
|
||||||
|
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
|
||||||
|
* land as text, never markup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
|
||||||
|
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||||
|
|
||||||
|
// A real origin: the renderer resolves its fetch URL against `location.href`.
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const window = dom.window as any;
|
||||||
|
const document = window.document as Document;
|
||||||
|
|
||||||
|
class WorkerMock {
|
||||||
|
static instances: WorkerMock[] = [];
|
||||||
|
onmessage: ((event: { data: any }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onmessageerror: (() => void) | null = null;
|
||||||
|
postMessage = vi.fn();
|
||||||
|
terminate = vi.fn();
|
||||||
|
constructor(readonly url: string) {
|
||||||
|
WorkerMock.instances.push(this);
|
||||||
|
}
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRenderer(fetchMock: ReturnType<typeof vi.fn>) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document,
|
||||||
|
URL,
|
||||||
|
AbortController,
|
||||||
|
Worker: WorkerMock,
|
||||||
|
ResizeObserver: undefined,
|
||||||
|
fetch: fetchMock,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
return (window as any).CodemanSpreadsheetPreview as { open(options: Record<string, unknown>): { dispose(): void } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadata() {
|
||||||
|
return {
|
||||||
|
type: 'metadata',
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '<Summary>',
|
||||||
|
rows: 1_000_000,
|
||||||
|
cols: 100,
|
||||||
|
defaultRowHeight: 20,
|
||||||
|
defaultColumnWidth: 64,
|
||||||
|
rowOverrides: [],
|
||||||
|
columnOverrides: [],
|
||||||
|
},
|
||||||
|
{ id: '2', name: 'Details', rows: 1, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('spreadsheet preview renderer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
WorkerMock.instances = [];
|
||||||
|
document.body.innerHTML = '<div id="preview"></div>';
|
||||||
|
delete (window as any).CodemanSpreadsheetPreview;
|
||||||
|
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => callback(0), 0) as unknown as number;
|
||||||
|
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses oversized metadata before fetch or worker creation', () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 10 * 1024 * 1024 + 1 });
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(WorkerMock.instances).toHaveLength(0);
|
||||||
|
expect(document.body.textContent).toContain('10 MB limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches same-origin preview bytes, transfers them, and bounds rendered cells', async () => {
|
||||||
|
const bytes = new ArrayBuffer(8);
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => bytes }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/api/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith({ type: 'load', bytes }, [bytes]));
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/book.xlsx?preview=true',
|
||||||
|
expect.objectContaining({ signal: expect.anything() })
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.emit(metadata());
|
||||||
|
const tileRequest = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(tileRequest.type).toBe('tile');
|
||||||
|
expect(tileRequest.range.r2 - tileRequest.range.r1).toBeLessThan(100);
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: tileRequest.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: Array.from({ length: 3000 }, (_, index) => ({
|
||||||
|
row: index + 1,
|
||||||
|
col: 1,
|
||||||
|
text: `<b>${index}</b>`,
|
||||||
|
styleId: 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
expect(document.querySelectorAll('.spreadsheet-cell')).toHaveLength(2500);
|
||||||
|
expect(document.querySelector('.spreadsheet-cell')?.textContent).toBe('<b>0</b>');
|
||||||
|
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.top).toBe('20px');
|
||||||
|
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.left).toBe('36px');
|
||||||
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
||||||
|
grid.scrollLeft = 400;
|
||||||
|
grid.scrollTop = 300;
|
||||||
|
grid.dispatchEvent(new window.Event('scroll'));
|
||||||
|
expect((document.querySelector('.spreadsheet-row-heading') as HTMLElement).style.left).toBe('400px');
|
||||||
|
expect((document.querySelector('.spreadsheet-column-heading') as HTMLElement).style.top).toBe('300px');
|
||||||
|
expect(document.querySelector('.spreadsheet-sheet-tabs')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||||
|
expect(document.querySelector('.spreadsheet-grid')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||||
|
expect(Number.parseFloat((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height)).toBe(
|
||||||
|
8_000_020
|
||||||
|
);
|
||||||
|
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
|
||||||
|
expect((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height).toBe('40px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops stale tiles and aborts fetch plus terminates worker on dispose', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
const handle = renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
worker.emit(metadata());
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId - 1,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 1, col: 1, text: 'stale' }],
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).not.toContain('stale');
|
||||||
|
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
||||||
|
handle.dispose();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
expect(worker.terminate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows feature warnings and reports worker failures after readiness', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
worker.emit({ ...metadata(), warnings: ['charts'] });
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: [] });
|
||||||
|
expect(document.body.textContent).toContain('charts');
|
||||||
|
worker.onerror?.();
|
||||||
|
expect(document.body.textContent).toContain('Spreadsheet parser failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bar sits above the grid in a flex column; unbounded, enough warnings
|
||||||
|
// pushed the grid out of view.
|
||||||
|
it('clamps the notice bar height and scrolls its overflow', () => {
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
const rule = /\.spreadsheet-preview-notice\s*\{([^}]*)\}/.exec(css)?.[1] || '';
|
||||||
|
expect(rule).toMatch(/max-height:\s*\d/);
|
||||||
|
expect(rule).toMatch(/overflow-y:\s*auto/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit empty-sheet state without dropping workbook warnings', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
worker.emit({
|
||||||
|
type: 'metadata',
|
||||||
|
warnings: ['charts'],
|
||||||
|
styles: [],
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: '0',
|
||||||
|
name: 'Data',
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
defaultRowHeight: 20,
|
||||||
|
defaultColumnWidth: 64,
|
||||||
|
rowOverrides: [],
|
||||||
|
columnOverrides: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Empty',
|
||||||
|
rows: 0,
|
||||||
|
cols: 0,
|
||||||
|
defaultRowHeight: 20,
|
||||||
|
defaultColumnWidth: 64,
|
||||||
|
rowOverrides: [],
|
||||||
|
columnOverrides: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '0',
|
||||||
|
cells: [{ row: 1, col: 1, text: 'old cell', styleId: 0 }],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain('old cell');
|
||||||
|
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
|
||||||
|
expect(document.body.textContent).toContain('This worksheet is empty.');
|
||||||
|
expect(document.body.textContent).toContain('charts');
|
||||||
|
expect(document.body.textContent).not.toContain('old cell');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizes row and column headings from the same axis math as the cells', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
// Column B 18 wide and row 2 30pt tall, as the worker reports the fixture in
|
||||||
|
// spreadsheet-preview-worker.test.ts; row 3 and column C hidden.
|
||||||
|
worker.emit({
|
||||||
|
type: 'metadata',
|
||||||
|
styles: [],
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Summary',
|
||||||
|
rows: 4,
|
||||||
|
cols: 4,
|
||||||
|
defaultRowHeight: 20,
|
||||||
|
defaultColumnWidth: 64,
|
||||||
|
rowOverrides: [
|
||||||
|
[2, 40],
|
||||||
|
[3, 0],
|
||||||
|
],
|
||||||
|
columnOverrides: [
|
||||||
|
[2, 126],
|
||||||
|
[3, 0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 2, col: 2, text: 'B2', styleId: 0 }],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
const cell = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||||
|
const heading = (selector: string, text: string) =>
|
||||||
|
[...document.querySelectorAll(selector)].find((element) => element.textContent === text) as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
const columnB = heading('.spreadsheet-column-heading', 'B');
|
||||||
|
const row2 = heading('.spreadsheet-row-heading', '2');
|
||||||
|
expect(columnB?.style.width).toBe(cell.style.width);
|
||||||
|
expect(columnB?.style.left).toBe(cell.style.left);
|
||||||
|
expect(row2?.style.height).toBe(cell.style.height);
|
||||||
|
expect(row2?.style.top).toBe(cell.style.top);
|
||||||
|
expect(heading('.spreadsheet-column-heading', 'A')?.style.width).toBe('64px');
|
||||||
|
expect(heading('.spreadsheet-row-heading', '1')?.style.height).toBe('20px');
|
||||||
|
// A hidden row or column has no size, so it gets no heading at all.
|
||||||
|
expect(heading('.spreadsheet-column-heading', 'C')).toBeUndefined();
|
||||||
|
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX only the scroll position may be scaled: dividing every
|
||||||
|
// cell and heading by the scale drew a 1,048,576-row sheet 7.6 px a row.
|
||||||
|
it('lays rows out at their real height on a sheet taller than the scroll cap', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const cellAt = (row: number) => document.querySelector(`.spreadsheet-cell[data-row="${row}"]`) as HTMLElement;
|
||||||
|
const rowHeading = (row: number) =>
|
||||||
|
[...document.querySelectorAll('.spreadsheet-row-heading')].find(
|
||||||
|
(element) => element.textContent === String(row)
|
||||||
|
) as HTMLElement;
|
||||||
|
const px = (value: string) => Number.parseFloat(value);
|
||||||
|
|
||||||
|
const first = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(first.range.r1).toBe(1);
|
||||||
|
const cell = (row: number) => ({ row, col: 1, text: `A${row}`, styleId: 0 });
|
||||||
|
worker.emit({ type: 'tile', requestId: first.requestId, sheetId: '1', cells: [cell(1), cell(2)], warnings: [] });
|
||||||
|
expect(cellAt(1).style.top).toBe('20px');
|
||||||
|
expect(cellAt(1).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(2).style.top) - px(cellAt(1).style.top)).toBe(20);
|
||||||
|
expect(rowHeading(2).style.height).toBe('20px');
|
||||||
|
// At scale 1 the first tile asks for about a viewport of rows, not a scaled one.
|
||||||
|
expect(first.range.r2).toBeLessThan(40);
|
||||||
|
|
||||||
|
// Scrolled to the end, the last row is requested, drawn at its real height,
|
||||||
|
// and ends exactly at the bottom of the scroll area.
|
||||||
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
||||||
|
const spacerHeight = px((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height);
|
||||||
|
grid.scrollTop = spacerHeight - 500;
|
||||||
|
grid.dispatchEvent(new window.Event('scroll'));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(worker.postMessage.mock.calls.at(-1)?.[0].requestId).toBeGreaterThan(first.requestId)
|
||||||
|
);
|
||||||
|
const last = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(last.range.r2).toBe(1_048_576);
|
||||||
|
expect(last.range.r2 - last.range.r1).toBeLessThan(40);
|
||||||
|
worker.emit({ type: 'tile', requestId: last.requestId, sheetId: '1', cells: [cell(1_048_576)], warnings: [] });
|
||||||
|
expect(cellAt(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(rowHeading(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(1_048_576).style.top) + 20).toBeCloseTo(spacerHeight, 6);
|
||||||
|
expect(px(rowHeading(1_048_575).style.top)).toBeCloseTo(px(cellAt(1_048_576).style.top) - 20, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a merge spanning a too-tall sheet inside the scroll area', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 1, col: 1, text: 'whole column', styleId: 0 }],
|
||||||
|
merges: ['A1:A1048576'],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
const merged = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||||
|
const spacerHeight = Number.parseFloat(
|
||||||
|
(document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(merged.style.top) + Number.parseFloat(merged.style.height)).toBeLessThanOrEqual(
|
||||||
|
spacerHeight
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits colour and background together or not at all', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
worker.emit({
|
||||||
|
...metadata(),
|
||||||
|
styles: [
|
||||||
|
{ font: { color: '#000000' }, fill: '#ffffff' },
|
||||||
|
{ font: { bold: true, color: '#000000' }, fill: undefined },
|
||||||
|
{ font: {}, fill: '#ffff00' },
|
||||||
|
{ font: { italic: true }, alignment: 'center' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const css = (document.querySelector('.spreadsheet-preview-shell style') as HTMLElement).textContent || '';
|
||||||
|
expect(css).toContain('.spreadsheet-style-0{color:#000000;background-color:#ffffff}');
|
||||||
|
// A half pair would strand the text on the skin's own background, so drop both.
|
||||||
|
expect(css).toContain('.spreadsheet-style-1{font-weight:700}');
|
||||||
|
expect(css).toContain('.spreadsheet-style-2{}');
|
||||||
|
expect(css).toContain('.spreadsheet-style-3{font-style:italic;text-align:center}');
|
||||||
|
expect(css).not.toContain('background-color:#ffff00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
|
||||||
|
* and that the worker/fetch lifecycle is torn down on close and on re-open.
|
||||||
|
*/
|
||||||
|
describe('file-preview overlay spreadsheet wiring', () => {
|
||||||
|
function loadOverlay(fileContent: Record<string, unknown>) {
|
||||||
|
const handles: Array<{ dispose: ReturnType<typeof vi.fn> }> = [];
|
||||||
|
const open = vi.fn(() => {
|
||||||
|
const handle = { dispose: vi.fn() };
|
||||||
|
handles.push(handle);
|
||||||
|
return handle;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: fileContent }) }));
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
console,
|
||||||
|
escapeHtml: (value: unknown) => String(value ?? ''),
|
||||||
|
CodemanBase: { base: '', url: (p: string) => p },
|
||||||
|
fetch: fetchMock,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
confirm: () => true,
|
||||||
|
document,
|
||||||
|
window: { CodemanSpreadsheetPreview: { open }, addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
});
|
||||||
|
vm.runInContext(panelsSource, context, { filename: 'panels-ui.js' });
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<div id="filePreviewOverlay"></div><div id="filePreviewTitle"></div><div id="filePreviewBody"></div>' +
|
||||||
|
'<div id="filePreviewFooter"></div>';
|
||||||
|
const app = new CodemanApp();
|
||||||
|
app.$ = (id: string) => document.getElementById(id);
|
||||||
|
app.sessions = new Map([['s1', { workingDir: '/work' }]]);
|
||||||
|
app.formatFileSize = () => '8 KB';
|
||||||
|
return { app, open, handles, fetchMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders a workspace xlsx through the renderer, pointed at the confined file-raw route', async () => {
|
||||||
|
const { app, open, handles } = loadOverlay({
|
||||||
|
type: 'spreadsheet',
|
||||||
|
size: 8192,
|
||||||
|
extension: 'xlsx',
|
||||||
|
url: '/api/sessions/s1/file-raw?path=book.xlsx',
|
||||||
|
});
|
||||||
|
await app.openFilePreview('book.xlsx', 's1');
|
||||||
|
const body = document.getElementById('filePreviewBody');
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(open).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ container: body, url: '/api/sessions/s1/file-raw?path=book.xlsx', size: 8192 })
|
||||||
|
);
|
||||||
|
expect(document.getElementById('filePreviewFooter')?.textContent).toContain('xlsx');
|
||||||
|
|
||||||
|
app.closeFilePreview();
|
||||||
|
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disposes the previous spreadsheet when another preview opens, and stale renders stop', async () => {
|
||||||
|
const { app, open, handles } = loadOverlay({
|
||||||
|
type: 'spreadsheet',
|
||||||
|
size: 1,
|
||||||
|
extension: 'xlsx',
|
||||||
|
url: '/api/sessions/s1/file-raw?path=a.xlsx',
|
||||||
|
});
|
||||||
|
await app.openFilePreview('a.xlsx', 's1');
|
||||||
|
const firstIsCurrent = (open.mock.calls[0] as unknown as [{ isCurrent(): boolean }])[0].isCurrent;
|
||||||
|
expect(firstIsCurrent()).toBe(true);
|
||||||
|
await app.openFilePreview('b.xlsx', 's1');
|
||||||
|
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstIsCurrent()).toBe(false);
|
||||||
|
expect(handles[1].dispose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a registered xlsx attachment from its by-id raw route', async () => {
|
||||||
|
const { app, open, fetchMock } = loadOverlay({});
|
||||||
|
await app.openFilePreview('book.xlsx', 's1', 'att-1');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(open).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/sessions/s1/attachments/att-1/raw' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves xls/ods on the download-only binary path', async () => {
|
||||||
|
const { app, open } = loadOverlay({ type: 'binary', size: 10, extension: 'xls' });
|
||||||
|
await app.openFilePreview('old.xls', 's1');
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(document.getElementById('filePreviewBody')?.textContent).toContain('Cannot preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
/** @fileoverview XLSX admission, formatting, and sparse geometry contracts. */
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as fflate from 'fflate';
|
||||||
|
|
||||||
|
type Core = {
|
||||||
|
LIMITS: Record<string, number>;
|
||||||
|
XlsxPreviewError: new (code: string, message: string) => Error & { code: string };
|
||||||
|
inspectZipDirectory(bytes: Uint8Array, limits?: Record<string, number>): { entries: Array<{ name: string }> };
|
||||||
|
admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record<string, number>): unknown;
|
||||||
|
createXmlCounter(
|
||||||
|
name: string,
|
||||||
|
counts: { cells: number; merges: number; styles: number },
|
||||||
|
limits: Record<string, number>
|
||||||
|
): { push(chunk: Uint8Array, final: boolean): void };
|
||||||
|
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
|
||||||
|
excelJsEntryName(name: string): string;
|
||||||
|
parseCellRef(ref: string): { row: number; col: number } | null;
|
||||||
|
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
|
||||||
|
createSparseAxis(count: number, defaultSize: number, overrides: Array<[number, number]>): unknown;
|
||||||
|
axisOffset(axis: unknown, index: number): number;
|
||||||
|
axisIndexAt(axis: unknown, offset: number): number;
|
||||||
|
computeViewport(axis: unknown, offset: number, viewportSize: number, overscan?: number): [number, number];
|
||||||
|
intersectingMerges(merges: string[], range: { r1: number; c1: number; r2: number; c2: number }): string[];
|
||||||
|
formatCellValue(value: unknown, format: string, date1904?: boolean): { text: string; warning?: string };
|
||||||
|
foldWarnings(warnings: string[]): string[];
|
||||||
|
DEFAULT_THEME_PALETTE: string[];
|
||||||
|
INDEXED_PALETTE: string[];
|
||||||
|
parseThemePalette(xml?: string): string[];
|
||||||
|
resolveColor(color: unknown, palette?: string[]): string | undefined;
|
||||||
|
contrastRatio(a: string, b: string): number;
|
||||||
|
ensureContrast(foreground: string, background: string, minRatio?: number): string;
|
||||||
|
resolveCellColors(
|
||||||
|
fillColor: unknown,
|
||||||
|
fontColor: unknown,
|
||||||
|
palette?: string[]
|
||||||
|
): { background?: string; foreground?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
||||||
|
const context = vm.createContext({ Uint8Array, DataView, TextDecoder, Date, Math, Number, String, Object, Map, Set });
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
const core = (context as unknown as { CodemanSpreadsheetXlsxCore: Core }).CodemanSpreadsheetXlsxCore;
|
||||||
|
|
||||||
|
const workbookZip = (sheet = '<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells/></worksheet>') =>
|
||||||
|
fflate.zipSync({
|
||||||
|
'[Content_Types].xml': fflate.strToU8('<Types/>'),
|
||||||
|
'xl/workbook.xml': fflate.strToU8('<workbook><sheets><sheet name="Sheet1"/></sheets></workbook>'),
|
||||||
|
'xl/styles.xml': fflate.strToU8('<styleSheet><cellXfs count="1"><xf/></cellXfs></styleSheet>'),
|
||||||
|
'xl/worksheets/sheet1.xml': fflate.strToU8(sheet),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spreadsheet XLSX core', () => {
|
||||||
|
it('rejects encrypted OLE files, malformed ZIPs, and ZIP64 sentinels', () => {
|
||||||
|
expect(() => core.inspectZipDirectory(Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0]))).toThrowError(/encrypted/i);
|
||||||
|
expect(() => core.inspectZipDirectory(Uint8Array.from([1, 2, 3]))).toThrowError(/malformed/i);
|
||||||
|
|
||||||
|
const zip = workbookZip();
|
||||||
|
const eocd = zip.length - 22;
|
||||||
|
zip[eocd + 10] = 0xff;
|
||||||
|
zip[eocd + 11] = 0xff;
|
||||||
|
expect(() => core.inspectZipDirectory(zip)).toThrowError(/ZIP64/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects divergent local-header and central-directory entry names', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
const localNameLength = new DataView(zip.buffer, zip.byteOffset, zip.byteLength).getUint16(26, true);
|
||||||
|
expect(localNameLength).toBeGreaterThan(0);
|
||||||
|
zip[30] = zip[30] === 120 ? 121 : 120;
|
||||||
|
expect(() => core.inspectZipDirectory(zip)).toThrowError(/names do not match/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects central-directory entries that were not streamed through admission', () => {
|
||||||
|
class IncompleteUnzip {
|
||||||
|
constructor(private readonly onFile: (file: any) => void) {}
|
||||||
|
register() {}
|
||||||
|
push(_bytes: Uint8Array, final: boolean) {
|
||||||
|
if (!final) return;
|
||||||
|
const file: Record<string, any> = {
|
||||||
|
name: '[Content_Types].xml',
|
||||||
|
start: () => file.ondata(null, new Uint8Array(), true),
|
||||||
|
};
|
||||||
|
this.onFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const incomplete = { Unzip: IncompleteUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
|
||||||
|
expect(() => core.admitXlsx(workbookZip(), incomplete)).toThrowError(/not streamed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses actual streamed output for entry, total, ratio, and XML-count limits', () => {
|
||||||
|
const inflated = 'x'.repeat(20_000);
|
||||||
|
const bomb = fflate.zipSync({ 'xl/worksheets/sheet1.xml': fflate.strToU8(inflated) }, { level: 9 });
|
||||||
|
expect(() => core.admitXlsx(bomb, fflate, { maxEntryBytes: 10_000 })).toThrowError(/entry/i);
|
||||||
|
expect(() => core.admitXlsx(bomb, fflate, { maxInflatedBytes: 10_000 })).toThrowError(/inflated/i);
|
||||||
|
expect(() => core.admitXlsx(bomb, fflate, { maxCompressionRatio: 2 })).toThrowError(/compression ratio/i);
|
||||||
|
|
||||||
|
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||||
|
expect(() => core.admitXlsx(workbookZip(cells), fflate, { maxCellsPerSheet: 3 })).toThrowError(/cells/i);
|
||||||
|
expect(() => core.admitXlsx(workbookZip(), fflate, { maxEntries: 2 })).toThrowError(/entries/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects OOXML feature parts and counts worksheets, merges, styles, and cells', () => {
|
||||||
|
const zip = fflate.zipSync({
|
||||||
|
...fflate.unzipSync(
|
||||||
|
workbookZip('<worksheet><sheetData><c r="A1"/></sheetData><mergeCell ref="A1:B2"/></worksheet>')
|
||||||
|
),
|
||||||
|
'xl/charts/chart1.xml': fflate.strToU8('<chart/>'),
|
||||||
|
'xl/externalLinks/externalLink1.xml': fflate.strToU8('<externalLink/>'),
|
||||||
|
});
|
||||||
|
const result = core.admitXlsx(zip, fflate) as {
|
||||||
|
counts: { worksheets: number; cells: number; merges: number; styles: number };
|
||||||
|
features: string[];
|
||||||
|
};
|
||||||
|
// The 2x2 merge costs its four covered cells on top of the one real cell.
|
||||||
|
// `elements` is every start tag across all six parts: 1 + 3 + 3 + 4 + 1 + 1.
|
||||||
|
expect(result.counts).toEqual({ worksheets: 1, cells: 5, rows: 0, merges: 1, styles: 1, elements: 13 });
|
||||||
|
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
|
||||||
|
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
|
||||||
|
const rebuilt = core.buildAdmittedArchive(admission, fflate);
|
||||||
|
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
|
||||||
|
name: string;
|
||||||
|
compressedSize: number;
|
||||||
|
declaredSize: number;
|
||||||
|
}>;
|
||||||
|
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
|
||||||
|
const roundTrip = fflate.unzipSync(rebuilt);
|
||||||
|
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
class DuplicateUnzip {
|
||||||
|
constructor(private readonly onFile: (file: any) => void) {}
|
||||||
|
register() {}
|
||||||
|
push(_bytes: Uint8Array, final: boolean) {
|
||||||
|
if (!final) return;
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
const file: Record<string, any> = {
|
||||||
|
name: 'xl/workbook.xml',
|
||||||
|
start: () => file.ondata(null, new Uint8Array(1), true),
|
||||||
|
};
|
||||||
|
this.onFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
|
||||||
|
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every entry the way JSZip and ExcelJS will see it', () => {
|
||||||
|
expect(core.excelJsEntryName('xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('/xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/./worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl//worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/foo/../worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('../xl/styles.xml')).toBe('xl/styles.xml');
|
||||||
|
expect(core.excelJsEntryName('//xl/styles.xml')).toBe('xl/styles.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/media/')).toBe('xl/media/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses two entries that resolve to the same name', () => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip());
|
||||||
|
const big = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||||
|
for (const twin of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'xl/x/../worksheets/sheet1.xml']) {
|
||||||
|
const zip = fflate.zipSync({ ...entries, [twin]: fflate.strToU8(big) });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate), twin).toThrowError(/same name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts and rebuilds entries under the names ExcelJS will see', () => {
|
||||||
|
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||||
|
const renamed = (name: string) => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip(cells));
|
||||||
|
const sheet = entries['xl/worksheets/sheet1.xml'];
|
||||||
|
delete entries['xl/worksheets/sheet1.xml'];
|
||||||
|
return fflate.zipSync({ ...entries, [name]: sheet });
|
||||||
|
};
|
||||||
|
for (const name of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'a/xl/worksheets/sheet2.xml.x']) {
|
||||||
|
expect(() => core.admitXlsx(renamed(name), fflate, { maxCellsPerSheet: 3 }), name).toThrowError(/cells/i);
|
||||||
|
expect(() => core.admitXlsx(renamed(name), fflate, { maxWorksheets: 0 }), name).toThrowError(/worksheet/i);
|
||||||
|
}
|
||||||
|
const admission = core.admitXlsx(renamed('/xl/./worksheets/sheet1.xml'), fflate) as {
|
||||||
|
entries: Record<string, Uint8Array>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(admission.entries)).toContain('xl/worksheets/sheet1.xml');
|
||||||
|
expect(Object.keys(fflate.unzipSync(core.buildAdmittedArchive(admission, fflate)))).toContain(
|
||||||
|
'xl/worksheets/sheet1.xml'
|
||||||
|
);
|
||||||
|
|
||||||
|
// `/xl/styles.xml` is ExcelJS's `xl/styles.xml`, so its <xf> tags count.
|
||||||
|
const styles = fflate.unzipSync(workbookZip());
|
||||||
|
const xf = styles['xl/styles.xml'];
|
||||||
|
delete styles['xl/styles.xml'];
|
||||||
|
const leading = fflate.zipSync({ ...styles, '/xl/styles.xml': xf });
|
||||||
|
expect(() => core.admitXlsx(leading, fflate, { maxStyles: 0 })).toThrowError(/styles limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges a merged range its full area and refuses one that does not parse', () => {
|
||||||
|
const merged = (ref: string) =>
|
||||||
|
workbookZip(
|
||||||
|
`<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells><mergeCell ref="${ref}"/></mergeCells></worksheet>`
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(merged('A1:CV30000'), fflate)).toThrowError(/cells limit/i);
|
||||||
|
// Reversed corners describe the same area.
|
||||||
|
expect(() => core.admitXlsx(merged('CV30000:A1'), fflate)).toThrowError(/cells limit/i);
|
||||||
|
expect(() => core.admitXlsx(merged('A1:XFE2'), fflate)).toThrowError(/merged range/i);
|
||||||
|
expect(() => core.admitXlsx(merged('not-a-range'), fflate)).toThrowError(/merged range/i);
|
||||||
|
const admitted = core.admitXlsx(merged('A1:J10'), fflate) as { counts: { cells: number } };
|
||||||
|
expect(admitted.counts.cells).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a <col> whose min or max is past the last Excel column', () => {
|
||||||
|
const cols = (attrs: string) =>
|
||||||
|
workbookZip(
|
||||||
|
`<worksheet><cols><col ${attrs} width="9"/></cols><sheetData><row><c r="A1"/></row></sheetData></worksheet>`
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="3000000"'), fflate)).toThrowError(/column max/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="16385" max="16385"'), fflate)).toThrowError(/column min/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="1e9"'), fflate)).toThrowError(/column max/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="16384"'), fflate)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a merge or col tag whole even when a stream chunk boundary cuts through it', () => {
|
||||||
|
// Markup on both sides, so a cut can land 128+ bytes past the tag's start.
|
||||||
|
const pad = '<sheetView workbookViewId="0"/>'.repeat(12);
|
||||||
|
const cases: Array<[string, RegExp]> = [
|
||||||
|
[`<worksheet>${pad}<cols><col min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||||
|
[`<worksheet>${pad}<cols><col x=">" min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||||
|
[`<worksheet>${pad}<mergeCells><mergeCell ref="A1:CV30000"/></mergeCells>${pad}</worksheet>`, /cells limit/i],
|
||||||
|
];
|
||||||
|
for (const [xml, pattern] of cases) {
|
||||||
|
const bytes = fflate.strToU8(xml);
|
||||||
|
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||||
|
const counter = core.createXmlCounter(
|
||||||
|
'xl/worksheets/sheet1.xml',
|
||||||
|
{ cells: 0, merges: 0, styles: 0 },
|
||||||
|
core.LIMITS
|
||||||
|
);
|
||||||
|
expect(() => {
|
||||||
|
counter.push(bytes.subarray(0, cut), false);
|
||||||
|
counter.push(bytes.subarray(cut), true);
|
||||||
|
}, `cut at ${cut}`).toThrowError(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads attributes in order, so a quoted value cannot hide or fake one', () => {
|
||||||
|
const counter = () =>
|
||||||
|
core.createXmlCounter(
|
||||||
|
'xl/worksheets/sheet1.xml',
|
||||||
|
{ cells: 0, merges: 0, styles: 0, rows: 0 } as never,
|
||||||
|
core.LIMITS
|
||||||
|
);
|
||||||
|
const push = (xml: string) => counter().push(fflate.strToU8(xml), true);
|
||||||
|
// A raw `>` or the other quote character is legal inside a value.
|
||||||
|
expect(() => push(`<mergeCell x=' ref="A1"' ref="A1:CV30000"/>`)).toThrowError(/cells limit/i);
|
||||||
|
expect(() => push('<col x=">" min="1" max="3000000"/>')).toThrowError(/column max/i);
|
||||||
|
expect(() => push(`<col x=' max="1"' min="1" max="3000000"/>`)).toThrowError(/column max/i);
|
||||||
|
// Anything the walk cannot read up to `>` is refused, as is a repeated name.
|
||||||
|
expect(() => push('<mergeCell ref="A1:B2" junk/>')).toThrowError(/do not parse/i);
|
||||||
|
expect(() => push('<mergeCell ref="A1" ref="A1:CV30000"/>')).toThrowError(/do not parse/i);
|
||||||
|
expect(() => push('<cols><col min="1" max="3" width="9"></col></cols><mergeCell ref="A1:B2" />')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts every <row>, empty or not, against per-sheet and total caps', () => {
|
||||||
|
const rows = (n: number) => '<worksheet><sheetData>' + '<row r="1"/>'.repeat(n) + '</sheetData></worksheet>';
|
||||||
|
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRowsPerSheet: 3 })).toThrowError(/rows limit/i);
|
||||||
|
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRows: 3 })).toThrowError(/rows limit/i);
|
||||||
|
const admitted = core.admitXlsx(workbookZip(rows(3)), fflate, { maxRowsPerSheet: 3 }) as {
|
||||||
|
counts: { rows: number };
|
||||||
|
};
|
||||||
|
expect(admitted.counts.rows).toBe(3);
|
||||||
|
// <rowBreaks>/<rows...> style names are not rows.
|
||||||
|
expect(
|
||||||
|
(core.admitXlsx(workbookZip('<worksheet><rowBreaks/></worksheet>'), fflate) as { counts: { rows: number } })
|
||||||
|
.counts.rows
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest
|
||||||
|
// index on every eachRow and `sheet.model`, so the index a row CLAIMS is cost.
|
||||||
|
it('refuses a <row r> that is not plain digits in 1-1048576 and a <row> whose attributes do not parse', () => {
|
||||||
|
const sheet = (rowTag: string) =>
|
||||||
|
workbookZip(`<worksheet><sheetData><row r="1"><c r="A1"/></row>${rowTag}</sheetData></worksheet>`);
|
||||||
|
for (const r of ['50000000', '1048577', '0', '1a', '-1', '1e3', ' 2', '']) {
|
||||||
|
expect(() => core.admitXlsx(sheet(`<row r="${r}"/>`), fflate), r).toThrowError(/row index/i);
|
||||||
|
}
|
||||||
|
expect(() => core.admitXlsx(sheet('<row r="2" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||||
|
// A quoted fake `r` cannot stand in for the real one.
|
||||||
|
expect(() => core.admitXlsx(sheet(`<row x=' r="2"' r="50000000"/>`), fflate)).toThrowError(/row index/i);
|
||||||
|
for (const ok of ['<row r="1048576"/>', '<row/>', '<row spans="1:1"><c r="A2"/></row>']) {
|
||||||
|
expect(() => core.admitXlsx(sheet(ok), fflate), ok).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS stores a sheet at `_worksheets[sheetId]`, and the `worksheets`
|
||||||
|
// getter slices and sorts that array, so a large id allocates a huge array.
|
||||||
|
it('refuses a <sheet sheetId> in xl/workbook.xml that is not plain digits or is above the cap', () => {
|
||||||
|
expect(core.LIMITS.maxSheetId).toBe(65535);
|
||||||
|
const book = (sheets: string, name = 'xl/workbook.xml') => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip());
|
||||||
|
delete entries['xl/workbook.xml'];
|
||||||
|
entries[name] = fflate.strToU8(`<workbook><sheets>${sheets}</sheets></workbook>`);
|
||||||
|
return fflate.zipSync(entries);
|
||||||
|
};
|
||||||
|
const one = (id: string) => `<sheet name="S" sheetId="${id}" r:id="rId1"/>`;
|
||||||
|
for (const id of ['30000000', '65536', '1a', '-1', '1e3', ' 1', '']) {
|
||||||
|
expect(() => core.admitXlsx(book(one(id)), fflate), id).toThrowError(/sheetId/i);
|
||||||
|
}
|
||||||
|
// Every <sheet> is read, not just the first; the name is the one ExcelJS sees.
|
||||||
|
expect(() => core.admitXlsx(book(one('1') + one('30000000')), fflate)).toThrowError(/sheetId/i);
|
||||||
|
expect(() => core.admitXlsx(book(one('30000000'), '/xl/./workbook.xml'), fflate)).toThrowError(/sheetId/i);
|
||||||
|
expect(() => core.admitXlsx(book('<sheet name="S" sheetId="1" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||||
|
expect(() => core.admitXlsx(book(`<sheet x=' sheetId="1"' sheetId="30000000"/>`), fflate)).toThrowError(/sheetId/i);
|
||||||
|
// `<sheets>` is the container, never a sheet; an absent sheetId is harmless.
|
||||||
|
expect(() => core.admitXlsx(book(one('1') + one('65535') + '<sheet name="S"/>'), fflate)).not.toThrow();
|
||||||
|
// Only the workbook part is read this way.
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const other = core.createXmlCounter('xl/other.xml', counts as never, core.LIMITS);
|
||||||
|
expect(() => other.push(fflate.strToU8(one('30000000')), true)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS builds an object per element in every part it parses, not only
|
||||||
|
// worksheets: each <r> run and <si> in sharedStrings.xml, each <font>, <fill>
|
||||||
|
// and <border> in styles, comments, drawings, VML and tables.
|
||||||
|
it('budgets every start tag outside xl/media, whatever part it sits in', () => {
|
||||||
|
expect(core.LIMITS.maxElements).toBe(2_000_000);
|
||||||
|
const withPart = (name: string, xml: string) =>
|
||||||
|
fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: fflate.strToU8(xml) });
|
||||||
|
const elementsOf = (zip: Uint8Array) =>
|
||||||
|
(core.admitXlsx(zip, fflate) as { counts: { elements: number } }).counts.elements;
|
||||||
|
const base = elementsOf(workbookZip());
|
||||||
|
const runs = '<sst><si>' + '<r/>'.repeat(50) + '</si></sst>';
|
||||||
|
const fonts = '<styleSheet><fonts>' + '<font/>'.repeat(50) + '</fonts></styleSheet>';
|
||||||
|
const cases: Array<[string, string, number]> = [
|
||||||
|
['xl/sharedStrings.xml', runs, 52],
|
||||||
|
['xl/comments1.xml', '<comments>' + '<comment/>'.repeat(50) + '</comments>', 51],
|
||||||
|
['xl/drawings/vmlDrawing1.vml', '<xml>' + '<v:shape/>'.repeat(50) + '</xml>', 51],
|
||||||
|
// The name ExcelJS sees, so `/xl/./` cannot slip a part past the budget.
|
||||||
|
['/xl/./sharedStrings.xml', runs, 52],
|
||||||
|
];
|
||||||
|
for (const [name, xml, added] of cases) {
|
||||||
|
expect(elementsOf(withPart(name, xml)), name).toBe(base + added);
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
core.admitXlsx(withPart(name, xml), fflate, { maxElements: base + added - 1 });
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect((thrown as { code?: string })?.code, name).toBe('element-limit');
|
||||||
|
}
|
||||||
|
// styles.xml is counted on top of its own <xf>/<numFmt> checks.
|
||||||
|
const styled = fflate.unzipSync(workbookZip());
|
||||||
|
styled['xl/styles.xml'] = fflate.strToU8(fonts);
|
||||||
|
expect(() => core.admitXlsx(fflate.zipSync(styled), fflate, { maxElements: 50 })).toThrowError(/element/i);
|
||||||
|
// Processing instructions, comments and end tags are not elements; a tag
|
||||||
|
// INSIDE a comment is still counted, which errs toward refusing.
|
||||||
|
expect(elementsOf(withPart('xl/other.xml', '<?xml version="1.0"?><!-- c --><a></a><_b/>'))).toBe(base + 2);
|
||||||
|
expect(elementsOf(withPart('xl/other.xml', '<!-- <a> --><a/>'))).toBe(base + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never budgets an xl/media part, which ExcelJS keeps as bytes', () => {
|
||||||
|
// Stored, so the compression-ratio cap stays out of the way.
|
||||||
|
const tags = fflate.strToU8('<r/>'.repeat(500));
|
||||||
|
for (const name of ['xl/media/image1.png', '/xl/./media/image1.jpeg']) {
|
||||||
|
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).not.toThrow();
|
||||||
|
}
|
||||||
|
// A name under xl/media/ that ExcelJS parses as XML (its patterns are
|
||||||
|
// unanchored) is budgeted like any other part.
|
||||||
|
for (const name of ['xl/media/xl/drawings/drawing1.xml', 'xl/media/xl/worksheets/sheet2.xml']) {
|
||||||
|
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).toThrowError(/element/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts start tags exactly when a chunk boundary cuts through one', () => {
|
||||||
|
const xml = '<sst><si>' + '<r/><t>x</t>'.repeat(20) + '</si></sst>';
|
||||||
|
const bytes = fflate.strToU8(xml);
|
||||||
|
for (const name of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml', 'xl/styles.xml']) {
|
||||||
|
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||||
|
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||||
|
const counter = core.createXmlCounter(name, counts as never, core.LIMITS);
|
||||||
|
counter.push(bytes.subarray(0, cut), false);
|
||||||
|
counter.push(bytes.subarray(cut), true);
|
||||||
|
expect(counts.elements, `${name} cut at ${cut}`).toBe(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry long text or binary in a non-worksheet part as an oversized tag', () => {
|
||||||
|
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||||
|
const text = fflate.strToU8('<sst><si><t>' + 'x'.repeat(600_000) + '</t></si></sst>');
|
||||||
|
const strings = core.createXmlCounter('xl/sharedStrings.xml', counts as never, core.LIMITS);
|
||||||
|
const binary = core.createXmlCounter('xl/embeddings/oleObject1.bin', counts as never, core.LIMITS);
|
||||||
|
const zeros = new Uint8Array(600_000);
|
||||||
|
expect(() => {
|
||||||
|
for (let at = 0; at < text.length; at += 65_536) {
|
||||||
|
strings.push(text.subarray(at, at + 65_536), at + 65_536 >= text.length);
|
||||||
|
}
|
||||||
|
for (let at = 0; at < zeros.length; at += 65_536) {
|
||||||
|
binary.push(zeros.subarray(at, at + 65_536), at + 65_536 >= zeros.length);
|
||||||
|
}
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(counts.elements).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts every <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, { ...core.LIMITS, maxStyles: 100 });
|
||||||
|
const xml = '<styleSheet><cellXfs><!-- </cellXfs> -->' + '<xf/>'.repeat(200) + '</cellXfs></styleSheet>';
|
||||||
|
expect(() => counter.push(fflate.strToU8(xml), true)).toThrowError(/styles limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS's `_mergeCellsInternal` checks every new merge against every earlier
|
||||||
|
// one on its sheet, so a sheet costs the SQUARE of its merge count: one sheet
|
||||||
|
// at the old 5,000 cap took 1.6 s, and 20 such sheets ran past the page timeout.
|
||||||
|
it('caps merges per sheet and across the workbook', () => {
|
||||||
|
expect(core.LIMITS.maxMergesPerSheet).toBe(2000);
|
||||||
|
expect(core.LIMITS.maxMerges).toBe(10000);
|
||||||
|
const merges = (n: number, row = 1) =>
|
||||||
|
Array.from({ length: n }, (_, i) => `<mergeCell ref="A${row + i * 2}:A${row + i * 2 + 1}"/>`).join('');
|
||||||
|
const sheetXml = (n: number) => `<worksheet><sheetData/><mergeCells>${merges(n)}</mergeCells></worksheet>`;
|
||||||
|
expect(() => core.admitXlsx(workbookZip(sheetXml(4)), fflate, { maxMergesPerSheet: 3 })).toThrowError(
|
||||||
|
/merged ranges limit/i
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(workbookZip(sheetXml(3)), fflate, { maxMergesPerSheet: 3 })).not.toThrow();
|
||||||
|
// Two sheets each under the per-sheet cap still trip the workbook-wide one.
|
||||||
|
const twoSheets = fflate.zipSync({
|
||||||
|
...fflate.unzipSync(workbookZip(sheetXml(3))),
|
||||||
|
'xl/worksheets/sheet2.xml': fflate.strToU8(sheetXml(3)),
|
||||||
|
});
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||||
|
/merged ranges limit/i
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 6 })).not.toThrow();
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'merge-limit' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS runs `isDateFmt` once per numeric cell, and its first step,
|
||||||
|
// `fmt.replace(/\[[^\]]*]/g, '')`, rescans to the end of the code for every
|
||||||
|
// `[` with no later `]`. The code is also echoed into the notice bar.
|
||||||
|
it('refuses a <numFmt> formatCode over 255 characters or with a [ after its last ]', () => {
|
||||||
|
const styles = (numFmts: string) => {
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||||
|
const xml = `<styleSheet><numFmts count="1">${numFmts}</numFmts><cellXfs count="1"><xf/></cellXfs></styleSheet>`;
|
||||||
|
return () => counter.push(fflate.strToU8(xml), true);
|
||||||
|
};
|
||||||
|
const fmt = (code: string) => `<numFmt numFmtId="164" formatCode="${code}"/>`;
|
||||||
|
expect(styles(fmt('0'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||||
|
expect(styles(fmt('0'.repeat(255)))).not.toThrow();
|
||||||
|
for (const code of ['[', '0[', '[Red]0[', '[[[[', '[Red]0.00;[']) {
|
||||||
|
expect(styles(fmt(code)), code).toThrowError(/unclosed bracket/i);
|
||||||
|
}
|
||||||
|
for (const code of ['[Red]0.00', '[$-409]mmm d, yyyy', '[h]:mm:ss', '0.00', '#,##0;[Red]-#,##0', ']', '[[]']) {
|
||||||
|
expect(styles(fmt(code)), code).not.toThrow();
|
||||||
|
}
|
||||||
|
// The code is checked as ExcelJS decodes it: an entity cannot hide a `[`,
|
||||||
|
// and an entity-heavy code is measured by its decoded length.
|
||||||
|
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles(fmt('"x"'.repeat(60)))).not.toThrow();
|
||||||
|
expect(styles(fmt('&'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||||
|
// Attributes are read in order, so a quoted fake formatCode cannot mask the real one.
|
||||||
|
expect(styles(`<numFmt x=' formatCode="0"' numFmtId="164" formatCode="0["/>`)).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles('<numFmt numFmtId="164" formatCode="0" junk/>')).toThrowError(/do not parse/i);
|
||||||
|
// `<numFmts>` is the container, not a format.
|
||||||
|
expect(styles('')).not.toThrow();
|
||||||
|
// The refusal never echoes the code itself.
|
||||||
|
for (const code of ['QZJX[', `${'QZJX'.repeat(64)}0`]) {
|
||||||
|
expect(styles(fmt(code))).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'number-format', message: expect.not.stringContaining('QZJX') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every <numFmt> in the file is read, wherever it sits (dxfs carry them too).
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||||
|
const dxf = `<styleSheet><dxfs count="1"><dxf>${fmt('0[')}</dxf></dxfs></styleSheet>`;
|
||||||
|
expect(() => counter.push(fflate.strToU8(dxf), true)).toThrowError(/unclosed bracket/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps cell display text at 1,000 characters for every value shape', () => {
|
||||||
|
const long = 'x'.repeat(50_000);
|
||||||
|
const shapes: Array<[string, unknown]> = [
|
||||||
|
['string', long],
|
||||||
|
['rich text', { richText: [{ text: long }, { text: long }] }],
|
||||||
|
['hyperlink', { text: long, hyperlink: 'https://example.invalid/' }],
|
||||||
|
['rich hyperlink', { text: { richText: [{ text: long }] }, hyperlink: 'https://example.invalid/' }],
|
||||||
|
['formula source', { formula: long }],
|
||||||
|
['formula result', { formula: 'A1', result: long }],
|
||||||
|
['error', { error: long }],
|
||||||
|
];
|
||||||
|
for (const [label, value] of shapes) {
|
||||||
|
const { text } = core.formatCellValue(value, 'General');
|
||||||
|
expect(text.length, label).toBeLessThanOrEqual(1000);
|
||||||
|
expect(text.endsWith('…'), label).toBe(true);
|
||||||
|
}
|
||||||
|
expect(core.formatCellValue('y'.repeat(1000), 'General').text).toBe('y'.repeat(1000));
|
||||||
|
expect(core.formatCellValue({ richText: [{ text: 'a' }, { text: 'b' }] }, 'General').text).toBe('ab');
|
||||||
|
// A cut never leaves half of a surrogate pair.
|
||||||
|
// 'aa' puts a high surrogate at index 998, exactly where a naive cut lands.
|
||||||
|
const emoji = core.formatCellValue('aa' + '😀'.repeat(600), 'General').text;
|
||||||
|
expect(emoji.length).toBeLessThanOrEqual(1000);
|
||||||
|
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An empty run adds no text, so stopping on the text length alone still
|
||||||
|
// visited every run, once per cell referencing the shared string, per tile.
|
||||||
|
it('visits at most maxCellTextChars + 1 rich-text runs, however many are empty', () => {
|
||||||
|
const bound = core.LIMITS.maxCellTextChars + 1;
|
||||||
|
const runs: Array<{ text: string }> = Array.from({ length: 5_000 }, () => ({ text: '' }));
|
||||||
|
runs[0] = { text: 'head' };
|
||||||
|
for (let index = bound; index < runs.length; index += 1) {
|
||||||
|
Object.defineProperty(runs, index, {
|
||||||
|
get() {
|
||||||
|
throw new Error(`rich-text run ${index} visited`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(core.formatCellValue({ richText: runs }, 'General')).toEqual({ text: 'head' });
|
||||||
|
// A run inside the bound still contributes.
|
||||||
|
runs[bound - 1] = { text: 'tail' };
|
||||||
|
expect(core.formatCellValue({ richText: runs }, 'General').text).toBe('headtail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an entry whose declared compressed size runs past the file', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
||||||
|
const eocd = zip.length - 22;
|
||||||
|
const centralStart = view.getUint32(eocd + 16, true);
|
||||||
|
view.setUint32(centralStart + 20, 0x7fffffff, true);
|
||||||
|
expect(() => core.inspectZipDirectory(zip)).toThrowError(/compressed bytes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives bounded extents from real cells and merges', () => {
|
||||||
|
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
|
||||||
|
expect(core.parseCellRef('XFE1')).toBeNull();
|
||||||
|
expect(core.deriveExtent(['B3'], ['D5:F9'])).toEqual({ rows: 9, cols: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps sparse axes and virtual viewports without dense allocation', () => {
|
||||||
|
const axis = core.createSparseAxis(1_000_000, 20, [
|
||||||
|
[2, 0],
|
||||||
|
[10, 40],
|
||||||
|
]);
|
||||||
|
expect(core.axisOffset(axis, 3)).toBe(20);
|
||||||
|
expect(core.axisIndexAt(axis, 20)).toBe(3);
|
||||||
|
const [start, end] = core.computeViewport(axis, 199, 60, 1);
|
||||||
|
expect(start).toBeLessThanOrEqual(9);
|
||||||
|
expect(end - start).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a plain walk over the overrides at every index', () => {
|
||||||
|
const overrides: Array<[number, number]> = [];
|
||||||
|
for (let index = 3; index <= 400; index += 7) overrides.push([index, (index * 13) % 50]);
|
||||||
|
const axis = core.createSparseAxis(500, 20, overrides);
|
||||||
|
for (let index = 0; index <= 502; index += 1) {
|
||||||
|
const bounded = Math.max(1, Math.min(501, index));
|
||||||
|
let expected = (bounded - 1) * 20;
|
||||||
|
for (const [at, size] of overrides) if (at < bounded) expected += size - 20;
|
||||||
|
expect(core.axisOffset(axis, index), `index ${index}`).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns intersecting merges even when their anchor is offscreen', () => {
|
||||||
|
expect(core.intersectingMerges(['A1:D4', 'Z1:Z2'], { r1: 3, c1: 3, r2: 6, c2: 6 })).toEqual(['A1:D4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats common values and safely flags unknown formats', () => {
|
||||||
|
expect(core.formatCellValue(0.125, '0.0%').text).toBe('12.5%');
|
||||||
|
expect(core.formatCellValue(1234.5, '#,##0.00').text).toBe('1,234.50');
|
||||||
|
expect(core.formatCellValue(10, '$#,##0.00').text).toBe('$10.00');
|
||||||
|
expect(core.formatCellValue(1, 'yyyy-mm-dd').text).toBe('1900-01-01');
|
||||||
|
expect(core.formatCellValue(0, 'yyyy-mm-dd', true).text).toBe('1904-01-01');
|
||||||
|
expect(core.formatCellValue(0.5, 'hh:mm:ss').text).toBe('12:00:00');
|
||||||
|
expect(core.formatCellValue(1.5, 'yyyy-mm-dd hh:mm').text).toBe('1900-01-01 12:00');
|
||||||
|
expect(core.formatCellValue(7, '[Red][<0]0.0')).toMatchObject({ text: '7', warning: expect.any(String) });
|
||||||
|
expect(core.formatCellValue({ formula: 'SUM(A1:A2)' }, 'General')).toMatchObject({ text: '=SUM(A1:A2)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Excel displays at most 15 significant digits; String() printed the binary noise.
|
||||||
|
it('shows General and unsupported-format numbers at 15 significant digits, as Excel does', () => {
|
||||||
|
expect(core.formatCellValue(0.1 + 0.2, 'General').text).toBe('0.3');
|
||||||
|
expect(core.formatCellValue(10.1 * 3, 'General').text).toBe('30.3');
|
||||||
|
expect(core.formatCellValue({ formula: '0.1+0.2', result: 0.1 + 0.2 }, 'General').text).toBe('0.3');
|
||||||
|
expect(core.formatCellValue(0.1 + 0.2, '0.00E+00')).toEqual({
|
||||||
|
text: '0.3',
|
||||||
|
warning: 'Unsupported number format: 0.00E+00',
|
||||||
|
});
|
||||||
|
expect(core.formatCellValue(1234.5, 'General').text).toBe('1234.5');
|
||||||
|
expect(core.formatCellValue(-42, 'General').text).toBe('-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a time-only AM/PM format as a 12-hour time, not a date', () => {
|
||||||
|
expect(core.formatCellValue(14.5 / 24, 'h:mm AM/PM')).toEqual({ text: '2:30 PM' });
|
||||||
|
expect(core.formatCellValue(0, 'h:mm AM/PM').text).toBe('12:00 AM');
|
||||||
|
expect(core.formatCellValue(0.5, 'h:mm AM/PM').text).toBe('12:00 PM');
|
||||||
|
expect(core.formatCellValue(9.25 / 24, 'hh:mm:ss AM/PM').text).toBe('09:15:00 AM');
|
||||||
|
// ExcelJS loads a time-formatted cell as a Date on the 1899-12-31 epoch day.
|
||||||
|
expect(core.formatCellValue(new Date(Date.UTC(1899, 11, 31, 14, 30)), 'h:mm AM/PM')).toEqual({
|
||||||
|
text: '2:30 PM',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds unsupported number format warnings into one counted entry', () => {
|
||||||
|
const many = Array.from({ length: 800 }, (_, index) => `Unsupported number format: 0.0${'0'.repeat(index)}E+0`);
|
||||||
|
const folded = core.foldWarnings(['charts', ...many, 'Formula has no cached result']);
|
||||||
|
expect(folded).toEqual(['charts', '800 unsupported number formats', 'Formula has no cached result']);
|
||||||
|
expect(core.foldWarnings(['Unsupported number format: 0.00E+00'])).toEqual(['Unsupported number format: 0.00E+00']);
|
||||||
|
expect(core.foldWarnings(['charts'])).toEqual(['charts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// toLocaleString throws a RangeError above 100 fraction digits; Excel caps at 30.
|
||||||
|
it('caps a number format at 30 decimals instead of throwing', () => {
|
||||||
|
expect(core.formatCellValue(1.5, `0.${'0'.repeat(120)}`).text).toBe(`1.5${'0'.repeat(29)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom"><a:themeElements>
|
||||||
|
<a:clrScheme name="Custom">
|
||||||
|
<a:dk1><a:sysClr val="windowText" lastClr="102030"/></a:dk1>
|
||||||
|
<a:lt1><a:sysClr val="window" lastClr="F0F1F2"/></a:lt1>
|
||||||
|
<a:dk2><a:srgbClr val="203040"/></a:dk2>
|
||||||
|
<a:lt2><a:srgbClr val="E0E1E2"/></a:lt2>
|
||||||
|
<a:accent1><a:srgbClr val="FF0000"/></a:accent1>
|
||||||
|
<a:accent2><a:srgbClr val="00FF00"/></a:accent2>
|
||||||
|
<a:accent3><a:srgbClr val="0000FF"/></a:accent3>
|
||||||
|
<a:accent4><a:srgbClr val="010203"/></a:accent4>
|
||||||
|
<a:accent5><a:srgbClr val="040506"/></a:accent5>
|
||||||
|
<a:accent6><a:srgbClr val="070809"/></a:accent6>
|
||||||
|
<a:hlink><a:srgbClr val="123456"/></a:hlink>
|
||||||
|
<a:folHlink><a:srgbClr val="654321"/></a:folHlink>
|
||||||
|
</a:clrScheme></a:themeElements></a:theme>`;
|
||||||
|
|
||||||
|
describe('spreadsheet XLSX colour resolution', () => {
|
||||||
|
// The clrScheme and slot patterns rescan to the end of the text for every
|
||||||
|
// unclosed opening tag, so a padded theme is quadratic.
|
||||||
|
it('uses the default palette for a theme above 64 KB, even a 1 MB run of unclosed clrScheme tags', () => {
|
||||||
|
const padded = '<a:clrScheme>'.repeat(Math.ceil((1024 * 1024) / 13));
|
||||||
|
expect(padded.length).toBeGreaterThanOrEqual(1024 * 1024);
|
||||||
|
const started = performance.now();
|
||||||
|
expect(core.parseThemePalette(padded)).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
expect(performance.now() - started).toBeLessThan(1_000);
|
||||||
|
|
||||||
|
// The cap is on characters: a real theme padded to exactly 64 KB still parses, one more does not.
|
||||||
|
const fill = (length: number) =>
|
||||||
|
themeXml.replace('</a:theme>', `<!--${' '.repeat(length - themeXml.length - 7)}--></a:theme>`);
|
||||||
|
expect(fill(64 * 1024)).toHaveLength(64 * 1024);
|
||||||
|
expect(core.parseThemePalette(fill(64 * 1024))[4]).toBe('#ff0000');
|
||||||
|
expect(core.parseThemePalette(fill(64 * 1024 + 1))).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a theme palette into styles.xml index order, swapping lt/dk against clrScheme order', () => {
|
||||||
|
const palette = core.parseThemePalette(themeXml);
|
||||||
|
expect(palette).toHaveLength(12);
|
||||||
|
// styles.xml order is lt1, dk1, lt2, dk2 while clrScheme lists dk1, lt1, dk2, lt2.
|
||||||
|
expect(palette[0]).toBe('#f0f1f2');
|
||||||
|
expect(palette[1]).toBe('#102030');
|
||||||
|
expect(palette[2]).toBe('#e0e1e2');
|
||||||
|
expect(palette[3]).toBe('#203040');
|
||||||
|
expect(palette.slice(4)).toEqual([
|
||||||
|
'#ff0000',
|
||||||
|
'#00ff00',
|
||||||
|
'#0000ff',
|
||||||
|
'#010203',
|
||||||
|
'#040506',
|
||||||
|
'#070809',
|
||||||
|
'#123456',
|
||||||
|
'#654321',
|
||||||
|
]);
|
||||||
|
expect(core.resolveColor({ theme: 0 }, palette)).toBe('#f0f1f2');
|
||||||
|
expect(core.resolveColor({ theme: 1 }, palette)).toBe('#102030');
|
||||||
|
expect(core.resolveColor({ theme: 12 }, palette)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads sysClr entries from lastClr and srgbClr entries from val', () => {
|
||||||
|
const sysOnly = core.parseThemePalette(
|
||||||
|
themeXml.replace('<a:srgbClr val="203040"/>', '<a:sysClr val="windowText" lastClr="ABCDEF"/>')
|
||||||
|
);
|
||||||
|
expect(sysOnly[3]).toBe('#abcdef');
|
||||||
|
// A sysClr with no lastClr is unusable and must not poison the whole palette.
|
||||||
|
const missing = core.parseThemePalette(
|
||||||
|
themeXml.replace('<a:sysClr val="window" lastClr="F0F1F2"/>', '<a:sysClr val="window"/>')
|
||||||
|
);
|
||||||
|
expect(missing[0]).toBe(core.DEFAULT_THEME_PALETTE[0]);
|
||||||
|
expect(missing[1]).toBe('#102030');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies Excel tint to HSL luminance in both directions', () => {
|
||||||
|
const palette = core.parseThemePalette(themeXml);
|
||||||
|
expect(core.resolveColor({ theme: 4, tint: 0.5 }, palette)).toBe('#ff8080');
|
||||||
|
expect(core.resolveColor({ theme: 4, tint: -0.5 }, palette)).toBe('#800000');
|
||||||
|
expect(core.resolveColor({ theme: 4, tint: 0 }, palette)).toBe('#ff0000');
|
||||||
|
expect(core.resolveColor({ theme: 4, tint: 1 }, palette)).toBe('#ffffff');
|
||||||
|
expect(core.resolveColor({ theme: 4, tint: -1 }, palette)).toBe('#000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default Office palette when theme XML is missing or unparseable', () => {
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE).toHaveLength(12);
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[0]).toBe('#ffffff');
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[1]).toBe('#000000');
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[2]).toBe('#eeece1');
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[3]).toBe('#1f497d');
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[4]).toBe('#4f81bd');
|
||||||
|
expect(core.DEFAULT_THEME_PALETTE[11]).toBe('#800080');
|
||||||
|
expect(core.parseThemePalette('')).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
expect(core.parseThemePalette(undefined)).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
expect(core.parseThemePalette('<html>not a theme</html>')).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
expect(core.resolveColor({ theme: 1 })).toBe('#000000');
|
||||||
|
expect(core.resolveColor({ theme: 0 })).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves legacy indexed palette entries and ignores the auto sentinels', () => {
|
||||||
|
expect(core.INDEXED_PALETTE).toHaveLength(64);
|
||||||
|
expect(core.resolveColor({ indexed: 0 })).toBe('#000000');
|
||||||
|
expect(core.resolveColor({ indexed: 9 })).toBe('#ffffff');
|
||||||
|
expect(core.resolveColor({ indexed: 13 })).toBe('#ffff00');
|
||||||
|
expect(core.resolveColor({ indexed: 22 })).toBe('#c0c0c0');
|
||||||
|
expect(core.resolveColor({ indexed: 63 })).toBe('#333333');
|
||||||
|
expect(core.resolveColor({ indexed: 64 })).toBeUndefined();
|
||||||
|
expect(core.resolveColor({ indexed: 65 })).toBeUndefined();
|
||||||
|
expect(core.resolveColor({ argb: 'FF1F497D' })).toBe('#1f497d');
|
||||||
|
expect(core.resolveColor({ argb: 'nope' })).toBeUndefined();
|
||||||
|
expect(core.resolveColor(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes WCAG contrast ratios and overrides with the correct polarity', () => {
|
||||||
|
expect(core.contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5);
|
||||||
|
expect(core.contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
|
||||||
|
expect(core.contrastRatio('#ffff00', '#ffffff')).toBeLessThan(4.5);
|
||||||
|
// White on yellow is unreadable -> black wins.
|
||||||
|
expect(core.ensureContrast('#ffffff', '#ffff00')).toBe('#000000');
|
||||||
|
// Near-black on black -> white wins.
|
||||||
|
expect(core.ensureContrast('#11111b', '#1a1a2e')).toBe('#ffffff');
|
||||||
|
// Already-legible pairs are preserved verbatim.
|
||||||
|
expect(core.ensureContrast('#1f497d', '#ffffff')).toBe('#1f497d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins an implicit white sheet background for a dark font with no resolvable fill', () => {
|
||||||
|
expect(core.resolveCellColors(undefined, { argb: 'FF000000' })).toEqual({
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
expect(core.resolveCellColors({ indexed: 64 }, { theme: 1 })).toEqual({
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins black text when a fill resolves but the font colour does not', () => {
|
||||||
|
expect(core.resolveCellColors({ theme: 0 }, undefined)).toEqual({
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
expect(core.resolveCellColors({ argb: 'FFFFF8E7' }, undefined)).toEqual({
|
||||||
|
background: '#fff8e7',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides light-on-light and dark-on-dark pairs while keeping legible authored pairs', () => {
|
||||||
|
expect(core.resolveCellColors({ indexed: 13 }, { indexed: 9 })).toEqual({
|
||||||
|
background: '#ffff00',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FFF5F5F5' })).toEqual({
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#000000',
|
||||||
|
});
|
||||||
|
expect(core.resolveCellColors({ argb: 'FF1A1A2E' }, { argb: 'FF11111B' })).toEqual({
|
||||||
|
background: '#1a1a2e',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
});
|
||||||
|
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FF1F497D' })).toEqual({
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#1f497d',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps theme fills and theme fonts instead of dropping them', () => {
|
||||||
|
const palette = core.parseThemePalette(themeXml);
|
||||||
|
expect(core.resolveCellColors({ theme: 4, tint: 0.6 }, { theme: 1 }, palette)).toEqual({
|
||||||
|
background: '#ff9999',
|
||||||
|
foreground: '#102030',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits neither colour when nothing resolves, so the skin tokens stay paired', () => {
|
||||||
|
expect(core.resolveCellColors(undefined, undefined)).toEqual({});
|
||||||
|
expect(core.resolveCellColors({ indexed: 64 }, { theme: 99 })).toEqual({});
|
||||||
|
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview The server's side of the tile grid's SSE filter (live server,
|
* @fileoverview The server's side of the tile grid's SSE filter (live server,
|
||||||
* multi-user mode, port 3287).
|
* multi-user mode, ephemeral port).
|
||||||
*
|
*
|
||||||
* While tiles own the terminal the page subscribes with TILE_GRID_SSE_FILTER
|
* While tiles own the terminal the page subscribes with TILE_GRID_SSE_FILTER
|
||||||
* (constants.js), an id that names no session, so the server sends it no
|
* (constants.js), an id that names no session, so the server sends it no
|
||||||
@@ -31,8 +31,7 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
|||||||
|
|
||||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||||
|
|
||||||
const PORT = 3287;
|
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
|
||||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||||
const alice = { Authorization: basic('alice', 'alicepass1') };
|
const alice = { Authorization: basic('alice', 'alicepass1') };
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ beforeAll(async () => {
|
|||||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||||
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
||||||
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
||||||
server = new WebServer(PORT, false, true);
|
server = new WebServer(0, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
*
|
*
|
||||||
* That contract is load-bearing: assets are served `immutable` for a year, and
|
* That contract is load-bearing: assets are served `immutable` for a year, and
|
||||||
* `index.html` must revalidate every time or a deploy leaves browsers on stale
|
* `index.html` must revalidate every time or a deploy leaves browsers on stale
|
||||||
* markup (see `cacheBustAssets` in server.ts).
|
* markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static
|
||||||
|
* plugin (every page has its own route), so the HTML half is asserted on the route.
|
||||||
*
|
*
|
||||||
* These tests drive a REAL WebServer on purpose. Asserting against an inline
|
* These tests drive a REAL WebServer on purpose. Asserting against an inline
|
||||||
* re-registration of the plugin would keep passing after a revert in server.ts.
|
* re-registration of the plugin would keep passing after a revert in server.ts.
|
||||||
@@ -61,18 +62,6 @@ describe('static asset Cache-Control headers', () => {
|
|||||||
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes static HTML revalidate so deploys are picked up', async () => {
|
|
||||||
// ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that
|
|
||||||
// answers from renderIndexHtml() and never reaches @fastify/static, so asserting
|
|
||||||
// on it passes even with setHeaders fully broken (verified: reverting server.ts
|
|
||||||
// to the v9 form fails the two /app.js tests and leaves an index.html assertion
|
|
||||||
// green). upload.html has no route of its own, so it is the only HTML that
|
|
||||||
// actually exercises the `.html` branch of setHeaders.
|
|
||||||
const res = await get(`${baseUrl}/upload.html`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get('cache-control')).toBe('no-cache');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
|
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
|
||||||
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
|
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
|
||||||
// sendFile() too, and v10 moved it from the raw response onto the reply, which
|
// sendFile() too, and v10 moved it from the raw response onto the reply, which
|
||||||
|
|||||||
@@ -231,4 +231,166 @@ describe('orphaned terminal input recovery wiring', () => {
|
|||||||
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
|
||||||
expect(sent).toEqual([]);
|
expect(sent).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sequence a real Android device logged (SwiftKey in Edge) when autocorrect fired on
|
||||||
|
* space: every key is a keyCode-229 keydown plus a plain `insertText`, then ONE keydown that
|
||||||
|
* deletes five characters and a second that inserts `rompt `. Trusted events through the real
|
||||||
|
* textarea and the real xterm, so `execCommand` produces the same `beforeinput`/`input`
|
||||||
|
* pairs the keyboard does. The byte stream is replayed (DEL erases a character) to get the
|
||||||
|
* line the shell ends up with.
|
||||||
|
*/
|
||||||
|
async function autocorrectOnSpace() {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||||
|
const originalSessionId = app.activeSessionId;
|
||||||
|
const originalLocalEcho = app._localEchoEnabled;
|
||||||
|
const originalSendInput = app._sendInputAsync;
|
||||||
|
const originalPendingInput = app._pendingInput;
|
||||||
|
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||||
|
const sent: string[] = [];
|
||||||
|
const key229 = () => {
|
||||||
|
const down = new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||||
|
textarea.dispatchEvent(down);
|
||||||
|
};
|
||||||
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
try {
|
||||||
|
app.activeSessionId = 'cod388-browser-autocorrect';
|
||||||
|
app._localEchoEnabled = false;
|
||||||
|
app._pendingInput = '';
|
||||||
|
app._lastKeystrokeTime = 0;
|
||||||
|
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
|
||||||
|
textarea.value = '';
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
for (const ch of 'testing the peompt') {
|
||||||
|
key229();
|
||||||
|
document.execCommand('insertText', false, ch);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
// SwiftKey's autocorrect: both edits in one task, before any timer runs.
|
||||||
|
key229();
|
||||||
|
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
|
||||||
|
document.execCommand('delete');
|
||||||
|
key229();
|
||||||
|
document.execCommand('insertText', false, 'rompt ');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
|
||||||
|
const line: string[] = [];
|
||||||
|
for (const ch of sent.join('')) {
|
||||||
|
if (ch === '\x7f') line.pop();
|
||||||
|
else line.push(ch);
|
||||||
|
}
|
||||||
|
return { raw: sent.join(''), line: line.join(''), textarea: textarea.value };
|
||||||
|
} finally {
|
||||||
|
app.activeSessionId = originalSessionId;
|
||||||
|
app._localEchoEnabled = originalLocalEcho;
|
||||||
|
app._sendInputAsync = originalSendInput;
|
||||||
|
app._pendingInput = originalPendingInput;
|
||||||
|
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||||
|
textarea.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('an autocorrect that deletes a word and retypes it reaches the shell once, not duplicated', async () => {
|
||||||
|
const { line, textarea } = await autocorrectOnSpace();
|
||||||
|
expect(textarea).toBe('testing the prompt ');
|
||||||
|
expect(line).toBe('testing the prompt ');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batched Android shape from #441, now with an edit that REWRITES text: the last
|
||||||
|
* character's 229 keydown and insertText land in the same page task as Enter's keydown.
|
||||||
|
* xterm clears the textarea for Enter before the edit-sync timer would run, so a timer
|
||||||
|
* left pending diffed the whole line against '' and sent one DEL per character ahead of
|
||||||
|
* the submitted line (with local echo on, `hello` + Enter submitted `h`). The edit is
|
||||||
|
* settled at the next keydown, before xterm sees it. Run with local echo both ways: it
|
||||||
|
* changes which of the DELs and the `\r` reaches the wire first.
|
||||||
|
*/
|
||||||
|
async function lastEditThenEnter(options: { localEcho: boolean; autocorrect: boolean }) {
|
||||||
|
return page.evaluate(async ({ localEcho, autocorrect }) => {
|
||||||
|
const app = (window as any).app;
|
||||||
|
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||||
|
const originalSessionId = app.activeSessionId;
|
||||||
|
const originalLocalEcho = app._localEchoEnabled;
|
||||||
|
const originalSendInput = app._sendInputAsync;
|
||||||
|
const originalPendingInput = app._pendingInput;
|
||||||
|
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||||
|
const sent: string[] = [];
|
||||||
|
const keydown = (init: KeyboardEventInit, keyCode: number) => {
|
||||||
|
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||||
|
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
|
||||||
|
textarea.dispatchEvent(down);
|
||||||
|
};
|
||||||
|
const key229 = () => keydown({ key: 'Unidentified' }, 229);
|
||||||
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
try {
|
||||||
|
app.activeSessionId = 'cod388-browser-edit-enter';
|
||||||
|
app._localEchoEnabled = localEcho;
|
||||||
|
app._pendingInput = '';
|
||||||
|
app._lastKeystrokeTime = 0;
|
||||||
|
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
|
||||||
|
textarea.value = '';
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
const typed = autocorrect ? 'testing the peompt' : 'hell';
|
||||||
|
for (const ch of typed) {
|
||||||
|
key229();
|
||||||
|
document.execCommand('insertText', false, ch);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
// ONE task: no awaits between the edit(s) and Enter.
|
||||||
|
if (autocorrect) {
|
||||||
|
key229();
|
||||||
|
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
|
||||||
|
document.execCommand('delete');
|
||||||
|
key229();
|
||||||
|
document.execCommand('insertText', false, 'rompt ');
|
||||||
|
} else {
|
||||||
|
key229();
|
||||||
|
document.execCommand('insertText', false, 'o');
|
||||||
|
}
|
||||||
|
keydown({ key: 'Enter', code: 'Enter' }, 13);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250)); // local echo delays the \r by 80 ms
|
||||||
|
|
||||||
|
const line: string[] = [];
|
||||||
|
for (const ch of sent.join('')) {
|
||||||
|
if (ch === '\x7f') line.pop();
|
||||||
|
else line.push(ch);
|
||||||
|
}
|
||||||
|
return { raw: sent.join(''), line: line.join('') };
|
||||||
|
} finally {
|
||||||
|
app.activeSessionId = originalSessionId;
|
||||||
|
app._localEchoEnabled = originalLocalEcho;
|
||||||
|
app._sendInputAsync = originalSendInput;
|
||||||
|
app._pendingInput = originalPendingInput;
|
||||||
|
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||||
|
textarea.value = '';
|
||||||
|
}
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const localEcho of [true, false]) {
|
||||||
|
it(`a 229 last character in the same task as Enter submits the whole line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
|
||||||
|
const { raw, line } = await lastEditThenEnter({ localEcho, autocorrect: false });
|
||||||
|
expect(raw).not.toContain('\x7f');
|
||||||
|
expect(line).toBe('hello\r');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`an autocorrect plus Enter in one task submits the corrected line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
|
||||||
|
const { line } = await lastEditThenEnter({ localEcho, autocorrect: true });
|
||||||
|
expect(line).toBe('testing the prompt \r');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('control: without the edit sync, xterm alone reproduces the duplicated line', async () => {
|
||||||
|
// destroy() puts xterm's own handler back. Keep this LAST: it leaves the controller off.
|
||||||
|
await page.evaluate(() => (window as any).app._keyCode229Recovery.destroy());
|
||||||
|
const { line } = await autocorrectOnSpace();
|
||||||
|
// Byte for byte what the phone sent in the device log.
|
||||||
|
expect(line).toBe('testing the peompttesting the prompt rompt ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -422,3 +422,294 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
|
|||||||
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
|
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xterm's `_handleAnyTextareaChanges` diffs the helper textarea with
|
||||||
|
* `newValue.replace(oldValue, '')`, which only works when the keyboard appended.
|
||||||
|
* Autocorrect on space (SwiftKey, Gboard) deletes the word and inserts the corrected
|
||||||
|
* one, and xterm answers by sending the WHOLE value, then the inserted text again:
|
||||||
|
* a real device log produced `testing the peompttesting the prompt rompt `.
|
||||||
|
*/
|
||||||
|
function editSyncHarness() {
|
||||||
|
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||||
|
const exposed: Record<string, any> = {};
|
||||||
|
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||||
|
|
||||||
|
const textarea = Object.assign(makeTextarea(), { value: '' });
|
||||||
|
const sent: string[] = [];
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
let timerId = 0;
|
||||||
|
// xterm's own (flawed) diff, as shipped, so the control case can prove the bug.
|
||||||
|
const xtermOriginal = function (this: any) {
|
||||||
|
const oldValue = textarea.value;
|
||||||
|
exposed.__timers.set(++timerId, () => {
|
||||||
|
const newValue = textarea.value;
|
||||||
|
const diff = newValue.replace(oldValue, '');
|
||||||
|
if (newValue.length > oldValue.length) sent.push(diff);
|
||||||
|
else if (newValue.length < oldValue.length) sent.push('\x7f');
|
||||||
|
else if (newValue !== oldValue) sent.push(newValue);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
exposed.__timers = timers;
|
||||||
|
const helper = {
|
||||||
|
_isComposing: false,
|
||||||
|
_dataAlreadySent: '',
|
||||||
|
_handleAnyTextareaChanges: xtermOriginal,
|
||||||
|
_coreService: { triggerDataEvent: (data: string) => sent.push(data) },
|
||||||
|
};
|
||||||
|
const create = (withSync: boolean) =>
|
||||||
|
exposed.CodemanKeyCode229Recovery.create({
|
||||||
|
textarea,
|
||||||
|
emitRecovered: (data: string) => sent.push(data),
|
||||||
|
getCompositionHelper: withSync ? () => helper : undefined,
|
||||||
|
setTimer: (callback: () => void) => {
|
||||||
|
const id = ++timerId;
|
||||||
|
timers.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimer: (id: number) => timers.delete(id),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
exposed,
|
||||||
|
helper,
|
||||||
|
textarea,
|
||||||
|
sent,
|
||||||
|
create,
|
||||||
|
/** One keydown, whose xterm timer is registered at keydown time like the real one. */
|
||||||
|
keydown() {
|
||||||
|
helper._handleAnyTextareaChanges();
|
||||||
|
},
|
||||||
|
edit(value: string) {
|
||||||
|
textarea.value = value;
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
for (const [id, callback] of [...timers]) {
|
||||||
|
timers.delete(id);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** What the shell line holds once every DEL has been applied. */
|
||||||
|
line() {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const ch of sent.join('')) {
|
||||||
|
if (ch === '\x7f') out.pop();
|
||||||
|
else out.push(ch);
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('edit-based sync of the helper textarea (autocorrect replacements)', () => {
|
||||||
|
const typeKeys = (h: ReturnType<typeof editSyncHarness>, text: string) => {
|
||||||
|
for (const ch of text) {
|
||||||
|
h.keydown();
|
||||||
|
h.edit(h.textarea.value + ch);
|
||||||
|
h.flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The exact event shape from a device log: per-key appends, then ONE keydown deleting five
|
||||||
|
// characters and a SECOND keydown inserting `rompt `, both landing before any timer runs.
|
||||||
|
const autocorrect = (h: ReturnType<typeof editSyncHarness>) => {
|
||||||
|
typeKeys(h, 'testing the peompt');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('testing the p');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('testing the prompt ');
|
||||||
|
h.flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The batched Android shape (#441): the last character's keydown and `insertText` arrive in the
|
||||||
|
// SAME page task as Enter's keydown. xterm's own Enter handling clears the textarea before the
|
||||||
|
// edit's timer runs, so a timer left pending would diff the whole line against '' and send one
|
||||||
|
// DEL per character AHEAD of the submitted line. The edit is therefore settled at the next
|
||||||
|
// keydown, before xterm sees that key.
|
||||||
|
it('settles a pending edit at the next keydown, so Enter in the same task cannot erase the line', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
const controller = h.create(true);
|
||||||
|
typeKeys(h, 'hell');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('hello');
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||||
|
h.textarea.value = ''; // xterm's CR handling, which runs after the custom key handler
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.join('')).toBe('hello');
|
||||||
|
expect(h.sent).not.toContain('\x7f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autocorrect and Enter in one task submits the corrected line, not a run of DELs', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
const controller = h.create(true);
|
||||||
|
typeKeys(h, 'testing the peompt');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('testing the p');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('testing the prompt ');
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||||
|
h.textarea.value = '';
|
||||||
|
h.flush();
|
||||||
|
expect(h.line()).toBe('testing the prompt ');
|
||||||
|
expect(h.sent.filter((c) => c === '\x7f')).toHaveLength(5); // the five deleted characters, nothing more
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settling first stands the same keystroke’s orphan candidate down (no double send)', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
const controller = h.create(true);
|
||||||
|
// xterm's canonical-data hook, as terminal-ui.js wires it.
|
||||||
|
const origTrigger = h.helper._coreService.triggerDataEvent;
|
||||||
|
h.helper._coreService.triggerDataEvent = (data: string) => {
|
||||||
|
origTrigger(data);
|
||||||
|
controller.notifyCanonicalData();
|
||||||
|
};
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
|
||||||
|
h.keydown();
|
||||||
|
h.edit('o');
|
||||||
|
h.textarea.fire('input', inputEvent('o'));
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.join('')).toBe('o');
|
||||||
|
});
|
||||||
|
|
||||||
|
// compositionend and the Enter keydown in one task: xterm's keydown then finalizes the
|
||||||
|
// composition SYNCHRONOUSLY via `_finalizeComposition(false)`, which ignores `_dataAlreadySent`,
|
||||||
|
// so the settle must leave that text to xterm or it is sent twice.
|
||||||
|
it('does not resend a composition xterm finalizes itself at the Enter keydown', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
const controller = h.create(true);
|
||||||
|
typeKeys(h, 'ab ');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('ab word');
|
||||||
|
(h.helper as any)._isSendingComposition = true;
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.join('')).toBe('ab ');
|
||||||
|
});
|
||||||
|
|
||||||
|
// On the timer path (and at a 229 keydown) xterm finalizes the composition ASYNCHRONOUSLY and
|
||||||
|
// skips `_dataAlreadySent`, so the edit must still be applied there: guarding it would drop the
|
||||||
|
// non-composing `x` typed before the composition.
|
||||||
|
it('still applies the edit while xterm finalizes a composition asynchronously', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
typeKeys(h, 'ab ');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('ab xword');
|
||||||
|
(h.helper as any)._isSendingComposition = true;
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.join('')).toBe('ab xword');
|
||||||
|
expect(h.helper._dataAlreadySent).toBe('xword');
|
||||||
|
|
||||||
|
const k = editSyncHarness();
|
||||||
|
const controller = k.create(true);
|
||||||
|
typeKeys(k, 'ab ');
|
||||||
|
k.keydown();
|
||||||
|
k.edit('ab xword');
|
||||||
|
(k.helper as any)._isSendingComposition = true;
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
|
||||||
|
k.flush();
|
||||||
|
expect(k.sent.join('')).toBe('ab xword');
|
||||||
|
expect(k.helper._dataAlreadySent).toBe('xword');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('control: xterm alone duplicates the line when the keyboard autocorrects', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(false);
|
||||||
|
autocorrect(h);
|
||||||
|
expect(h.sent.join('')).toBe('testing the peompttesting the prompt rompt ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with edit sync the line ends up exactly as the textarea reads', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
autocorrect(h);
|
||||||
|
expect(h.line()).toBe('testing the prompt ');
|
||||||
|
expect(h.sent.filter((s) => s === '\x7f')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain typing is still one chunk per keystroke, and a single delete is one DEL', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
typeKeys(h, 'abc');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('ab');
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent).toEqual(['a', 'b', 'c', '\x7f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a multi-character delete sends one DEL per character, not one DEL in total', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
typeKeys(h, 'hello');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('he');
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.slice(5)).toEqual(['\x7f', '\x7f', '\x7f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an equal-length rewrite is a delete and a retype, not the whole value again', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
typeKeys(h, 'cat');
|
||||||
|
h.keydown();
|
||||||
|
h.edit('cut');
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent.slice(3)).toEqual(['\x7f', '\x7f', 'ut']);
|
||||||
|
expect(h.line()).toBe('cut');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend after xterm clears the textarea (Enter), and counts emoji as one character', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
typeKeys(h, 'hi');
|
||||||
|
h.textarea.value = ''; // xterm's own reset after Enter: no input event, no emission
|
||||||
|
typeKeys(h, 'yo');
|
||||||
|
expect(h.sent).toEqual(['h', 'i', 'y', 'o']);
|
||||||
|
|
||||||
|
const e = editSyncHarness();
|
||||||
|
e.create(true);
|
||||||
|
typeKeys(e, 'a😀');
|
||||||
|
e.keydown();
|
||||||
|
e.edit('a');
|
||||||
|
e.flush();
|
||||||
|
expect(e.sent.slice(2)).toEqual(['\x7f']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays out of the way while composing, and leaves xterm alone without its internals', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
h.create(true);
|
||||||
|
h.helper._isComposing = true;
|
||||||
|
h.keydown();
|
||||||
|
h.edit('x');
|
||||||
|
h.flush();
|
||||||
|
expect(h.sent).toEqual([]);
|
||||||
|
|
||||||
|
const bare = editSyncHarness();
|
||||||
|
const original = bare.helper._handleAnyTextareaChanges;
|
||||||
|
bare.create(false);
|
||||||
|
expect(bare.helper._handleAnyTextareaChanges).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores xterm’s own handler on destroy', () => {
|
||||||
|
const h = editSyncHarness();
|
||||||
|
const original = h.helper._handleAnyTextareaChanges;
|
||||||
|
const controller = h.create(true);
|
||||||
|
expect(h.helper._handleAnyTextareaChanges).not.toBe(original);
|
||||||
|
controller.destroy();
|
||||||
|
expect(h.helper._handleAnyTextareaChanges).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminal-ui.js hands the controller xterm’s composition helper', () => {
|
||||||
|
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
|
||||||
|
expect(terminalSource).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editBetween counts code points and replaces everything after the common prefix', () => {
|
||||||
|
const { editBetween } = editSyncHarness().exposed.CodemanKeyCode229Recovery;
|
||||||
|
expect(editBetween('abc', 'abcd')).toEqual({ deleted: 0, inserted: 'd' });
|
||||||
|
expect(editBetween('abc', 'ab')).toEqual({ deleted: 1, inserted: '' });
|
||||||
|
expect(editBetween('testing the peompt', 'testing the prompt ')).toEqual({ deleted: 5, inserted: 'rompt ' });
|
||||||
|
expect(editBetween('x😀', 'x')).toEqual({ deleted: 1, inserted: '' });
|
||||||
|
expect(editBetween('', '')).toEqual({ deleted: 0, inserted: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,13 +46,20 @@ function loadTerminalUiHarness() {
|
|||||||
return { app: new (CodemanApp as any)(), logs };
|
return { app: new (CodemanApp as any)(), logs };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
|
/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */
|
||||||
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
||||||
const { app, logs } = loadTerminalUiHarness();
|
const { app, logs } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([
|
app.sessions = new Map([
|
||||||
['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }],
|
[
|
||||||
|
'sess-1',
|
||||||
|
{
|
||||||
|
mode: overrides.mode ?? 'claude',
|
||||||
|
cliVersion: overrides.cliVersion,
|
||||||
|
cliMouseTracking: overrides.cliMouseTracking,
|
||||||
|
},
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
@@ -135,7 +142,7 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
|
|||||||
|
|
||||||
describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => {
|
describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => {
|
||||||
it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => {
|
it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => {
|
||||||
const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false
|
const { app, sent } = hollowApp(); // cliVersion unknown → gate false
|
||||||
|
|
||||||
// Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp.
|
// Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp.
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||||||
@@ -149,7 +156,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('accumulates sub-page travel instead of dropping or over-sending it', () => {
|
it('accumulates sub-page travel instead of dropping or over-sending it', () => {
|
||||||
const { app, sent } = hollowClaudeApp();
|
const { app, sent } = hollowApp();
|
||||||
|
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed…
|
||||||
app._flushWheelSgrQueue();
|
app._flushWheelSgrQueue();
|
||||||
@@ -161,15 +168,34 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('caps the keys one gesture batch can emit', () => {
|
it('caps the keys one gesture batch can emit', () => {
|
||||||
const { app, sent } = hollowClaudeApp();
|
const { app, sent } = hollowApp();
|
||||||
|
|
||||||
app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
|
app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel
|
||||||
app._flushWheelSgrQueue();
|
app._flushWheelSgrQueue();
|
||||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
|
||||||
|
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
|
||||||
|
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
|
||||||
|
// one screen exactly like a repaint-mode Claude pane. The difference is that
|
||||||
|
// OpenCode IGNORES SGR wheel reports (verified against an idle pane: six
|
||||||
|
// `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown
|
||||||
|
// — its `messages_page_up/down` binds — is the ONLY gesture that reaches its
|
||||||
|
// transcript. Without this the wheel was silently dead in every OpenCode tab.
|
||||||
|
const { app, sent } = hollowApp({ mode: 'opencode' });
|
||||||
|
|
||||||
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||||||
|
app._flushWheelSgrQueue();
|
||||||
|
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||||||
|
|
||||||
|
app._maybePageCliTranscript({ shiftKey: false }, 18);
|
||||||
|
app._flushWheelSgrQueue();
|
||||||
|
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves every session that has real local scrollback alone', () => {
|
it('leaves every session that has real local scrollback alone', () => {
|
||||||
const { app } = hollowClaudeApp();
|
const { app } = hollowApp();
|
||||||
|
|
||||||
// Shift is the explicit "give me local scrollback" gesture — never paged.
|
// Shift is the explicit "give me local scrollback" gesture — never paged.
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false);
|
expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false);
|
||||||
@@ -179,12 +205,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
app.terminal.buffer.active.baseY = 0;
|
app.terminal.buffer.active.baseY = 0;
|
||||||
|
|
||||||
// Non-Claude modes keep their existing behavior (shell scrolls tmux history
|
// Modes with real terminal scrollback keep their existing behavior (shell/pi
|
||||||
// through the alt-screen strip; codex/gemini page keys are unverified).
|
// own tmux history through the alt-screen strip; codex/gemini/antigravity/…
|
||||||
|
// page-key behaviour is unverified — docs/scrollback-fix-plan.md).
|
||||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]);
|
||||||
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
|
|
||||||
// An alternate-screen pane belongs to xterm's own alt-scroll handling.
|
// An alternate-screen pane belongs to xterm's own alt-scroll handling.
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
@@ -197,7 +226,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
|
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
|
||||||
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
|
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
|
||||||
// Version and tracking both qualify, so the opt-out is the only thing saying no.
|
// Version and tracking both qualify, so the opt-out is the only thing saying no.
|
||||||
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward…
|
const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward…
|
||||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||||
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
|
||||||
@@ -207,7 +236,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('drops travel accumulated on another tab', () => {
|
it('drops travel accumulated on another tab', () => {
|
||||||
const { app, sent } = hollowClaudeApp();
|
const { app, sent } = hollowApp();
|
||||||
|
|
||||||
app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
|
app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page
|
||||||
app.activeSessionId = 'sess-2';
|
app.activeSessionId = 'sess-2';
|
||||||
@@ -228,7 +257,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
|
|
||||||
describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
||||||
it('prints the decision and its inputs once per session, and again when it changes', () => {
|
it('prints the decision and its inputs once per session, and again when it changes', () => {
|
||||||
const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' });
|
const { app, logs } = hollowApp({ cliVersion: '2.1.100' });
|
||||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||||
|
|
||||||
app._logScrollRouting('local-scrollback');
|
app._logScrollRouting('local-scrollback');
|
||||||
@@ -255,7 +284,7 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
|
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
|
||||||
const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed
|
const { app, logs } = hollowApp(); // no cliVersion — the probe failed
|
||||||
app._logScrollRouting('page-keys');
|
app._logScrollRouting('page-keys');
|
||||||
expect(logs[0]).toContain('cliVersion=unknown');
|
expect(logs[0]).toContain('cliVersion=unknown');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never hand-reports for a shell session, even with tracking somehow set', () => {
|
it('follows the server flag alone, with no mode list of its own', () => {
|
||||||
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns
|
// A shell's DECSETs are not stripped, so xterm's own encoder owns the mouse there
|
||||||
// the mouse there and a second, hand-encoded report would double-report.
|
// and a hand-encoded report would double-report. That is kept by the SERVER never
|
||||||
|
// setting the flag for a non-stripping mode (pinned in claude-scrollback-strip.test.ts),
|
||||||
|
// not by a mode check here: the browser reads only the flag.
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]);
|
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||||
|
|
||||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'some-future-cli', cliMouseTracking: true }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hand-reports for opencode, whose DECSETs the server now strips', () => {
|
||||||
|
// opencode's TUI enables mouse tracking, tmux passes the DECSETs through, and
|
||||||
|
// xterm used to report DRAGS to the TUI instead of selecting — so marking text
|
||||||
|
// copied nothing. The server strips them now (isMuxMouseStripMode), which makes
|
||||||
|
// the hand-encoded tap the only way a click still reaches opencode.
|
||||||
|
const { app } = loadTerminalUiHarness();
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'opencode' }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'opencode', cliMouseTracking: true }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static guard: a test builds `WebServer` on an ephemeral port.
|
||||||
|
*
|
||||||
|
* A fixed port is a red suite on any machine where something else holds it, and a
|
||||||
|
* collision between two runs on one host (two worktrees, or CI plus a local run): the
|
||||||
|
* suite runs files serially (`fileParallelism: false`), so the four port pairs #440
|
||||||
|
* found never met inside one run, only across runs. `new WebServer(0, …)` binds
|
||||||
|
* whatever the OS hands out and `boundPort` reads it back, so there is nothing left
|
||||||
|
* to collide on.
|
||||||
|
*
|
||||||
|
* A WebServer built under test/ whose port argument is not the literal `0` fails —
|
||||||
|
* `new WebServer(…)`, a class declared `extends WebServer`, or a destructured alias
|
||||||
|
* (`{ WebServer: T }`) — unless the file is in LEGACY_FIXED_PORT_FILES, the files that
|
||||||
|
* construct one with a non-zero port today (a few never call `start()`); the follow-up
|
||||||
|
* sweep converts them. A converted file cannot stay listed: an entry whose file no
|
||||||
|
* longer matches fails too.
|
||||||
|
*
|
||||||
|
* Not covered: a helper that takes the port as a parameter is checked at the helper,
|
||||||
|
* not at its callers (test/mobile/helpers/server.ts is listed, so the mobile tests
|
||||||
|
* calling `createTestServer(PORT)` are not checked), `import { WebServer as X }`, and
|
||||||
|
* `new mod.WebServer(…)`. Raw `listen({ port: N })` and `new WebSocketServer({ port: N })`
|
||||||
|
* belong to the sweep.
|
||||||
|
*
|
||||||
|
* Port: N/A (pure static analysis).
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, sep } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const TEST_ROOT = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
/** Predates the guard; converted in the follow-up sweep. Shrink only. */
|
||||||
|
const LEGACY_FIXED_PORT_FILES = new Set(
|
||||||
|
[
|
||||||
|
'admin-routes.test.ts',
|
||||||
|
'base-path-server.test.ts',
|
||||||
|
'capture-geometry-retry.browser.test.ts',
|
||||||
|
'capture-load-window.browser.test.ts',
|
||||||
|
'case-custom-path.browser.test.ts',
|
||||||
|
'doctor-settings.browser.test.ts',
|
||||||
|
'edge-cases.test.ts',
|
||||||
|
'file-link-click.test.ts',
|
||||||
|
'git-status.browser.test.ts',
|
||||||
|
'hooks-config.test.ts',
|
||||||
|
'http-contract.test.ts',
|
||||||
|
'inline-rename.test.ts',
|
||||||
|
'integration-flows.test.ts',
|
||||||
|
'key-tester.browser.test.ts',
|
||||||
|
'mobile/helpers/server.ts',
|
||||||
|
'opencode-resize.test.ts',
|
||||||
|
'operation-lightspeed.test.ts',
|
||||||
|
'ownership-scoping.test.ts',
|
||||||
|
'pane-exit-sweep.test.ts',
|
||||||
|
'paste-image-dir-shared.test.ts',
|
||||||
|
'perf-browser.test.ts',
|
||||||
|
'quick-start.test.ts',
|
||||||
|
'ralph-integration.test.ts',
|
||||||
|
'scheduled-runs.test.ts',
|
||||||
|
'security-regression.test.ts',
|
||||||
|
'session-cleanup.test.ts',
|
||||||
|
'session-pane-exit.test.ts',
|
||||||
|
'session.test.ts',
|
||||||
|
'shift-enter-keypress.browser.test.ts',
|
||||||
|
'split-pane-auto-collapse.browser.test.ts',
|
||||||
|
'split-pane-orchestration.browser.test.ts',
|
||||||
|
'split-pane-terminal.browser.test.ts',
|
||||||
|
'sse-cors-headers.test.ts',
|
||||||
|
'sse-events.test.ts',
|
||||||
|
'sse-routing-remote.test.ts',
|
||||||
|
'sse-subscription-filter.test.ts',
|
||||||
|
'static-cache-headers.test.ts',
|
||||||
|
'terminal-copy-shortcut.test.ts',
|
||||||
|
'terminal-keycode229-recovery.browser.test.ts',
|
||||||
|
'webgl-fallback.test.ts',
|
||||||
|
'webhook-settings.browser.test.ts',
|
||||||
|
'webview-lost-root-frame.test.ts',
|
||||||
|
'webview-sse.test.ts',
|
||||||
|
].map((p) => p.split('/').join(sep))
|
||||||
|
);
|
||||||
|
|
||||||
|
function testFiles(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const full = join(dir, name);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
if (name !== 'node_modules') out.push(...testFiles(full));
|
||||||
|
} else if (name.endsWith('.ts')) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First argument of every `new WebServer(` in `source`, trimmed (may span lines) — and of
|
||||||
|
* every `new X(` where the same file makes X a WebServer: `class X extends WebServer`, or
|
||||||
|
* a destructured alias `{ WebServer: X }` (how `quick-start.test.ts` builds its server).
|
||||||
|
*/
|
||||||
|
function webServerPortArgs(source: string): string[] {
|
||||||
|
const classes = [
|
||||||
|
'WebServer',
|
||||||
|
...[...source.matchAll(/class\s+(\w+)\s+extends\s+WebServer\b/g)].map((m) => m[1]),
|
||||||
|
...[...source.matchAll(/\bWebServer\s*:\s*(\w+)/g)].map((m) => m[1]),
|
||||||
|
];
|
||||||
|
return classes.flatMap((name) =>
|
||||||
|
[...source.matchAll(new RegExp(`new ${name}\\(\\s*([^,)]*)`, 'g'))].map((m) => m[1].trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELF = fileURLToPath(import.meta.url);
|
||||||
|
const scanned = testFiles(TEST_ROOT)
|
||||||
|
.filter((f) => f !== SELF)
|
||||||
|
.map((file) => ({ rel: relative(TEST_ROOT, file), args: webServerPortArgs(readFileSync(file, 'utf8')) }));
|
||||||
|
const fixed = (args: string[]) => args.some((a) => a !== '0');
|
||||||
|
|
||||||
|
describe('test servers bind an ephemeral port', () => {
|
||||||
|
it('reads the first argument the way a reader would', () => {
|
||||||
|
expect(webServerPortArgs('new WebServer(0, false, true)')).toEqual(['0']);
|
||||||
|
expect(webServerPortArgs('new WebServer(\n PORT,\n false)')).toEqual(['PORT']);
|
||||||
|
expect(webServerPortArgs('new WebServer(3162, false)')).toEqual(['3162']);
|
||||||
|
expect(webServerPortArgs('new WebServer()')).toEqual(['']);
|
||||||
|
expect(webServerPortArgs('class T extends WebServer {}\nconst s = new T(3299, false);')).toEqual(['3299']);
|
||||||
|
expect(webServerPortArgs('const { WebServer: T } = mod;\nreturn new T(port, false);')).toEqual(['port']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no test outside the legacy list builds WebServer on a fixed port', () => {
|
||||||
|
const offenders = scanned
|
||||||
|
.filter((f) => fixed(f.args) && !LEGACY_FIXED_PORT_FILES.has(f.rel))
|
||||||
|
.map(
|
||||||
|
(f) =>
|
||||||
|
`${f.rel}: new WebServer(${f.args.find((a) => a !== '0')}, …) — use new WebServer(0, …) and server.boundPort`
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the legacy list only names files that still need converting', () => {
|
||||||
|
const stale = [...LEGACY_FIXED_PORT_FILES].filter((rel) => !scanned.some((f) => f.rel === rel && fixed(f.args)));
|
||||||
|
expect(stale).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview `new WebServer(0, …)`: the OS picks the port and every reader of
|
||||||
|
* `this.port` sees that number, not the 0 that was asked for (#440).
|
||||||
|
*
|
||||||
|
* Port: ephemeral.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
describe('WebServer on an ephemeral port', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
/** Built BEFORE start(), as setupRoutes() builds it, so a by-value snapshot would read 0. */
|
||||||
|
let ctx: { port: number };
|
||||||
|
const savedApiUrl = process.env.CODEMAN_API_URL;
|
||||||
|
/** The socket's own answer, so each test stands on its own instead of trusting boundPort. */
|
||||||
|
const socketPort = () =>
|
||||||
|
((server as unknown as { app: { server: { address(): AddressInfo } } }).app.server.address() as AddressInfo).port;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(0, false, true);
|
||||||
|
ctx = (server as unknown as { createRouteContext(): { port: number } }).createRouteContext();
|
||||||
|
await server.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop();
|
||||||
|
if (savedApiUrl === undefined) delete process.env.CODEMAN_API_URL;
|
||||||
|
else process.env.CODEMAN_API_URL = savedApiUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the port the OS assigned', async () => {
|
||||||
|
expect(socketPort()).toBeGreaterThan(0);
|
||||||
|
expect(server.boundPort).toBe(socketPort());
|
||||||
|
const res = await fetch(`http://127.0.0.1:${server.boundPort}/api/status`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the route context reads it live (the tunnel start gets the real port, not 0)', () => {
|
||||||
|
expect(ctx.port).toBe(socketPort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports the real port to the panes as CODEMAN_API_URL', () => {
|
||||||
|
expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,12 +47,39 @@ describe('xterm private-API dependency guard', () => {
|
|||||||
expect(
|
expect(
|
||||||
lock.packages['node_modules/@xterm/xterm']?.version,
|
lock.packages['node_modules/@xterm/xterm']?.version,
|
||||||
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
|
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
|
||||||
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame), then update ' +
|
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' +
|
||||||
'VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
|
'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' +
|
||||||
|
'_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent), ' +
|
||||||
|
'then update VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
|
||||||
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
|
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
|
||||||
).toBe(VERIFIED_XTERM_VERSION);
|
).toBe(VERIFIED_XTERM_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// terminal-keycode229-recovery.js swaps in an edit-based replacement for xterm's
|
||||||
|
// CompositionHelper._handleAnyTextareaChanges (Android autocorrect = delete + insert, which xterm's
|
||||||
|
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`,
|
||||||
|
// `_isSendingComposition` and `_dataAlreadySent`; if xterm renames any of them the install quietly
|
||||||
|
// falls back to xterm's own handler and the duplication returns (or, for `_isSendingComposition`, a
|
||||||
|
// composition xterm finalizes at an Enter keydown is sent twice). Property names survive
|
||||||
|
// minification, so a string check on the shipped bundle catches a rename on upgrade.
|
||||||
|
it('still ships the composition-helper fields the edit-based 229 sync depends on', () => {
|
||||||
|
const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
|
||||||
|
for (const name of [
|
||||||
|
'_handleAnyTextareaChanges',
|
||||||
|
'_compositionHelper',
|
||||||
|
'_coreService',
|
||||||
|
'_isComposing',
|
||||||
|
'_isSendingComposition',
|
||||||
|
'_dataAlreadySent',
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
bundle,
|
||||||
|
`xterm no longer mentions ${name}: re-verify terminal-keycode229-recovery.js installEditSync() ` +
|
||||||
|
'(src/web/public) against the new CompositionHelper before bumping VERIFIED_XTERM_VERSION'
|
||||||
|
).toContain(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// If someone deletes the watchdog, this guard is pointless noise — keep the
|
// If someone deletes the watchdog, this guard is pointless noise — keep the
|
||||||
// two tied together so the range check cannot outlive what it protects.
|
// two tied together so the range check cannot outlive what it protects.
|
||||||
it('is guarding a watchdog that still exists', () => {
|
it('is guarding a watchdog that still exists', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user