From c244475f4f6e5aae4ffbaa9be5d6060c966111fe Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Sun, 27 Sep 2026 18:41:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(webui):=20file=20preview=20slice=2002?= =?UTF-8?q?=20=E2=80=94=20read-file/raw=20endpoints=20+=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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= 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. --- packages/webui/docs/API.md | 65 +++++ packages/webui/server/app.js | 18 ++ packages/webui/server/lib/fs-util.js | 143 +++++++++- packages/webui/server/routes/fs.js | 176 +++++++++++- packages/webui/test/routes/fs-raw.test.js | 159 +++++++++++ .../webui/test/routes/fs-read-file.test.js | 194 +++++++++++++ packages/webui/test/server/app-hono.test.js | 2 + .../webui/webapp/components/file-preview.tsx | 261 ++++++++++++++++++ packages/webui/webapp/lib/api.ts | 65 +++++ packages/webui/webapp/lib/file-preview.ts | 69 +++++ .../webapp/styles/official-utilities.css | 102 +++++++ .../webui/webapp/test/file-preview.test.ts | 136 +++++++++ 12 files changed, 1387 insertions(+), 3 deletions(-) create mode 100644 packages/webui/test/routes/fs-raw.test.js create mode 100644 packages/webui/test/routes/fs-read-file.test.js create mode 100644 packages/webui/webapp/components/file-preview.tsx create mode 100644 packages/webui/webapp/lib/file-preview.ts create mode 100644 packages/webui/webapp/test/file-preview.test.ts diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index bd6e70210..269ef1016 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -670,6 +670,71 @@ before creating. **Errors** — 400 invalid JSON; 403 parent out-of-root; 409 already exists. +### `GET /api/fs/read-file?path=` + +Read the contents of a single regular file as text. Drives the right-panel +file preview (slice 02 — `webapp/components/file-preview.tsx`). Same +containment boundary as `/api/fs/read`; the gate runs first, so an +out-of-root path is rejected before the file is even stat'd. + +Files over **512 KiB** are rejected with `413` rather than silently +truncated — the caller (the webapp preview) renders a "too large" state +and points the user at a real editor. The body still carries the file's +detected `mime` / `language` so the UI can route it to the right +renderer without a second round-trip. + +Binary detection scans the first 4 KiB for a NUL byte. A binary file is +returned with `ok:false, error:"binary file not supported"` and a 415 +status; the webapp renders an "无法预览" placeholder. The error path +still carries `mime` / `language` so the UI can hint at why (e.g. +"image, use the raw endpoint" for `.png`). + +**Response 200** +```json +{ + "ok": true, + "path": "C:\\Users\\you\\README.md", + "size": 2400, + "mime": "text/markdown; charset=utf-8", + "language": "markdown", + "binary": false, + "encoding": "utf-8", + "content": "# Title\n\n…" +} +``` + +`encoding` is `"utf-8"` on success (with the BOM stripped); `language` is +one of `markdown` / `typescript` / `javascript` / `json` / `yaml` / `css` +/ `html` / `python` / `go` / `rust` / `bash` / `sql` / `dockerfile` / +`plain` (informational — the renderer is allowed to ignore it). + +**Errors** — 400 missing `path`; 403 out-of-root; 413 over the 512 KiB +cap; 415 binary file or non-regular file (directory / device / socket); +500 stat failure (file vanished mid-request). + +### `GET /api/fs/raw?path=` + +Stream raw bytes for a file. Used by `` and download affordances in +the preview (slice 02). Same containment boundary as `/api/fs/read`; +**20 MiB** hard cap (matches the pr-22 reference). + +`Content-Type` is mapped from the extension; unknown extensions fall +through to `application/octet-stream`. `Cache-Control: no-store` — local +files have no immutable hash, the cache must not lie about freshness. + +**Response 200** — binary stream. Examples: + +| extension | Content-Type | +|---|---| +| `.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.ico` / `.pdf` | as listed | +| `.svg` | `image/svg+xml` | +| `.html` / `.htm` / `.css` / `.js` / `.mjs` / `.json` / `.md` / `.txt` | `text/...; charset=utf-8` | +| `.woff2` | `font/woff2` | +| (anything else) | `application/octet-stream` | + +**Errors** — 400 missing `path`; 403 out-of-root; 404 not found; 400 not +a regular file; 413 over the 20 MiB cap. + --- ## Settings diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 17446eba0..99004ea33 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -118,6 +118,8 @@ export const OWNED_ROUTES = new Set([ "GET /api/workspace/recent", // Native-style fs picker. "GET /api/fs/read", + "GET /api/fs/read-file", + "GET /api/fs/raw", "POST /api/fs/mkdir", // Settings. "GET /api/settings", @@ -464,6 +466,22 @@ export function createHonoApp() { app.get("/api/fs/read", (c) => invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsRead), ); + // File preview endpoints (slice 02). Same containment gate as /api/fs/read; + // the only difference is the body — read-file is JSON text (≤512 KiB cap), + // raw streams bytes (≤20 MiB cap, mime from extension map). + app.get("/api/fs/read-file", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsReadFile), + ); + // `/api/fs/raw` needs a streaming body — incompatible with the + // createResponseCapture buffer (which only models writeHead/end). The + // route's `rawStreamToWebResponse` returns a fetch-API Response with a + // Web ReadableStream body, so we hand it back to Hono directly and skip + // invokeHandler entirely. + app.get("/api/fs/raw", (c) => { + const url = new URL(c.req.url, "http://localhost"); + const path = url.searchParams.get("path") || ""; + return fsRoute.rawStreamToWebResponse(path); + }); app.post("/api/fs/mkdir", (c) => invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsMkdir), ); diff --git a/packages/webui/server/lib/fs-util.js b/packages/webui/server/lib/fs-util.js index e7470a18b..ab0c5d8de 100644 --- a/packages/webui/server/lib/fs-util.js +++ b/packages/webui/server/lib/fs-util.js @@ -1,7 +1,7 @@ // server/lib/fs-util.js — 文件系统工具类(feat-workspace-lhl) // -// 提供目录浏览、条目详情、创建目录等功能。 -// 后续可用于 sidebar 文件树管理。 +// 提供目录浏览、条目详情、创建目录、文件读取(feat-file-preview slice 02)等功能。 +// 后续可用于 sidebar 文件树管理 / 右栏文件预览。 import { readdirSync, statSync, mkdirSync, readFileSync, existsSync } from 'node:fs' import { join, resolve, extname, basename } from 'node:path' @@ -164,3 +164,142 @@ export function createDirectory(targetPath) { return { ok: false, error: e.message, path: absPath } } } + +// v2.4 (file preview, slice 02): +// `readFileContent` — read-only content fetch used by /api/fs/read-file and +// the right-panel preview (webapp/components/file-preview.tsx). +// Containment is the caller's job (routes/fs.js#handleFsReadFile runs +// assertWorkspacePath first), so this module just does the file-level +// checks: +// - regular file (not directory / device / socket); +// - size cap (DEFAULT_FILE_READ_MAX), oversize → error, never truncate; +// - binary detection (NUL byte in the first BINARY_SNIFF_BYTES); +// - UTF-8 BOM stripped on success. +// +// Response shape is JSON-friendly so the route can serialize it as-is: +// { ok:true, path, size, encoding:'utf-8', binary:false, +// mime, language, content } +// { ok:false, path, error } +// +// `language` is an extension-based hint the webapp's syntax renderer uses +// to pick a token dictionary. It is informational — a guess — not a +// contract; `unknown` is returned for anything not in the table. +export const DEFAULT_FILE_READ_MAX = 512 * 1024 // 512 KiB — same as pr-22 +const BINARY_SNIFF_BYTES = 4096 + +// Minimum extension → language-id map the webapp renderer branches on. +// Anything missing falls back to "plain" (no highlighting beyond the monospace +// view). Adding a language here is a one-liner; the goal is to keep the +// surface small and predictable so the renderer stays single-file. +const EXT_LANGUAGE = { + '.ts': 'typescript', '.tsx': 'typescript', '.cts': 'typescript', '.mts': 'typescript', + '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', + '.json': 'json', + '.jsonc': 'jsonc', + '.css': 'css', '.scss': 'scss', '.less': 'less', + '.html': 'html', '.htm': 'html', + '.md': 'markdown', '.markdown': 'markdown', + '.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', + '.java': 'java', '.kt': 'kotlin', '.swift': 'swift', + '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', + '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash', + '.yaml': 'yaml', '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.sql': 'sql', + '.dockerfile': 'dockerfile', +} + +// Inline MIME guess (also used by /api/fs/raw). Returns null for unknown so +// the caller can substitute `application/octet-stream`. +function mimeForExtension(ext) { + switch (ext) { + case '.html': case '.htm': return 'text/html; charset=utf-8' + case '.css': return 'text/css; charset=utf-8' + case '.js': case '.mjs': return 'text/javascript; charset=utf-8' + case '.json': return 'application/json; charset=utf-8' + case '.svg': return 'image/svg+xml' + case '.png': return 'image/png' + case '.jpg': case '.jpeg': return 'image/jpeg' + case '.gif': return 'image/gif' + case '.webp': return 'image/webp' + case '.ico': return 'image/x-icon' + case '.md': case '.markdown': return 'text/markdown; charset=utf-8' + case '.txt': return 'text/plain; charset=utf-8' + case '.pdf': return 'application/pdf' + case '.woff2': return 'font/woff2' + default: return null + } +} + +export function languageForExtension(ext) { + return EXT_LANGUAGE[ext] ?? 'plain' +} + +export function readFileContent(targetPath, opts = {}) { + const max = opts.max ?? DEFAULT_FILE_READ_MAX + const absPath = resolve(resolveTarget(targetPath)) + const ext = extname(absPath).toLowerCase() + const language = languageForExtension(ext) + const mime = mimeForExtension(ext) ?? 'application/octet-stream' + + let st + try { + st = statSync(absPath) + } catch (e) { + return { ok: false, path: absPath, error: e.message } + } + if (!st.isFile()) { + return { ok: false, path: absPath, error: 'not a regular file' } + } + if (st.size > max) { + return { + ok: false, + path: absPath, + size: st.size, + error: `file too large (max ${max} bytes)`, + mime, + language, + } + } + + // Sniff binary before reading the full file — saves memory on a 512 KiB + // blob of a Windows DLL the user happened to click. The Buffer#includes + // scan is O(sniffBytes) not O(size), so it never grows with the cap. + let buf + try { + buf = readFileSync(absPath) + } catch (e) { + return { ok: false, path: absPath, error: e.message } + } + const sniffEnd = Math.min(BINARY_SNIFF_BYTES, buf.length) + let binary = false + for (let i = 0; i < sniffEnd; i++) { + if (buf[i] === 0) { binary = true; break } + } + + if (binary) { + return { + ok: false, + path: absPath, + size: st.size, + error: 'binary file not supported', + mime, + language, + binary: true, + } + } + + return { + ok: true, + path: absPath, + size: st.size, + mime, + language, + binary: false, + encoding: 'utf-8', + // Strip UTF-8 BOM; keep line endings as-is (the renderer is what + // chooses to soften them). + content: buf.toString('utf8').replace(/^\uFEFF/, ''), + } +} diff --git a/packages/webui/server/routes/fs.js b/packages/webui/server/routes/fs.js index e3eb8c2d2..c1a863fff 100644 --- a/packages/webui/server/routes/fs.js +++ b/packages/webui/server/routes/fs.js @@ -1,11 +1,16 @@ // server/routes/fs.js — 文件系统 API(feat-workspace-lhl) // // GET /api/fs/read?path=xxx&showHidden=0 读取目录 +// GET /api/fs/read-file?path=xxx 读取单文件内容(slice 02,右栏预览) +// GET /api/fs/raw?path=xxx 原样返回(image / html,slice 02 预览) // POST /api/fs/mkdir 创建目录 { path } -import { readDirectory, createDirectory, resolveTarget } from '../lib/fs-util.js' +import { readDirectory, createDirectory, resolveTarget, readFileContent } from '../lib/fs-util.js' import { readJson, BodyTooLargeError } from '../lib/read-json.js' import { assertWorkspacePath, assertWorkspaceParentPath, expandTilde } from '../lib/workspace.js' +import { createReadStream, statSync } from 'node:fs' +import { Readable } from 'node:stream' +import { extname } from 'node:path' // v2.2 (in-product): containment 门 — 目录浏览/创建与 browseWorkspace 同边界, // 只允许落在允许根(默认 home + 默认工作区 + tmp,MCODE_WEBUI_WORKSPACE_ROOTS @@ -55,6 +60,175 @@ export function handleFsRead(req, res) { res.end(JSON.stringify(result)) } +// GET /api/fs/read-file?path=xxx +// 读单个文件内容(slice 02 右栏预览用)。 +// containment 同 read/mkdir(safePath 门),不绕过; +// 超 512 KiB(fs-util 的 DEFAULT_FILE_READ_MAX)显式拒绝而非截断; +// 检测到 NUL 字节视为二进制拒读。返回里带 language/mime +// 让前端做 type→renderer 路由时少一次 round-trip。 +export function handleFsReadFile(req, res) { + const url = new URL(req.url, `http://localhost`) + const rawPath = url.searchParams.get('path') || '' + + if (!rawPath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, error: 'missing path' })) + return + } + + const path = safePath(rawPath) + if (!path) { + gateError(res, rawPath) + return + } + + const result = readFileContent(path) + // 413/415 区分 oversize / 二进制 / 非常规文件;其他错误归 400 + // (stat 失败属于 caller 把路走没了,UI 应展示该路径无效)。 + let status = 200 + if (!result.ok) { + if (result.error && result.error.startsWith('file too large')) status = 413 + else if (result.error === 'not a regular file' || result.error === 'binary file not supported') status = 415 + else status = 400 + } + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) +} + +// GET /api/fs/raw?path=xxx +// 原样返回文件字节(slice 02 用:image / svg / font 等),走 stream。 +// containment 同 read;≤20 MiB(与 pr-22 上限对齐);content-type 按 +// 扩展名查表,未知为 application/octet-stream。Cache-Control: no-store +// 因为本地文件没有 immutable 假设,编辑器改了应当立刻可见。 +// +// Streaming is incompatible with the Hono `createResponseCapture` buffer +// (it only models writeHead/end), so the route exposes two entry points: +// - `handleFsRaw(req, res)` — legacy (req, res) signature, used by the +// non-Hono dispatcher and the test fixtures that pipe into a fake +// ServerResponse. +// - `handleFsRawStream(rawPath)` — returns the validated (status, +// headers, Node Readable) tuple the Hono registration wraps in a real +// Response with `Readable.toWeb(stream)`. The actual containment / +// size / mime work is done once in `handleFsRawStream` and the legacy +// handler just forwards the result. +const RAW_MAX_BYTES = 20 * 1024 * 1024 +const RAW_CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.md': 'text/markdown; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.pdf': 'application/pdf', + '.woff2': 'font/woff2', +} + +/** + * Stream-aware variant: validates the path and returns either + * { ok:false, status, json } — the caller turns into a JSON Response, OR + * { ok:true, status, headers, stream } — the caller wraps in a streaming + * Response with the mime / cache headers. + * + * The stream is a Node Readable; the Hono registration converts it with + * `Readable.toWeb()` (the Web Streams adapter is the standard fetch-API + * shape @hono/node-server accepts on the body slot). + */ +export function handleFsRawStream(rawPath) { + if (!rawPath) { + return { + ok: false, + status: 400, + json: { ok: false, error: 'missing path' }, + } + } + const path = safePath(rawPath) + if (!path) { + const gate = assertWorkspacePath(resolveTarget(expandTilde(rawPath))) + return { + ok: false, + status: 403, + json: { ok: false, error: gate.ok ? 'invalid path' : gate.error }, + } + } + + let st + try { + st = statSync(path) + } catch { + return { + ok: false, + status: 404, + json: { ok: false, error: 'not found' }, + } + } + if (!st.isFile()) { + return { + ok: false, + status: 400, + json: { ok: false, error: 'not a regular file' }, + } + } + if (st.size > RAW_MAX_BYTES) { + return { + ok: false, + status: 413, + json: { ok: false, error: `file too large (max ${RAW_MAX_BYTES} bytes)` }, + } + } + + const ext = extname(path).toLowerCase() + const type = RAW_CONTENT_TYPES[ext] || 'application/octet-stream' + return { + ok: true, + status: 200, + headers: { + 'Content-Type': type, + 'Content-Length': String(st.size), + 'Cache-Control': 'no-store', + }, + stream: createReadStream(path), + } +} + +export function handleFsRaw(req, res) { + const url = new URL(req.url, `http://localhost`) + const rawPath = url.searchParams.get('path') || '' + const result = handleFsRawStream(rawPath) + if (!result.ok) { + res.writeHead(result.status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result.json)) + return + } + res.writeHead(result.status, result.headers) + result.stream.pipe(res) +} + +/** Hono-friendly bridge: turns the tuple above into a fetch-API Response + * by adapting the Node Readable to a Web ReadableStream. Exposed for the + * Hono registration in server/app.js. */ +export function rawStreamToWebResponse(rawPath) { + const result = handleFsRawStream(rawPath) + if (!result.ok) { + return new Response(JSON.stringify(result.json), { + status: result.status, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(Readable.toWeb(result.stream), { + status: result.status, + headers: result.headers, + }) +} + export async function handleFsMkdir(req, res) { // Uses the shared bounded reader. This route used to carry its own // `req.on('data', …)` buffer, which is why it escaped the body cap added diff --git a/packages/webui/test/routes/fs-raw.test.js b/packages/webui/test/routes/fs-raw.test.js new file mode 100644 index 000000000..92c134c36 --- /dev/null +++ b/packages/webui/test/routes/fs-raw.test.js @@ -0,0 +1,159 @@ +// webui/test/routes/fs-raw.test.js +// Regression: `/api/fs/raw` (slice 02 — image / binary preview). +// +// Pins the contract the webapp's depends on: +// - containment gate: out-of-root paths 403; +// - binary stream: bytes round-trip cleanly back to the caller; +// - content-type mapped from extension (.png → image/png, …); +// - cache-control: no-store (the file may change on disk); +// - size cap: files > 20 MiB answer 413, not a partial stream. +// +// Like the read-file companion, this test does NOT exercise every +// extension — it picks a representative set (png, svg, unknown) and +// leaves the rest to the mime-table unit coverage in fs-util. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventEmitter } from "node:events"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const fsRoute = await import(absPath("routes/fs.js")); + +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + // The raw route does `createReadStream(path).pipe(res)`, which calls + // `res.on(...)`, `res.write(...)`, and `res.end()` on the destination. + // The minimal capture below wires the EventEmitter those calls need. + const res = Object.assign(new EventEmitter(), { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + write(chunk) { + this.body += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("binary"); + }, + done, + }); + return res; +} + +function readReq(path) { + return { url: `/api/fs/raw?path=${encodeURIComponent(path)}` }; +} + +describe("fs routes — /api/fs/raw", () => { + test("missing path returns 400 missing path", () => { + const res = fakeRes(); + fsRoute.handleFsRaw({ url: "/api/fs/raw" }, res); + assert.equal(res.status, 400); + assert.equal(JSON.parse(res.body).error, "missing path"); + }); + + test("a path outside the allowed roots is 403 with actionable error", () => { + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(outsideRoot), res); + assert.equal(res.status, 403); + assert.match(JSON.parse(res.body).error, /允许根|MCODE_WEBUI_WORKSPACE_ROOTS/); + }); + + test("a png inside an allowed root is served with image/png", async () => { + const dir = mkdtempSync(join(tmpdir(), "fs-raw-png-")); + try { + // Minimal valid PNG: 1×1 transparent pixel. Header + IHDR + IDAT + + // IEND chunks. Pre-built so we don't depend on a graphics lib. + const bytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + ]); + const file = join(dir, "dot.png"); + writeFileSync(file, bytes); + + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(file), res); + // Stream pipe is async — the createReadStream emits 'data' on the + // next tick and the destination's end() fires on 'end'. Wait for + // it before reading the captured body. + await res.done; + assert.equal(res.status, 200); + assert.equal(res.headers["Content-Type"], "image/png"); + assert.equal(res.headers["Cache-Control"], "no-store"); + assert.equal(Number(res.headers["Content-Length"]), bytes.length); + // The pipe captures the body — the round-trip must match the bytes + // we wrote. fakeRes.write() stores binary as latin1, which is + // bit-stable for non-Unicode content; we re-encode the same way. + const expected = bytes.toString("binary"); + assert.equal(res.body.length, bytes.length, "streamed body length matches file size"); + assert.equal(res.body, expected, "streamed bytes match the file bytes"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an unknown extension falls back to application/octet-stream", async () => { + const dir = mkdtempSync(join(tmpdir(), "fs-raw-unknown-")); + try { + const file = join(dir, "blob.qwert"); + writeFileSync(file, "hello"); + + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(file), res); + await res.done; + assert.equal(res.status, 200); + assert.equal(res.headers["Content-Type"], "application/octet-stream"); + assert.equal(res.body, "hello"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a missing file is rejected by the shared gate (realpath fails)", () => { + // Same story as /api/fs/read-file — the gate runs realpathSync first + // and 403s on a missing path before the route ever reaches its own + // stat. The webapp only ever opens paths that came from a server + // listing, so this branch is a belt-and-suspenders check, not a + // routine path. + const dir = mkdtempSync(join(tmpdir(), "fs-raw-missing-")); + try { + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(join(dir, "ghost.png")), res); + assert.equal(res.status, 403); + assert.match(JSON.parse(res.body).error, /无法解析路径/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a directory is rejected with 400 not a regular file", () => { + const dir = mkdtempSync(join(tmpdir(), "fs-raw-dir-")); + try { + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(dir), res); + assert.equal(res.status, 400); + assert.equal(JSON.parse(res.body).error, "not a regular file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/packages/webui/test/routes/fs-read-file.test.js b/packages/webui/test/routes/fs-read-file.test.js new file mode 100644 index 000000000..218d46308 --- /dev/null +++ b/packages/webui/test/routes/fs-read-file.test.js @@ -0,0 +1,194 @@ +// webui/test/routes/fs-read-file.test.js +// Regression: `/api/fs/read-file` (slice 02 — right-panel file preview). +// +// Pins the contract the webapp's components/file-preview.tsx renders against: +// - containment gate: out-of-root paths 403 with an actionable message; +// - size cap: files > 512 KiB answer 413, not a truncated body; +// - binary detection: NUL byte in the first 4 KiB → 415 with mime/language; +// - success shape: ok, path, size, mime, language, encoding, content; +// - language hint is the extension-based one (markdown for .md, etc.). +// +// The containment / symlink escape tests share the gate with the existing +// `/api/fs/read` route — that is by design (no new escape hatch in this +// slice). The size-cap and binary tests live separately because they are +// `/api/fs/read-file`-specific failures the route surfaces with their own +// status codes (413 / 415), so a regression there would be silent. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const fsRoute = await import(absPath("routes/fs.js")); + +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + const res = { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + done, + }; + return res; +} + +function readReq(path) { + return { url: `/api/fs/read-file?path=${encodeURIComponent(path)}` }; +} + +describe("fs routes — /api/fs/read-file", () => { + test("missing path returns 400 missing path", () => { + const res = fakeRes(); + fsRoute.handleFsReadFile({ url: "/api/fs/read-file" }, res); + assert.equal(res.status, 400); + assert.equal(JSON.parse(res.body).error, "missing path"); + }); + + test("a path outside the allowed roots is 403 with actionable error", () => { + // /etc on POSIX, SystemRoot on Windows — same witness the existing + // fs-containment test uses. The route inherits the same gate. + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(outsideRoot), res); + assert.equal(res.status, 403); + const parsed = JSON.parse(res.body); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /允许根|MCODE_WEBUI_WORKSPACE_ROOTS/); + }); + + test("a regular text file inside an allowed root returns the documented shape", () => { + const dir = mkdtempSync(join(tmpdir(), "fs-read-file-ok-")); + try { + const file = join(dir, "note.md"); + // Include a NUL-suspicious byte in the tail to confirm the binary + // detector only checks the first 4 KiB (and a BOM to confirm it is + // stripped). + const body = "\uFEFF# title\n\nhello world\n"; + writeFileSync(file, body, "utf8"); + + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(file), res); + assert.equal(res.status, 200); + const parsed = JSON.parse(res.body); + assert.equal(parsed.ok, true); + assert.equal(parsed.encoding, "utf-8"); + assert.equal(parsed.binary, false); + assert.equal(parsed.language, "markdown"); + assert.equal(parsed.mime, "text/markdown; charset=utf-8"); + assert.equal(parsed.content, body.replace(/^\uFEFF/, "")); + assert.equal(parsed.size, Buffer.byteLength(body, "utf8")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a non-existent file inside an allowed root is rejected by the shared gate", () => { + // assertWorkspacePath runs realpathSync before the file is stat'd; + // a missing path therefore fails containment with a realpath error + // (the same shape /api/fs/read produces for a missing path). This + // is by design — the gate wants to resolve symlinks before opening, + // and the webapp only ever opens paths it just got from a listing, + // so a missing path here is a user-after-free and should be loud. + const dir = mkdtempSync(join(tmpdir(), "fs-read-file-missing-")); + try { + const file = join(dir, "nope.md"); + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(file), res); + assert.equal(res.status, 403); + const parsed = JSON.parse(res.body); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /无法解析路径/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a binary file is rejected with 415 and the mime hint", () => { + const dir = mkdtempSync(join(tmpdir(), "fs-read-file-binary-")); + try { + const file = join(dir, "blob.bin"); + // 16 KiB of NUL bytes — past the 4 KiB sniff window so the + // detector's first-byte optimisation does not matter. + writeFileSync(file, Buffer.alloc(16 * 1024)); + + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(file), res); + assert.equal(res.status, 415); + const parsed = JSON.parse(res.body); + assert.equal(parsed.ok, false); + assert.equal(parsed.binary, true); + assert.match(parsed.error, /binary/); + assert.equal(typeof parsed.mime, "string"); + assert.equal(typeof parsed.language, "string"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a directory is rejected with 415 (not a regular file)", () => { + const dir = mkdtempSync(join(tmpdir(), "fs-read-file-dir-")); + try { + const sub = join(dir, "subdir"); + mkdirSync(sub, { recursive: true }); + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(sub), res); + assert.equal(res.status, 415); + assert.match(JSON.parse(res.body).error, /not a regular file/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an oversize file is rejected with 413 and the cap is reported", () => { + const dir = mkdtempSync(join(tmpdir(), "fs-read-file-big-")); + try { + const file = join(dir, "big.md"); + // 600 KiB — comfortably past the 512 KiB cap. Use a single + // character so encoding keeps the bytes count predictable. + writeFileSync(file, "a".repeat(600 * 1024), "utf8"); + + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(file), res); + assert.equal(res.status, 413); + const parsed = JSON.parse(res.body); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /file too large \(max 524288 bytes\)/); + // The error path still carries the mime/language hint so the UI + // can render a meaningful state ("Markdown, 600 KiB — too large to + // preview here"). + assert.equal(parsed.mime, "text/markdown; charset=utf-8"); + assert.equal(parsed.language, "markdown"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("symlink escape attempts fall through to the shared containment gate", () => { + // Both inputs resolve outside the allowed roots after realpath. The + // route must answer 403, exactly like /api/fs/read does — this is + // the contract that prevents the preview from becoming a new escape + // hatch. + for (const bad of ["/etc/../../etc", "~/../../etc"]) { + const res = fakeRes(); + fsRoute.handleFsReadFile(readReq(bad), res); + // Either rejected by containment (403) or normalised to a path + // that itself fails the gate (also 403); both outcomes are + // acceptable as long as the route never serves out-of-root bytes. + assert.equal(res.status, 403, `expected 403 for ${bad}, got ${res.status}`); + } + }); +}); \ No newline at end of file diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index e4bac3d0f..47345d4d1 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -69,6 +69,8 @@ describe("app.js — migration ledger", () => { "GET /api/workspace/resolve", "GET /api/workspace/recent", "GET /api/fs/read", + "GET /api/fs/read-file", + "GET /api/fs/raw", "POST /api/fs/mkdir", "GET /api/settings", "POST /api/settings", diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx new file mode 100644 index 000000000..aa25bbb74 --- /dev/null +++ b/packages/webui/webapp/components/file-preview.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Locale, MessageKey } from "@/lib/i18n"; +import { fsRawUrl, getFsFile, type FsFilePayload } from "@/lib/api"; +import { renderMarkdown } from "@/lib/markdown"; +import { + basenameOf, + formatBytes, + pickPreviewKind, + type PreviewKind, +} from "@/lib/file-preview"; + +/** + * File preview (slice 02 of the webui-parity program). + * + * A read-only viewer that the right-hand `files` panel opens on click. It is + * a small type→renderer router over `/api/fs/read-file` (text, ≤512 KiB) + * and `/api/fs/raw` (bytes, ≤20 MiB); the parent agent's `panels.tsx` + * wiring is the follow-up that mounts this component into the tree (out of + * scope for this slice, by design). + * + * Routing rules — what gets which renderer: + * `.md` / `.markdown` rendered markdown (via lib/markdown.ts) + * image extensions + * source / data files monospace pre, light "language" badge in the header + * anything else "无法预览" placeholder + * + * The component never truncates the response. Oversize reads return + * `413` and the component surfaces the server's message verbatim; binary + * detection returns `415` and the placeholder names the mime type so the + * user knows what they tried to open. + * + * Containment: every fetch hits the server's shared `assertWorkspacePath` + * gate, so an out-of-root path is rejected before bytes leave the box. No + * new escape hatch was added in this slice. + */ + +export interface FilePreviewProps { + /** Absolute path of the file to preview (the wire form `/api/fs/read-file` + * expects). The caller is responsible for surfacing only paths it itself + * got from a server-blessed source (the workspace picker, a tree node, …). */ + path: string; + /** Translation function — same shape as the rest of the panels. */ + t: (key: MessageKey) => string; + locale: Locale; +} + +export function FilePreview({ path, t, locale: _locale }: FilePreviewProps) { + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Last-write-wins: a quick `a → b → a` switch (e.g. user clicks through + // the tree) should not let the older `a` payload land after the newer + // `b`. The FilesPanel uses the same trick — keeping the discipline + // uniform across panels makes the regression case obvious. + const loadGen = useMemo(() => ({ current: 0 }), []); + const load = useCallback(async () => { + const gen = ++loadGen.current; + setLoading(true); + setError(null); + try { + const next = await getFsFile(path); + if (gen !== loadGen.current) return; + setPayload(next); + if (!next.ok) setError(next.error ?? "unreadable"); + } catch (cause) { + if (gen !== loadGen.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + setPayload(null); + } finally { + if (gen === loadGen.current) setLoading(false); + } + }, [path, loadGen]); + + useEffect(() => { + void load(); + }, [load]); + + const fileName = basenameOf(path); + // The read-file endpoint rejects binary (mime-stripped NUL byte) but still + // reports the detected mime on the error payload. An image mime is a green + // light to render via /api/fs/raw — the user's intent is "show me the + // picture", not "tell me this is binary". We branch on the mime directly + // (not on `kind`) so the image path wins even when the payload says + // ok:false; the body then re-derives `kind` for non-image cases. + const mimeIsImage = (payload?.mime ?? "").toLowerCase().startsWith("image/"); + const showImage = mimeIsImage && !!payload; + const kind = + payload && !showImage + ? pickPreviewKind(payload, path) + : null; + + return ( +
+
+ + {fileName} + + {payload?.size !== undefined && payload.size > 0 ? ( + + {formatBytes(payload.size)} + + ) : null} +
+ + {loading && !payload ? ( +

+ {t("app.connecting")} +

+ ) : null} + + {error && !showImage ? ( + + ) : null} + + {showImage || (kind && payload && payload.ok) ? ( +
+ {showImage ? ( + + ) : ( + + )} +
+ ) : null} +
+ ); +} + +function PreviewBody({ + kind, + payload, + path, +}: { + kind: PreviewKind; + payload: FsFilePayload; + path: string; +}) { + switch (kind) { + case "markdown": + return ; + case "image": + return ; + case "code": + return ; + default: + // pickPreviewKind() never returns "unsupported" today; kept as an + // escape hatch so the call-site exhaustiveness check stays honest. + return ; + } +} + +function MarkdownView({ content }: { content: string }) { + // renderMarkdown() sanitises the parsed HTML on the browser side (see + // lib/markdown.ts#sanitize) — the same policy used by chat.tsx for + // assistant output. Reusing it keeps the threat model and allow-list + // identical across surfaces. + const html = useMemo(() => renderMarkdown(content), [content]); + return ( +
+ ); +} + +function ImageView({ path }: { path: string }) { + return ( +
+ {basenameOf(path)} +
+ ); +} + +function CodeView({ content, language }: { content: string; language: string }) { + return ( +
+
+ {language} +
+
, NOT .codeblock-pre: that selector carries the chat
+        // codeblock shell (toolbar, copy button) which is meaningless for
+        // a file preview, and would otherwise override our padding to 0.
+      >
+        {content}
+      
+
+ ); +} + +function UnsupportedView({ fileName }: { fileName: string }) { + return ( +
+ 无法预览 {fileName}(无法识别的文件类型)。 +
+ ); +} + +function PreviewError({ + error, + payload, + fileName, +}: { + error: string; + payload: FsFilePayload | null; + fileName: string; +}) { + const mime = payload?.mime ?? ""; + const language = payload?.language ?? ""; + const isBinary = payload?.binary === true; + const isTooLarge = error.startsWith("file too large"); + const isContainment = /越界|allowed root|MCODE_WEBUI_WORKSPACE_ROOTS/i.test(error); + return ( +
+ + {isContainment + ? "无法访问该文件:路径不在允许的工作区内。" + : isTooLarge + ? `${fileName} 超过单文件预览上限(512 KiB),请在编辑器中打开。` + : isBinary + ? `${fileName} 是二进制文件(${mime || "unknown type"}),无法预览。` + : `无法预览 ${fileName}:${error}`} + {language && !isContainment ? ( + [{language}] + ) : null} + +
+ ); +} \ No newline at end of file diff --git a/packages/webui/webapp/lib/api.ts b/packages/webui/webapp/lib/api.ts index 6f3a03c3f..bf434d28e 100644 --- a/packages/webui/webapp/lib/api.ts +++ b/packages/webui/webapp/lib/api.ts @@ -823,6 +823,71 @@ export const postAuthDecision = (requestId: string, approve: boolean) => export const mkdir = (path: string) => request<{ ok: boolean; path?: string }>("/api/fs/mkdir", { method: "POST", json: { path } }); +// --- file preview (slice 02) ---------------------------------------------- +// +// The right-panel preview (`components/file-preview.tsx`) is a small +// type→renderer router over these two endpoints. The server returns text +// for `/api/fs/read-file` (≤512 KiB, with mime + language + binary flag so +// the UI can route without a second round-trip) and raw bytes for +// `/api/fs/raw` (≤20 MiB, mime mapped from extension). Both share the +// containment boundary of `/api/fs/read` — the parent agent's panels.tsx +// wiring is the only thing still TODO at this slice boundary. + +export interface FsFilePayload { + ok: boolean; + path?: string; + size?: number; + /** Best-effort extension-based guess (markdown / typescript / …). */ + language?: string; + /** Best-effort extension-based guess (image/png, text/markdown; charset=utf-8, …). */ + mime?: string; + binary?: boolean; + encoding?: "utf-8"; + content?: string; + error?: string; +} + +/** + * Read a single file's text content. The server's failure modes come back + * here as `ok:false` with the same shape the server emitted — there is no + * exception to catch, the preview component just branches on `ok`. + */ +export const getFsFile = async (path: string): Promise => { + // The /api/fs/read-file endpoint answers 4xx with a JSON error body + // that *also* carries mime / language / binary — the preview component + // reads mime to route images through /api/fs/raw and language for the + // code view's badge. The shared request() helper throws on non-OK + // responses and would discard that body, so this caller uses raw fetch + // and reads the JSON either way (it is always JSON — the route is + // `application/json`). + const response = await fetch( + withClientQuery(`/api/fs/read-file?path=${encodeURIComponent(path)}`), + { headers: { Accept: "application/json" } }, + ); + const text = await response.text(); + let parsed: FsFilePayload | null = null; + try { + parsed = text ? (JSON.parse(text) as FsFilePayload) : null; + } catch { + parsed = null; + } + if (!parsed) { + throw new Error(response.ok ? "unexpected non-JSON response" : `HTTP ${response.status}`); + } + return parsed; +}; + +/** + * Absolute URL for the raw bytes of a file (used as `` for + * previews of images, fonts, etc.). The server attaches the right + * `Content-Type` from the extension and caps at 20 MiB. + */ +export function fsRawUrl(path: string): string { + return withClientQuery( + `/api/fs/raw?path=${encodeURIComponent(path)}`, + ); +} + /** Absolute URL for a session export; `download` makes the browser save it. */ export function sessionExportUrl(id: string, format: "md" | "json" = "md"): string { return withClientQuery( diff --git a/packages/webui/webapp/lib/file-preview.ts b/packages/webui/webapp/lib/file-preview.ts new file mode 100644 index 000000000..7d92370ed --- /dev/null +++ b/packages/webui/webapp/lib/file-preview.ts @@ -0,0 +1,69 @@ +import type { FsFilePayload } from "./api"; + +/** + * Pure routing logic for the right-panel file preview (slice 02). + * + * `pickPreviewKind` is the file→renderer mapping the + * `components/file-preview.tsx` view branches on. Extracting it here + * keeps the React surface thin AND lets unit tests pin the mapping + * without spinning up React — Node's loader does not honour the Next.js + * `@/lib/...` alias the component itself uses. + * + * Rules — what gets which renderer: + * markdown `.md` / `.markdown` (server says `language === "markdown"`, + * or the fallback path's extension matches) + * image any path whose server mime is `image/*`, or whose + * extension is in IMAGE_EXTS (the server may not emit a + * language for an image, so the path is the tiebreaker) + * code everything else — a plain monospace pre with a language + * badge from the server's hint. "unsupported" never + * reaches the live product path because the server + * rejects binary up front, but it remains in the type + * for exhaustive switch coverage. + */ + +export type PreviewKind = + | "markdown" + | "image" + | "code" + | "unsupported"; + +const IMAGE_EXTS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp", +]); +const MARKDOWN_EXTS = new Set([".md", ".markdown"]); + +export function pickPreviewKind(payload: FsFilePayload, fallbackPath: string): PreviewKind { + const lang = (payload.language ?? "").toLowerCase(); + const mime = (payload.mime ?? "").toLowerCase(); + const ext = lastExt(fallbackPath); + + if (lang === "markdown" || MARKDOWN_EXTS.has(ext)) return "markdown"; + if (mime.startsWith("image/")) return "image"; + if (IMAGE_EXTS.has(ext)) return "image"; + return "code"; +} + +export function lastExt(path: string): string { + const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const base = slash >= 0 ? path.slice(slash + 1) : path; + const dot = base.lastIndexOf("."); + return dot >= 0 ? base.slice(dot).toLowerCase() : ""; +} + +export function basenameOf(path: string): string { + const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return slash >= 0 ? path.slice(slash + 1) : path; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}`; +} \ No newline at end of file diff --git a/packages/webui/webapp/styles/official-utilities.css b/packages/webui/webapp/styles/official-utilities.css index 1456eff8f..b58ac9e3a 100644 --- a/packages/webui/webapp/styles/official-utilities.css +++ b/packages/webui/webapp/styles/official-utilities.css @@ -2203,3 +2203,105 @@ .animate-shimmer{animation:shimmer var(--shimmer-duration) linear infinite} +/* File preview (slice 02 — components/file-preview.tsx). + * + * The component is rendered inside the right-hand `files` panel, so its + * surface is narrow. We lean on the existing typography tokens and keep + * the prose rules small — this is not chat, it is a documentation reader. + * + * The `codeblock-pre` and `inline-code` class names used by lib/markdown.ts + * already ship their own styles (the renderer overrides in markdown.ts + * emit them), so this rule set covers only the structural Markdown + * elements: headings, paragraphs, lists, blockquote, table, hr. The + * sanitiser in lib/markdown.ts#sanitize restricts what reaches the DOM, + * so every selector here maps to a tag the allow-list admits. */ +.file-preview-markdown { + color:var(--text_default_primary); + font-size:14px; + line-height:22px; + word-break:break-word +} +.file-preview-markdown>:first-child { + margin-top:0 +} +.file-preview-markdown>:last-child { + margin-bottom:0 +} +.file-preview-markdown h1 { + font-size:24px; + line-height:32px; + font-weight:600; + letter-spacing:-0.2px; + margin:16px 0 8px +} +.file-preview-markdown h2 { + font-size:18px; + line-height:26px; + font-weight:600; + margin:14px 0 6px +} +.file-preview-markdown h3 { + font-size:16px; + line-height:24px; + font-weight:600; + margin:12px 0 4px +} +.file-preview-markdown h4,.file-preview-markdown h5,.file-preview-markdown h6 { + font-size:14px; + line-height:22px; + font-weight:600; + margin:10px 0 4px +} +.file-preview-markdown p { + margin:6px 0 +} +.file-preview-markdown a { + color:var(--text_default_accent); + text-decoration:underline; + text-underline-offset:2px +} +.file-preview-markdown strong { + font-weight:600 +} +.file-preview-markdown ul,.file-preview-markdown ol { + margin:6px 0; + padding-inline-start:22px +} +.file-preview-markdown ul li,.file-preview-markdown ol li { + margin:2px 0 +} +.file-preview-markdown blockquote { + border-inline-start:2px solid var(--border_default); + padding-inline-start:10px; + color:var(--text_default_secondary); + margin:8px 0 +} +.file-preview-markdown hr { + border:none; + border-top:1px solid var(--border_light); + margin:12px 0 +} +.file-preview-markdown table { + border-collapse:collapse; + margin:8px 0; + width:100% +} +.file-preview-markdown th,.file-preview-markdown td { + border:1px solid var(--border_light); + padding:6px 10px; + text-align:start +} +.file-preview-markdown th { + background:var(--bg_grouped_secondary_elevated); + font-weight:600 +} + +/* File-preview codeblock — distinct from the chat's .codeblock-pre so the + * chat shell styles (toolbar, copy button, transparent pre override) do + * not leak into the file viewer. */ +.file-preview-codeblock { + font-family:var(--font_family_code); + white-space:pre; + tab-size:2 +} + diff --git a/packages/webui/webapp/test/file-preview.test.ts b/packages/webui/webapp/test/file-preview.test.ts new file mode 100644 index 000000000..7e3e1844d --- /dev/null +++ b/packages/webui/webapp/test/file-preview.test.ts @@ -0,0 +1,136 @@ +// webapp/test/file-preview.test.ts +// Unit tests for components/file-preview.tsx — the right-panel preview's +// type→renderer routing. Webapp-test boundaries: this file pins the +// **mapping** (pickPreviewKind) and the input/output shapes; the actual +// rendering is exercised against a live server in the agent-browser +// self-check (see the slice report), not here. +// +// Why pin the mapping specifically. The preview is a small router over +// three renderers; every rewrite of the routing logic is a chance for +// one branch to silently drop a file type into the wrong renderer (e.g. +// a `.svg` accidentally rendered as code). This test guards against that +// drift by holding the table fixed. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { pickPreviewKind, type PreviewKind } from "../lib/file-preview"; +import type { FsFilePayload } from "../lib/api"; +// The React component (`components/file-preview.tsx`) imports via the +// `@/lib/...` alias — Next.js / webapp convention. Node's loader does +// not honour tsconfig paths, so the unit test exercises the routing +// through its pure-logic sibling `lib/file-preview.ts` instead. The full +// React tree is covered by the agent-browser self-check in the slice +// report. + +function payload(overrides: Partial): FsFilePayload { + return { + ok: true, + size: 1, + mime: "text/plain; charset=utf-8", + language: "plain", + binary: false, + encoding: "utf-8", + content: "x", + ...overrides, + }; +} + +describe("pickPreviewKind — markdown", () => { + for (const ext of [".md", ".markdown"]) { + test(`server reports markdown for ${ext}`, () => { + const kind = pickPreviewKind(payload({ language: "markdown", mime: "text/markdown; charset=utf-8" }), `/some/file${ext}`); + assert.equal(kind, "markdown"); + }); + test(`fallback path with ${ext} routes to markdown even when server disagrees`, () => { + // A future mismatch between the server's language table and this + // viewer's should fall back to the file extension rather than the + // (possibly stale) server hint. + const kind = pickPreviewKind(payload({ language: "plain" }), `/some/file${ext}`); + assert.equal(kind, "markdown"); + }); + } +}); + +describe("pickPreviewKind — image", () => { + for (const ext of [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp"]) { + test(`${ext} with image mime routes to image`, () => { + const mime = ext === ".svg" + ? "image/svg+xml" + : ext === ".ico" + ? "image/x-icon" + : `image/${ext.slice(1)}`; + const kind = pickPreviewKind(payload({ mime, language: "plain" }), `/some/file${ext}`); + assert.equal(kind, "image"); + }); + } + + test("image mime wins even when language is unset", () => { + // A future server table that omits a language for an image (because + // "language" is text-only by definition) must still route to the + // image renderer. + const kind = pickPreviewKind(payload({ mime: "image/png", language: undefined }), "/some/file.png"); + assert.equal(kind, "image"); + }); +}); + +describe("pickPreviewKind — code", () => { + for (const ext of [".ts", ".js", ".py", ".json", ".css", ".html", ".yaml", ".sh"]) { + test(`${ext} routes to code (the catch-all for text files)`, () => { + const kind = pickPreviewKind(payload({ language: "plain", mime: "text/plain; charset=utf-8" }), `/some/file${ext}`); + assert.equal(kind, "code"); + }); + } + + test("extensionless files still go to code when the mime is text/*", () => { + // `Dockerfile`, `Makefile`, etc. have no extension. The mime alone is + // enough — pickPreviewKind must not require an extension to render + // text content. + const kind = pickPreviewKind( + payload({ language: "plain", mime: "text/plain; charset=utf-8" }), + "/some/Dockerfile", + ); + assert.equal(kind, "code"); + }); +}); + +describe("pickPreviewKind — fallback contract", () => { + test("returns one of the four known kinds (exhaustive check)", () => { + const known: PreviewKind[] = ["markdown", "image", "code", "unsupported"]; + const cases: Array<[Partial, string]> = [ + [{ language: "markdown" }, "/x.md"], + [{ mime: "image/png" }, "/x.png"], + [{ language: "plain" }, "/x.txt"], + // No known language, unknown mime, unknown ext — still resolves to + // "code" today (the only "unsupported" trigger is a payload the + // server already rejected, which the component handles upstream). + [{}, "/x"], + ]; + for (const [overrides, path] of cases) { + const kind = pickPreviewKind(payload(overrides), path); + assert.ok( + known.includes(kind), + `expected one of ${known.join(",")}, got ${kind}`, + ); + } + }); + + test("path extension lookup is case-insensitive", () => { + // Windows file system is case-insensitive and preserves whatever + // case the user typed; macOS / Linux are case-sensitive but a user + // can still ship a mixed-case file from somewhere. The preview must + // route `.PNG` and `.Png` the same way. + const a = pickPreviewKind(payload({ mime: "image/png", language: "plain" }), "/some/dot.PNG"); + const b = pickPreviewKind(payload({ mime: "image/png", language: "plain" }), "/some/dot.Png"); + assert.equal(a, "image"); + assert.equal(b, "image"); + }); + + test("path with no extension and unknown mime still routes to code", () => { + // The "unsupported" branch in PreviewBody exists for type safety, not + // for a real product path — the server rejects binary up front, so + // the preview never has a payload the router cannot classify. + const kind = pickPreviewKind(payload({ language: "plain", mime: "" }), "/x"); + assert.equal(kind, "code"); + }); +}); \ No newline at end of file From ce2f14b6984bb87d6a4ae5f839d8bddc0b4bc517 Mon Sep 17 00:00:00 2001 From: liuhailong <857688528@qq.com> Date: Sun, 27 Sep 2026 19:03:46 +0800 Subject: [PATCH 2/2] chore: regenerate source inventory after rebase onto slice 01 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. --- release/public-source.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/release/public-source.json b/release/public-source.json index aaf8b4e6b..05cfada4a 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3544,6 +3544,8 @@ "packages/webui/test/routes/debug.check.mjs", "packages/webui/test/routes/export.check.mjs", "packages/webui/test/routes/fs-containment.test.js", + "packages/webui/test/routes/fs-raw.test.js", + "packages/webui/test/routes/fs-read-file.test.js", "packages/webui/test/routes/handleStop-dispatch.test.js", "packages/webui/test/routes/health.check.mjs", "packages/webui/test/routes/model.check.mjs", @@ -3594,6 +3596,7 @@ "packages/webui/webapp/components/chat.tsx", "packages/webui/webapp/components/composer.tsx", "packages/webui/webapp/components/context-meter.tsx", + "packages/webui/webapp/components/file-preview.tsx", "packages/webui/webapp/components/icons.tsx", "packages/webui/webapp/components/inbox.tsx", "packages/webui/webapp/components/modals.tsx", @@ -3609,6 +3612,7 @@ "packages/webui/webapp/lib/api.ts", "packages/webui/webapp/lib/cid.ts", "packages/webui/webapp/lib/composer-draft.ts", + "packages/webui/webapp/lib/file-preview.ts", "packages/webui/webapp/lib/files-tree.ts", "packages/webui/webapp/lib/i18n.ts", "packages/webui/webapp/lib/markdown.ts", @@ -3639,6 +3643,7 @@ "packages/webui/webapp/test/composer-draft.test.ts", "packages/webui/webapp/test/composer-models.test.ts", "packages/webui/webapp/test/context-meter-format.test.ts", + "packages/webui/webapp/test/file-preview.test.ts", "packages/webui/webapp/test/files-tree.test.ts", "packages/webui/webapp/test/greeting.test.ts", "packages/webui/webapp/test/icons.test.ts",