Skip to content

feat(webui): file preview endpoints + viewer (webui-parity slice 02) - #47

Merged
fengzhi09 merged 2 commits into
mainfrom
feat/file-preview
Sep 27, 2026
Merged

fengzhi09 merged 2 commits into
mainfrom
feat/file-preview

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Slice 02 of the webui-parity program (.tickets/webui-parity/02-file-preview.md): the read-only file viewer foundation.

  • GET /api/fs/read-file?path= — JSON text content, ≤512 KiB (structured 413 over), binary sniff → 415 with mime/language preserved so the client can still route images.
  • GET /api/fs/raw?path= — streamed bytes, ≤20 MiB, mime from extension map, Cache-Control: no-store.
  • Both gated by the existing containment helper (safePath → assertWorkspacePath); no new escape hatch. Registered in OWNED_ROUTES; docs/API.md updated.
  • New components/file-preview.tsx + lib/file-preview.ts (pure type→renderer mapping): markdown via the repo's existing lib/markdown.ts (same sanitizer/allow-list as chat), source as monospace + language badge, images via <img src="/api/fs/raw?path=…&cid=…">.
  • getFsFile uses a raw fetch (idiomatic here, as elsewhere in api.ts) so the structured 4xx body survives the preview's image branch.

This slice deliberately does not mount the viewer in the panel — that wiring plus the shared open.file.in.web action is tracked in slice 12, which the acceptance explicitly required before this feature is called user-visible.

Acceptance (independent agent)

PASS-WITH-CONCERNS, all slice scope verified live:

  • Containment: .. to a real /etc/hostname, absolute /etc/*, symlink → /etc/hostname, symlinked dir → /etc, and a dangling symlink all refused 403 before any filesystem touch (error names the resolved target). Controls pass: .. inside roots and a /tmp→/tmp symlink both return 200 — the gate resolves rather than blanket-refusing.
  • Type rendering: .md rendered with <script> fully stripped; .ts code view + typescript badge; .png via /api/fs/raw (verified 200, byte-identical, no-store) — not file://; binary → 415 clear state; oversize → clear message.
  • Caps: >512 KiB → structured 413 with size/mime, no truncated body; >20 MiB raw → 413.
  • Gates: route tests 14/14 ×3 · webapp 360/360 · typecheck ✓ · build ✓ · check:source ✓. Diff touches only slice files; legacy /api/fs/read and /api/state unaffected.

Open policy item (deliberately left to a follow-up, non-blocking): credential-shaped files (.env, credentials.ini, ~/.ssh/*) preview as full plaintext — consistent with the existing /api/fs/read gate (home is an allowed root). A mask/warn policy is recommended as follow-up; noting the server advertises a LAN URL, so these GETs are LAN-reachable (pre-existing property).

Full pnpm verify deferred to CI.

Right-panel file preview (slice 02 of webui-parity): the files panel
opens a read-only viewer for the clicked path. Renders markdown via
the existing lib/markdown renderer (so the threat model and allow-list
match chat output), source code as monospace pre with a language
badge from the server hint, and images via /api/fs/raw.

Server:
  GET /api/fs/read-file?path=<file>
    JSON content (≤512 KiB), 4xx with mime/language/binary preserved
    on the error body so the client can still route. Containment gate
    is the shared assertWorkspacePath from server/lib/workspace.js —
    no new escape hatch; symlink / '..' attempts go through the same
    realpath containment check as /api/fs/read.
  GET /api/fs/raw?path=<file>
    Stream bytes (≤20 MiB), mime mapped from extension, Cache-Control:
    no-store. The Hono layer wraps the streaming via rawStreamToWebResponse
    because createResponseCapture is sync-only (header comment in
    server/app.js calls out streaming as P2; this slice is the first
    consumer). The legacy handleFsRaw(req, res) keeps the streaming
    shape for non-Hono callers / test fixtures.

Webapp:
  lib/file-preview.ts — pure pickPreviewKind + helpers, separated so
    unit tests can pin the type→renderer routing without spinning up
    React (Node loader does not honour the Next.js '@/lib/...' alias).
  components/file-preview.tsx — the React view; renders header +
    error/markdown/image/code states. Uses raw fetch for getFsFile
    so the structured 4xx body is preserved (request() helper throws
    on non-OK and would discard mime/binary).
  styles/official-utilities.css — .file-preview-markdown / -codeblock
    rules (headings, paragraphs, lists, table, hr; the chat codeblock
    shell is NOT used here, the chat shell styles would clobber our
    padding to 0).

Tests:
  routes/fs-read-file.test.js — containment, size cap (413), binary
    (415), directory (415), missing path, full success shape.
  routes/fs-raw.test.js — containment, mime mapping, stream round-trip,
    directory (400), missing path.
  test/server/app-hono.test.js — extended to assert the two new
    routes are in OWNED_ROUTES.
  webapp/test/file-preview.test.ts — 25 cases pinning the pickPreviewKind
    mapping (markdown, image mime tiebreaker, code catch-all,
    case-insensitive extension, exhaustive kind set).

Live self-check (isolated 18144/18145, MCODE_WEBUI_DATA_DIR=/tmp/dev-fp/data):
opened FilePreview against README.md (markdown), api.ts (typescript),
and 02-workspace-shell.jpg (image) — screenshot at
/tmp/dev-fp/screenshots/file-preview-final.png shows all three branches
rendering correctly with no console errors. The panels.tsx wiring that
mounts this component into the files panel tree is the follow-up
(parent agent owns it); this slice is the read-only viewer + endpoints.
The rebase conflict on release/public-source.json was resolved by
regenerating the inventory in this worktree so the file records both
slices: slice 01's files-tree.ts / files-tree.test.ts and slice 02's
file-preview.{ts,tsx} / file-preview.test.ts.

check:source: 4599 files pass. test:webapp: 396/396.
@fengzhi09
fengzhi09 merged commit 207244e into main Sep 27, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/file-preview branch September 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant