mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
Merge pull request #168 from shenlvkang-collab/contrib/mobile-path-picker-preview
feat(mobile): add filesystem path picker and document/image previews
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(mobile): browse and insert local file and folder paths
|
||||||
|
|
||||||
|
Add a root-confined filesystem picker to Link Existing and the extended mobile
|
||||||
|
keyboard bar. Selected paths remain editable at the active prompt, supported
|
||||||
|
images/documents/text files open in a safe inline preview, and a new one-tap
|
||||||
|
action clears only the current unsent input without invoking `/clear`.
|
||||||
@@ -211,6 +211,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||||
|
|
||||||
|
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and does not inherit the attachment confinement** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||||
|
|
||||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||||
|
|
||||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||||
@@ -281,7 +283,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
|
|
||||||
~197 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
~199 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||||
|
|
||||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
|
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
|
||||||
|
|
||||||
|
### Filesystem path picker
|
||||||
|
|
||||||
|
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to `/mnt/d`), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||||
|
|
||||||
|
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
|
||||||
|
|
||||||
### Ultracode and workflow-run visualization
|
### Ultracode and workflow-run visualization
|
||||||
|
|
||||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||||
|
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +69,34 @@ export interface ProcessStats {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A selectable entry returned by the filesystem path-picker API. */
|
||||||
|
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||||
|
|
||||||
|
export interface FilesystemBrowseEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size?: number;
|
||||||
|
symlink?: boolean;
|
||||||
|
previewKind?: FilesystemPreviewKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A named root the path picker may browse without escaping its allowlist. */
|
||||||
|
export interface FilesystemBrowseRoot {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response payload for `GET /api/filesystem/browse`. */
|
||||||
|
export interface FilesystemBrowseData {
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
root: string;
|
||||||
|
roots: FilesystemBrowseRoot[];
|
||||||
|
entries: FilesystemBrowseEntry[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2043,8 +2043,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Folder Path</label>
|
<label>Folder Path</label>
|
||||||
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
<div class="path-input-group">
|
||||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||||
|
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse…</button>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Remote Tab -->
|
<!-- Remote Tab -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||||
*
|
*
|
||||||
* Defines two exports:
|
* Defines three exports:
|
||||||
*
|
*
|
||||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||||
@@ -10,12 +10,15 @@
|
|||||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||||
|
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||||
|
* by Link Existing and the extended mobile keyboard bar.
|
||||||
*
|
*
|
||||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||||
* and other modal dialogs.
|
* and other modal dialogs.
|
||||||
*
|
*
|
||||||
* @globals {object} KeyboardAccessoryBar
|
* @globals {object} KeyboardAccessoryBar
|
||||||
|
* @globals {object} PathPicker
|
||||||
* @globals {class} FocusTrap
|
* @globals {class} FocusTrap
|
||||||
*
|
*
|
||||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||||
@@ -26,6 +29,338 @@
|
|||||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||||
// Loaded after mobile-handlers.js, before app.js
|
// Loaded after mobile-handlers.js, before app.js
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Shared Filesystem Path Picker
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const PathPicker = {
|
||||||
|
overlay: null,
|
||||||
|
_options: null,
|
||||||
|
_selectedPath: '',
|
||||||
|
_previousFocus: null,
|
||||||
|
_keydownHandler: null,
|
||||||
|
_loadSequence: 0,
|
||||||
|
_previewOverlay: null,
|
||||||
|
_previewRequestSequence: 0,
|
||||||
|
_previewPreviousFocus: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the lazy filesystem browser.
|
||||||
|
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||||
|
* title?: string, onSelect: (path: string) => void}} options
|
||||||
|
*/
|
||||||
|
open(options) {
|
||||||
|
this.close(false);
|
||||||
|
this._options = options;
|
||||||
|
this._selectedPath = '';
|
||||||
|
this._previousFocus = document.activeElement;
|
||||||
|
this._previousFocus?.blur?.();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'path-picker-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', options.title || 'Select a path');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="path-picker-dialog">
|
||||||
|
<div class="path-picker-header">
|
||||||
|
<strong class="path-picker-title"></strong>
|
||||||
|
<button type="button" class="path-picker-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="path-picker-roots-row">
|
||||||
|
<label for="pathPickerRoot">Location</label>
|
||||||
|
<select id="pathPickerRoot" class="path-picker-roots"></select>
|
||||||
|
</div>
|
||||||
|
<div class="path-picker-nav">
|
||||||
|
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||||
|
<div class="path-picker-current" title="Current folder"></div>
|
||||||
|
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||||
|
</div>
|
||||||
|
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||||
|
<div class="path-picker-list" role="listbox"></div>
|
||||||
|
<div class="path-picker-selection">
|
||||||
|
<span class="path-picker-selection-label">Selected</span>
|
||||||
|
<span class="path-picker-selection-value">None</span>
|
||||||
|
</div>
|
||||||
|
<div class="path-picker-actions">
|
||||||
|
<button type="button" class="path-picker-current-select">Select Current Folder</button>
|
||||||
|
<span class="path-picker-action-spacer"></span>
|
||||||
|
<button type="button" class="path-picker-cancel">Cancel</button>
|
||||||
|
<button type="button" class="path-picker-confirm" disabled>Select</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
this.overlay = overlay;
|
||||||
|
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
|
||||||
|
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
|
||||||
|
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||||
|
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||||
|
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||||
|
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||||
|
if (current) this.select(current);
|
||||||
|
});
|
||||||
|
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||||
|
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||||
|
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||||
|
if (parent) this.load(parent);
|
||||||
|
});
|
||||||
|
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) this.close(true);
|
||||||
|
});
|
||||||
|
this._keydownHandler = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this._previewOverlay) this.closePreview(true);
|
||||||
|
else this.close(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this._keydownHandler);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this.load(options.initialPath || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
async load(path) {
|
||||||
|
if (!this.overlay || !this._options) return;
|
||||||
|
const loadSequence = ++this._loadSequence;
|
||||||
|
const list = this.overlay.querySelector('.path-picker-list');
|
||||||
|
const status = this.overlay.querySelector('.path-picker-status');
|
||||||
|
list.replaceChildren();
|
||||||
|
status.textContent = 'Loading...';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (path) params.set('path', path);
|
||||||
|
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||||
|
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||||
|
this.render(result.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||||
|
if (path) {
|
||||||
|
this.load('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = error.message || 'Failed to browse this folder';
|
||||||
|
status.classList.add('error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
const rootSelect = this.overlay.querySelector('.path-picker-roots');
|
||||||
|
rootSelect.replaceChildren();
|
||||||
|
for (const root of data.roots) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = root.path;
|
||||||
|
option.textContent = `${root.label} — ${root.path}`;
|
||||||
|
option.selected = data.path === root.path || data.root === root.path;
|
||||||
|
rootSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||||
|
const up = this.overlay.querySelector('.path-picker-up');
|
||||||
|
up.dataset.parent = data.parent || '';
|
||||||
|
up.disabled = !data.parent;
|
||||||
|
const status = this.overlay.querySelector('.path-picker-status');
|
||||||
|
status.classList.remove('error');
|
||||||
|
status.textContent = data.entries.length === 0
|
||||||
|
? 'This folder is empty'
|
||||||
|
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||||
|
|
||||||
|
const list = this.overlay.querySelector('.path-picker-list');
|
||||||
|
list.replaceChildren();
|
||||||
|
for (const entry of data.entries) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'path-picker-item';
|
||||||
|
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||||
|
row.classList.add('not-selectable');
|
||||||
|
}
|
||||||
|
row.dataset.path = entry.path;
|
||||||
|
row.dataset.type = entry.type;
|
||||||
|
row.setAttribute('role', 'option');
|
||||||
|
|
||||||
|
const open = document.createElement('button');
|
||||||
|
open.type = 'button';
|
||||||
|
open.className = 'path-picker-item-main';
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'path-picker-item-icon';
|
||||||
|
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'path-picker-item-name';
|
||||||
|
name.textContent = entry.name;
|
||||||
|
open.append(icon, name);
|
||||||
|
if (entry.symlink) {
|
||||||
|
const link = document.createElement('span');
|
||||||
|
link.className = 'path-picker-item-link';
|
||||||
|
link.textContent = '\u2197';
|
||||||
|
open.appendChild(link);
|
||||||
|
}
|
||||||
|
if (entry.type === 'directory') {
|
||||||
|
const chevron = document.createElement('span');
|
||||||
|
chevron.className = 'path-picker-item-chevron';
|
||||||
|
chevron.textContent = '\u203A';
|
||||||
|
open.appendChild(chevron);
|
||||||
|
open.addEventListener('click', () => this.load(entry.path));
|
||||||
|
} else if (entry.previewKind) {
|
||||||
|
const preview = document.createElement('span');
|
||||||
|
preview.className = 'path-picker-item-preview';
|
||||||
|
preview.textContent = '\uD83D\uDC41';
|
||||||
|
open.appendChild(preview);
|
||||||
|
open.title = `Preview ${entry.name}`;
|
||||||
|
open.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||||
|
open.addEventListener('click', () => this.openPreview(entry));
|
||||||
|
} else if (!this._options.directoriesOnly) {
|
||||||
|
open.addEventListener('click', () => this.select(entry.path));
|
||||||
|
} else {
|
||||||
|
open.disabled = true;
|
||||||
|
}
|
||||||
|
row.appendChild(open);
|
||||||
|
|
||||||
|
if (entry.type === 'directory' || !this._options.directoriesOnly) {
|
||||||
|
const choose = document.createElement('button');
|
||||||
|
choose.type = 'button';
|
||||||
|
choose.className = 'path-picker-item-select';
|
||||||
|
choose.textContent = 'Choose';
|
||||||
|
choose.addEventListener('click', () => this.select(entry.path));
|
||||||
|
row.appendChild(choose);
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
select(path) {
|
||||||
|
if (!this.overlay) return;
|
||||||
|
this._selectedPath = path;
|
||||||
|
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
|
||||||
|
this.overlay.querySelector('.path-picker-confirm').disabled = false;
|
||||||
|
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
|
||||||
|
const selected = row.dataset.path === path;
|
||||||
|
row.classList.toggle('selected', selected);
|
||||||
|
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openPreview(entry) {
|
||||||
|
this.closePreview(false);
|
||||||
|
this._previewPreviousFocus = document.activeElement;
|
||||||
|
const requestSequence = ++this._previewRequestSequence;
|
||||||
|
const params = new URLSearchParams({ path: entry.path });
|
||||||
|
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||||
|
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'path-preview-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="path-preview-dialog">
|
||||||
|
<div class="path-preview-header">
|
||||||
|
<div class="path-preview-heading">
|
||||||
|
<strong class="path-preview-title"></strong>
|
||||||
|
<span class="path-preview-path"></span>
|
||||||
|
</div>
|
||||||
|
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||||
|
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
|
||||||
|
</div>`;
|
||||||
|
overlay.querySelector('.path-preview-title').textContent = entry.name;
|
||||||
|
overlay.querySelector('.path-preview-path').textContent = entry.path;
|
||||||
|
overlay.querySelector('.path-preview-open').href = previewUrl;
|
||||||
|
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) this.closePreview(true);
|
||||||
|
});
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this._previewOverlay = overlay;
|
||||||
|
|
||||||
|
const body = overlay.querySelector('.path-preview-body');
|
||||||
|
if (entry.previewKind === 'image') {
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'path-preview-image';
|
||||||
|
image.alt = entry.name;
|
||||||
|
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||||
|
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||||
|
image.src = previewUrl;
|
||||||
|
body.appendChild(image);
|
||||||
|
} else if (entry.previewKind === 'text') {
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then(async (response) => {
|
||||||
|
const content = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Text preview failed to load';
|
||||||
|
try {
|
||||||
|
message = JSON.parse(content).error || message;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
})
|
||||||
|
.then((content) => {
|
||||||
|
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'path-preview-text';
|
||||||
|
pre.textContent = content;
|
||||||
|
body.replaceChildren(pre);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const frame = document.createElement('iframe');
|
||||||
|
frame.className = 'path-preview-frame';
|
||||||
|
frame.title = entry.name;
|
||||||
|
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||||
|
frame.src = previewUrl;
|
||||||
|
body.appendChild(frame);
|
||||||
|
}
|
||||||
|
overlay.querySelector('.path-preview-close').focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
showPreviewError(message) {
|
||||||
|
const body = this._previewOverlay?.querySelector('.path-preview-body');
|
||||||
|
if (!body) return;
|
||||||
|
const error = document.createElement('div');
|
||||||
|
error.className = 'path-preview-error';
|
||||||
|
error.textContent = message || 'Preview failed to load';
|
||||||
|
body.replaceChildren(error);
|
||||||
|
},
|
||||||
|
|
||||||
|
closePreview(restoreFocus = true) {
|
||||||
|
this._previewRequestSequence += 1;
|
||||||
|
this._previewOverlay?.remove();
|
||||||
|
this._previewOverlay = null;
|
||||||
|
const previousFocus = this._previewPreviousFocus;
|
||||||
|
this._previewPreviousFocus = null;
|
||||||
|
if (restoreFocus) previousFocus?.focus?.();
|
||||||
|
},
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
if (!this._selectedPath || !this._options) return;
|
||||||
|
const selectedPath = this._selectedPath;
|
||||||
|
const onSelect = this._options.onSelect;
|
||||||
|
this.close(false);
|
||||||
|
onSelect(selectedPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
close(restoreFocus = true) {
|
||||||
|
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||||
|
this._keydownHandler = null;
|
||||||
|
this._loadSequence += 1;
|
||||||
|
this.closePreview(false);
|
||||||
|
this.overlay?.remove();
|
||||||
|
this.overlay = null;
|
||||||
|
const previousFocus = this._previousFocus;
|
||||||
|
this._previousFocus = null;
|
||||||
|
this._options = null;
|
||||||
|
this._selectedPath = '';
|
||||||
|
if (restoreFocus) previousFocus?.focus?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Mobile Keyboard Accessory Bar
|
// Mobile Keyboard Accessory Bar
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -92,6 +427,8 @@ const KeyboardAccessoryBar = {
|
|||||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||||
|
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||||
@@ -128,7 +465,7 @@ const KeyboardAccessoryBar = {
|
|||||||
this.handleAction(action, btn);
|
this.handleAction(action, btn);
|
||||||
|
|
||||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||||
if (refocusActions.has(action) ||
|
if (refocusActions.has(action) ||
|
||||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
@@ -207,6 +544,12 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'paste':
|
case 'paste':
|
||||||
this.pasteFromClipboard();
|
this.pasteFromClipboard();
|
||||||
break;
|
break;
|
||||||
|
case 'pick-path':
|
||||||
|
this.pickPath();
|
||||||
|
break;
|
||||||
|
case 'clear-input':
|
||||||
|
app.clearTerminalInput?.();
|
||||||
|
break;
|
||||||
case 'dismiss':
|
case 'dismiss':
|
||||||
// Blur active element to dismiss keyboard
|
// Blur active element to dismiss keyboard
|
||||||
document.activeElement?.blur();
|
document.activeElement?.blur();
|
||||||
@@ -265,6 +608,22 @@ const KeyboardAccessoryBar = {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Browse the active session's workspace and insert a selected path without Enter. */
|
||||||
|
pickPath() {
|
||||||
|
if (!app.activeSessionId) return;
|
||||||
|
const session = app.sessions?.get(app.activeSessionId);
|
||||||
|
PathPicker.open({
|
||||||
|
title: 'Insert File or Folder Path',
|
||||||
|
sessionId: app.activeSessionId,
|
||||||
|
initialPath: session?.workingDir || '',
|
||||||
|
directoriesOnly: false,
|
||||||
|
onSelect: (path) => {
|
||||||
|
app.insertTerminalText?.(path);
|
||||||
|
setTimeout(() => app.terminal?.focus(), 100);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/** Show a paste overlay for iOS compatibility.
|
/** Show a paste overlay for iOS compatibility.
|
||||||
* Handles three input paths from one dialog:
|
* Handles three input paths from one dialog:
|
||||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||||
|
|||||||
@@ -1888,6 +1888,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openLinkCasePathPicker() {
|
||||||
|
const pathInput = document.getElementById('linkCasePath');
|
||||||
|
PathPicker.open({
|
||||||
|
title: 'Select Existing Project Folder',
|
||||||
|
initialPath: pathInput.value.trim(),
|
||||||
|
directoriesOnly: true,
|
||||||
|
onSelect: (path) => {
|
||||||
|
pathInput.value = path;
|
||||||
|
const nameInput = document.getElementById('linkCaseName');
|
||||||
|
if (!nameInput.value.trim()) {
|
||||||
|
const folderName = path.split('/').filter(Boolean).pop() || '';
|
||||||
|
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
||||||
|
}
|
||||||
|
pathInput.focus();
|
||||||
|
pathInput.setSelectionRange(path.length, path.length);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async linkRemoteCase() {
|
async linkRemoteCase() {
|
||||||
const name = document.getElementById('remoteCaseName').value.trim();
|
const name = document.getElementById('remoteCaseName').value.trim();
|
||||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||||
|
|||||||
@@ -11064,6 +11064,438 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||||
|
.path-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input-group input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-input-browse {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10020;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(680px, 100%);
|
||||||
|
height: min(720px, calc(100dvh - 32px));
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-header,
|
||||||
|
.path-picker-nav,
|
||||||
|
.path-picker-roots-row,
|
||||||
|
.path-picker-selection,
|
||||||
|
.path-picker-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-roots-row {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-roots {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-nav {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-up,
|
||||||
|
.path-picker-refresh {
|
||||||
|
flex: 0 0 38px;
|
||||||
|
height: 38px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-up:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-current {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-status {
|
||||||
|
padding: 0 14px 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-status.error {
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
margin: 2px 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item:hover,
|
||||||
|
.path-picker-item.selected {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item.not-selectable {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-link,
|
||||||
|
.path-picker-item-chevron,
|
||||||
|
.path-picker-item-preview {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-preview {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-item-select {
|
||||||
|
align-self: center;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-selection {
|
||||||
|
padding: 9px 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-selection-value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-actions {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-actions button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-action-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-actions .path-picker-confirm {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-actions .path-picker-confirm:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10030;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1000px, 100%);
|
||||||
|
height: min(860px, calc(100dvh - 36px));
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-heading {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-title,
|
||||||
|
.path-preview-path {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-path {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-open,
|
||||||
|
.path-preview-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-open {
|
||||||
|
padding: 8px 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-close {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-loading,
|
||||||
|
.path-preview-error {
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-error {
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
margin: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-text {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
overflow: visible;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.path-picker-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-dialog {
|
||||||
|
width: 100%;
|
||||||
|
height: min(82dvh, 720px);
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-picker-current-select {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-overlay {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-dialog {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-preview-open {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
Orchestrator Panel
|
Orchestrator Panel
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -2506,6 +2506,50 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
|
insertTerminalText(text) {
|
||||||
|
if (!this.activeSessionId || !text) return;
|
||||||
|
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||||
|
this._localEchoOverlay.appendText(text);
|
||||||
|
} else {
|
||||||
|
this.sendInput(text).catch(() => {});
|
||||||
|
}
|
||||||
|
this.terminal?.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear only the current editable prompt. This is intentionally distinct
|
||||||
|
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
|
||||||
|
*/
|
||||||
|
clearTerminalInput() {
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
|
||||||
|
if (typeof CjkInput !== 'undefined') CjkInput.clear();
|
||||||
|
if (this._inputFlushTimeout) {
|
||||||
|
clearTimeout(this._inputFlushTimeout);
|
||||||
|
this._inputFlushTimeout = null;
|
||||||
|
}
|
||||||
|
this._pendingInput = '';
|
||||||
|
|
||||||
|
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||||
|
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||||
|
this._localEchoOverlay.clear();
|
||||||
|
this._localEchoOverlay.suppressBufferDetection();
|
||||||
|
this._flushedOffsets?.delete(this.activeSessionId);
|
||||||
|
this._flushedTexts?.delete(this.activeSessionId);
|
||||||
|
if (flushed.count > 0) {
|
||||||
|
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||||
|
// is the conventional kill-line key supported by shells and agent TUIs.
|
||||||
|
this.sendInput('\x15').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast?.('Input cleared', 'success');
|
||||||
|
this.terminal?.focus();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore terminal size to match web UI dimensions.
|
* Restore terminal size to match web UI dimensions.
|
||||||
* Use this after mobile screen attachment has squeezed the terminal.
|
* Use this after mobile screen attachment has squeezed the terminal.
|
||||||
|
|||||||
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||||
import { basename as pathBasename, join } from 'node:path';
|
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
FilesystemBrowseData,
|
||||||
|
FilesystemBrowseEntry,
|
||||||
|
FilesystemBrowseRoot,
|
||||||
|
FilesystemPreviewKind,
|
||||||
|
} from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,12 +30,20 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
|||||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
import {
|
||||||
|
CASES_DIR,
|
||||||
|
canAccessOwned,
|
||||||
|
findSessionOrFail,
|
||||||
|
getAuthUser,
|
||||||
|
parseBody,
|
||||||
|
validateSessionFilePath,
|
||||||
|
} from '../route-helpers.js';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||||
|
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
@@ -45,8 +61,14 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
txt: 'text/plain',
|
txt: 'text/plain',
|
||||||
};
|
};
|
||||||
|
|
||||||
function sanitizeDownloadName(fileName: string): string {
|
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||||
return fileName.replace(/["\\\r\n]/g, '_');
|
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
||||||
|
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
||||||
|
const encoded = encodeURIComponent(cleaned).replace(
|
||||||
|
/['()*]/g,
|
||||||
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||||
|
);
|
||||||
|
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||||
@@ -92,13 +114,12 @@ async function serveRawFile(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = createReadStream(resolvedPath);
|
const content = createReadStream(resolvedPath);
|
||||||
const safeName = sanitizeDownloadName(fileName);
|
|
||||||
if (download || extension === 'svg') {
|
if (download || extension === 'svg') {
|
||||||
reply.header(
|
reply.header(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||||
);
|
);
|
||||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||||
reply.header('Content-Length', stat.size);
|
reply.header('Content-Length', stat.size);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendRawStream(reply, content);
|
||||||
@@ -106,7 +127,7 @@ async function serveRawFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||||
reply.header('Content-Length', stat.size);
|
reply.header('Content-Length', stat.size);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendRawStream(reply, content);
|
||||||
@@ -194,7 +215,10 @@ async function serveConvertedPreview(
|
|||||||
|
|
||||||
const content = await fs.readFile(previewPath);
|
const content = await fs.readFile(previewPath);
|
||||||
reply.header('Content-Type', 'application/pdf');
|
reply.header('Content-Type', 'application/pdf');
|
||||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
||||||
|
);
|
||||||
reply.header('Cache-Control', 'no-cache');
|
reply.header('Cache-Control', 'no-cache');
|
||||||
reply.header('Content-Length', content.length);
|
reply.header('Content-Length', content.length);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
@@ -260,6 +284,147 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
|||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||||
|
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
||||||
|
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
||||||
|
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||||
|
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||||
|
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||||
|
|
||||||
|
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||||
|
const rel = relative(root, candidate);
|
||||||
|
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||||
|
return roots
|
||||||
|
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||||
|
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||||
|
const rel = relative(root, candidate);
|
||||||
|
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||||
|
const extension = extname(fileName).slice(1).toLowerCase();
|
||||||
|
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||||
|
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
||||||
|
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||||
|
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||||
|
// The shared sensitive-path matcher describes file locations such as
|
||||||
|
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||||
|
// opened and used to enumerate those filenames.
|
||||||
|
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
|
||||||
|
const candidates: Array<{ label: string; path: string }> = [
|
||||||
|
{ label: 'Home', path: homedir() },
|
||||||
|
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||||
|
{ label: 'WSL D:', path: '/mnt/d' },
|
||||||
|
];
|
||||||
|
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||||
|
if (extraRoots) {
|
||||||
|
for (const [index, path] of extraRoots
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.entries()) {
|
||||||
|
candidates.push({ label: `Configured ${index + 1}`, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFilesystemPickerRoots(
|
||||||
|
ctx: SessionPort & ConfigPort,
|
||||||
|
sessionId?: string
|
||||||
|
): Promise<FilesystemBrowseRoot[]> {
|
||||||
|
const candidates = configuredFilesystemPickerRoots();
|
||||||
|
if (sessionId) {
|
||||||
|
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||||
|
statusCode: 404,
|
||||||
|
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
const roots: FilesystemBrowseRoot[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!isAbsolute(candidate.path)) continue;
|
||||||
|
try {
|
||||||
|
const resolved = realpathSync(candidate.path);
|
||||||
|
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||||
|
const stat = await fs.stat(resolved);
|
||||||
|
if (!stat.isDirectory()) continue;
|
||||||
|
seen.add(resolved);
|
||||||
|
roots.push({ label: candidate.label, path: resolved });
|
||||||
|
} catch {
|
||||||
|
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedFilesystemPickerPath = {
|
||||||
|
candidatePath: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
roots: FilesystemBrowseRoot[];
|
||||||
|
matchingRoot: FilesystemBrowseRoot;
|
||||||
|
blockedTrees: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||||
|
throw Object.assign(new Error(message), {
|
||||||
|
statusCode,
|
||||||
|
body: createErrorResponse(code, message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFilesystemPickerPath(
|
||||||
|
ctx: SessionPort & ConfigPort,
|
||||||
|
requestedPath: string | undefined,
|
||||||
|
sessionId?: string
|
||||||
|
): Promise<ResolvedFilesystemPickerPath> {
|
||||||
|
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
||||||
|
if (roots.length === 0) {
|
||||||
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRoot =
|
||||||
|
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||||
|
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||||
|
|
||||||
|
let resolvedPath: string;
|
||||||
|
try {
|
||||||
|
resolvedPath = realpathSync(candidatePath);
|
||||||
|
} catch {
|
||||||
|
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
||||||
|
if (!matchingRoot) {
|
||||||
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||||
|
}
|
||||||
|
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||||
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||||
|
}
|
||||||
|
|
||||||
function appendDownloadFlag(url: string): string {
|
function appendDownloadFlag(url: string): string {
|
||||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||||
}
|
}
|
||||||
@@ -375,6 +540,177 @@ async function buildExternalAttachmentRouteItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||||
|
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||||
|
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||||
|
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||||
|
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||||
|
ctx,
|
||||||
|
requestedPath,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||||
|
reply.code(403);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
reply.code(400);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reply.code(404);
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dirEntries;
|
||||||
|
try {
|
||||||
|
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
reply.code(403);
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
||||||
|
}
|
||||||
|
|
||||||
|
dirEntries.sort((a, b) => {
|
||||||
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||||
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries: FilesystemBrowseEntry[] = [];
|
||||||
|
let truncated = false;
|
||||||
|
for (const entry of dirEntries) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePath = join(candidatePath, entry.name);
|
||||||
|
let targetPath: string;
|
||||||
|
try {
|
||||||
|
targetPath = realpathSync(visiblePath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||||
|
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||||
|
|
||||||
|
let type: FilesystemBrowseEntry['type'];
|
||||||
|
let size: number | undefined;
|
||||||
|
const symlink = entry.isSymbolicLink();
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
type = 'directory';
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
type = 'file';
|
||||||
|
} else if (symlink) {
|
||||||
|
try {
|
||||||
|
const targetStat = await fs.stat(targetPath);
|
||||||
|
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||||
|
if (type === 'file') size = targetStat.size;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||||
|
if (type === 'file' && size === undefined) {
|
||||||
|
try {
|
||||||
|
size = (await fs.stat(targetPath)).size;
|
||||||
|
} catch {
|
||||||
|
// The path is still selectable even when a size lookup races a change.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: visiblePath,
|
||||||
|
type,
|
||||||
|
size,
|
||||||
|
symlink: symlink || undefined,
|
||||||
|
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentCandidate = resolve(candidatePath, '..');
|
||||||
|
let parent: string | null = null;
|
||||||
|
if (candidatePath !== matchingRoot.path) {
|
||||||
|
try {
|
||||||
|
const resolvedParent = realpathSync(parentCandidate);
|
||||||
|
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
||||||
|
} catch {
|
||||||
|
// A concurrently removed parent simply disables upward navigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: candidatePath,
|
||||||
|
parent,
|
||||||
|
root: matchingRoot.path,
|
||||||
|
roots,
|
||||||
|
entries,
|
||||||
|
truncated,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline preview for files selected through the root-confined filesystem picker.
|
||||||
|
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||||
|
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||||
|
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||||
|
ctx,
|
||||||
|
requestedPath,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||||
|
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = pathBasename(candidatePath);
|
||||||
|
const extension = extname(fileName).slice(1).toLowerCase();
|
||||||
|
const previewKind = getFilesystemPreviewKind(fileName);
|
||||||
|
if (!previewKind) {
|
||||||
|
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
||||||
|
}
|
||||||
|
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
||||||
|
if (stat.size > sizeLimit) {
|
||||||
|
throwFilesystemPickerError(
|
||||||
|
413,
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
|
if (previewKind === 'text') {
|
||||||
|
const content = await fs.readFile(resolvedPath, 'utf8');
|
||||||
|
reply.type('text/plain; charset=utf-8').send(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (extension === 'docx' || extension === 'pptx') {
|
||||||
|
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||||
|
});
|
||||||
|
|
||||||
// File tree listing
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@@ -50,6 +50,39 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
|||||||
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem picker paths are never interpolated into a shell command, so legal
|
||||||
|
* filename characters such as spaces, quotes, and parentheses are accepted.
|
||||||
|
* Containment and symlink resolution are enforced by the route after parsing.
|
||||||
|
*/
|
||||||
|
const filesystemPickerPathSchema = z
|
||||||
|
.string()
|
||||||
|
.max(4096)
|
||||||
|
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||||
|
message: 'Path must be an absolute filesystem path',
|
||||||
|
})
|
||||||
|
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||||
|
|
||||||
|
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||||
|
export const FilesystemBrowseQuerySchema = z.object({
|
||||||
|
path: filesystemPickerPathSchema.optional(),
|
||||||
|
sessionId: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Query validation for a single allowlisted path-picker file preview. */
|
||||||
|
export const FilesystemPreviewQuerySchema = z.object({
|
||||||
|
path: filesystemPickerPathSchema,
|
||||||
|
sessionId: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// ========== Env Var Allowlist ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** Allowlisted env var key prefixes */
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
|
||||||
|
* extended mobile keyboard actions. No browser or real server required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||||
|
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
|
||||||
|
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||||
|
|
||||||
|
function loadTerminalMixin() {
|
||||||
|
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||||
|
const cjkClear = vi.fn();
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
CodemanApp: FakeCodemanApp,
|
||||||
|
CjkInput: { clear: cjkClear },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
document: { addEventListener: vi.fn() },
|
||||||
|
});
|
||||||
|
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||||
|
return { mixin: FakeCodemanApp.prototype, cjkClear };
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalHarness = loadTerminalMixin();
|
||||||
|
|
||||||
|
function loadKeyboardModule() {
|
||||||
|
const app = {
|
||||||
|
activeSessionId: 'session-1',
|
||||||
|
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
|
||||||
|
terminal: { focus: vi.fn() },
|
||||||
|
clearTerminalInput: vi.fn(),
|
||||||
|
insertTerminalText: vi.fn(),
|
||||||
|
sendInput: vi.fn(),
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
app,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
URLSearchParams,
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document: {},
|
||||||
|
setTimeout: (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimeout: vi.fn(),
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||||
|
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mobile filesystem picker actions', () => {
|
||||||
|
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||||
|
const { app, bar } = loadKeyboardModule();
|
||||||
|
bar.handleAction('clear-input');
|
||||||
|
|
||||||
|
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(keyboardSource).toContain('data-action="clear-input"');
|
||||||
|
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens at the active working directory and inserts the selected path without Enter', () => {
|
||||||
|
const { app, bar, picker } = loadKeyboardModule();
|
||||||
|
picker.open = vi.fn();
|
||||||
|
|
||||||
|
bar.handleAction('pick-path');
|
||||||
|
|
||||||
|
expect(picker.open).toHaveBeenCalledOnce();
|
||||||
|
const options = picker.open.mock.calls[0][0];
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
initialPath: '/mnt/d/AI',
|
||||||
|
directoriesOnly: false,
|
||||||
|
});
|
||||||
|
options.onSelect('/mnt/d/AI/project/file.ts');
|
||||||
|
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires Link Existing to the shared folder-only picker', () => {
|
||||||
|
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
|
||||||
|
expect(indexSource).toContain('id="linkCasePath"');
|
||||||
|
expect(sessionSource).toContain('openLinkCasePathPicker()');
|
||||||
|
expect(sessionSource).toContain('directoriesOnly: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Choose separate from safe inline file preview', () => {
|
||||||
|
expect(keyboardSource).toContain('openPreview(entry)');
|
||||||
|
expect(keyboardSource).toContain('/api/filesystem/preview?');
|
||||||
|
expect(keyboardSource).toContain("entry.previewKind === 'image'");
|
||||||
|
expect(keyboardSource).toContain("entry.previewKind === 'text'");
|
||||||
|
expect(keyboardSource).toContain("choose.textContent = 'Choose'");
|
||||||
|
expect(keyboardSource).toContain('pre.textContent = content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||||
|
const appendText = vi.fn();
|
||||||
|
const sendInput = vi.fn();
|
||||||
|
const focus = vi.fn();
|
||||||
|
const app = {
|
||||||
|
activeSessionId: 'session-1',
|
||||||
|
_localEchoEnabled: true,
|
||||||
|
_localEchoOverlay: { appendText },
|
||||||
|
terminal: { focus },
|
||||||
|
sendInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
|
||||||
|
|
||||||
|
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(focus).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pending and already-flushed prompt text without invoking /clear', () => {
|
||||||
|
const clear = vi.fn();
|
||||||
|
const suppressBufferDetection = vi.fn();
|
||||||
|
const sendInput = vi.fn(() => Promise.resolve());
|
||||||
|
const showToast = vi.fn();
|
||||||
|
const focus = vi.fn();
|
||||||
|
const app = {
|
||||||
|
activeSessionId: 'session-1',
|
||||||
|
_inputFlushTimeout: null,
|
||||||
|
_pendingInput: 'pending text',
|
||||||
|
_localEchoEnabled: true,
|
||||||
|
_localEchoOverlay: {
|
||||||
|
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||||
|
clear,
|
||||||
|
suppressBufferDetection,
|
||||||
|
},
|
||||||
|
_flushedOffsets: new Map([['session-1', 4]]),
|
||||||
|
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||||
|
sendInput,
|
||||||
|
showToast,
|
||||||
|
terminal: { focus },
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||||
|
|
||||||
|
expect(app._pendingInput).toBe('');
|
||||||
|
expect(clear).toHaveBeenCalledOnce();
|
||||||
|
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||||
|
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||||
|
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||||
|
expect(app._flushedOffsets.size).toBe(0);
|
||||||
|
expect(app._flushedTexts.size).toBe(0);
|
||||||
|
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
|
||||||
|
expect(focus).toHaveBeenCalledOnce();
|
||||||
|
expect(terminalHarness.cjkClear).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
|
||||||
|
const sendInput = vi.fn(() => Promise.resolve());
|
||||||
|
const app = {
|
||||||
|
activeSessionId: 'session-1',
|
||||||
|
_inputFlushTimeout: null,
|
||||||
|
_pendingInput: '',
|
||||||
|
_localEchoEnabled: false,
|
||||||
|
_localEchoOverlay: null,
|
||||||
|
sendInput,
|
||||||
|
showToast: vi.fn(),
|
||||||
|
terminal: { focus: vi.fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||||
|
|
||||||
|
expect(sendInput).toHaveBeenCalledWith('\x15');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,13 +8,14 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
import { ApiErrorCode } from '../../src/types.js';
|
||||||
|
|
||||||
// Mock fs/promises for file operations
|
// Mock fs/promises for file operations
|
||||||
vi.mock('node:fs/promises', () => ({
|
vi.mock('node:fs/promises', () => ({
|
||||||
default: {
|
default: {
|
||||||
readdir: vi.fn(async () => []),
|
readdir: vi.fn(async () => []),
|
||||||
readFile: vi.fn(async () => 'file content'),
|
readFile: vi.fn(async () => 'file content'),
|
||||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -55,13 +56,189 @@ describe('file-routes', () => {
|
|||||||
// Default: realpathSync returns the path unchanged
|
// Default: realpathSync returns the path unchanged
|
||||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
// Default stat
|
// Default stat
|
||||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
|
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||||
|
mockedReadFile.mockImplementation(async (path) =>
|
||||||
|
String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await harness.app.close();
|
await harness.app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== GET /api/filesystem/browse ==========
|
||||||
|
|
||||||
|
describe('GET /api/filesystem/browse', () => {
|
||||||
|
it('lists the active session folder lazily with directories first', async () => {
|
||||||
|
mockedReaddir.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
name: 'notes.txt',
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
isSymbolicLink: () => false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
isDirectory: () => true,
|
||||||
|
isFile: () => false,
|
||||||
|
isSymbolicLink: () => false,
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
const path = harness.ctx._session.workingDir;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.path).toBe(path);
|
||||||
|
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
||||||
|
expect(
|
||||||
|
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
||||||
|
entry.name,
|
||||||
|
entry.type,
|
||||||
|
entry.previewKind,
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
['src', 'directory', undefined],
|
||||||
|
['notes.txt', 'file', 'text'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside the configured roots', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
|
||||||
|
const root = harness.ctx._session.workingDir;
|
||||||
|
mockedReaddir.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
name: '.secret',
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => true,
|
||||||
|
isSymbolicLink: () => false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outside-link',
|
||||||
|
isDirectory: () => false,
|
||||||
|
isFile: () => false,
|
||||||
|
isSymbolicLink: () => true,
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
mockedRealpathSync.mockImplementation((path: string) =>
|
||||||
|
path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).data.entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown session scope', async () => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/filesystem/browse?sessionId=missing-session',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects direct navigation into a hidden descendant', async () => {
|
||||||
|
const hidden = `${harness.ctx._session.workingDir}/.git`;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== GET /api/filesystem/preview ==========
|
||||||
|
|
||||||
|
describe('GET /api/filesystem/preview', () => {
|
||||||
|
it('serves Markdown as inert plain text inside the active session root', async () => {
|
||||||
|
const path = `${harness.ctx._session.workingDir}/notes.md`;
|
||||||
|
mockedReadFile.mockImplementation(async (candidate) =>
|
||||||
|
candidate === path ? ('# Safe heading\n<script>alert(1)</script>' as never) : ('{}' as never)
|
||||||
|
);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/plain');
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(res.body).toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported file types', async () => {
|
||||||
|
const path = `${harness.ctx._session.workingDir}/archive.exe`;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects hidden files even when requested directly', async () => {
|
||||||
|
const path = `${harness.ctx._session.workingDir}/.env`;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a preview symlink whose real path escapes every allowed root', async () => {
|
||||||
|
const path = `${harness.ctx._session.workingDir}/outside.png`;
|
||||||
|
mockedRealpathSync.mockImplementation((candidate: string) =>
|
||||||
|
candidate === path ? ('/etc/shadow' as never) : (candidate as never)
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps text previews at 2MB', async () => {
|
||||||
|
const path = `${harness.ctx._session.workingDir}/large.txt`;
|
||||||
|
mockedStat.mockImplementation(async (candidate) =>
|
||||||
|
candidate === path
|
||||||
|
? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
|
||||||
|
: ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
|
||||||
|
);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(413);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== GET /api/sessions/:id/files ==========
|
// ========== GET /api/sessions/:id/files ==========
|
||||||
|
|
||||||
describe('GET /api/sessions/:id/files', () => {
|
describe('GET /api/sessions/:id/files', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user