Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/webui/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,71 @@ before creating.
**Errors** — 400 invalid JSON; 403 parent out-of-root; 409 already
exists.

### `GET /api/fs/read-file?path=<file>`

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=<file>`

Stream raw bytes for a file. Used by `<img>` 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
Expand Down
18 changes: 18 additions & 0 deletions packages/webui/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
);
Expand Down
143 changes: 141 additions & 2 deletions packages/webui/server/lib/fs-util.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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/, ''),
}
}
Loading
Loading