diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 0652182e..6687bcdf 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -122,6 +122,12 @@ export const OWNED_ROUTES = new Set([ "GET /api/fs/read-file", "GET /api/fs/raw", "POST /api/fs/mkdir", + // Slice 14 — open / reveal in OS file manager. Same containment + // gate as the other /api/fs/* routes (lib/open-target.js); the + // execFile boundary is the only new attack surface, and it never + // touches a shell. + "POST /api/fs/open-default", + "POST /api/fs/reveal", // Git panel (slice 03): right-panel git surface + `/review` parity // surfaces. Containment-gated; execFile (no shell); branch // checkout is allow-list gated. See lib/git.js header. @@ -484,15 +490,27 @@ export function createHonoApp() { // 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. + // invokeHandler entirely. `?download=1` flips the response into + // "save as" mode (slice 14's third action); the same containment + // gate, size cap, and regular-file check still apply. 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); + const download = url.searchParams.get("download") === "1"; + return fsRoute.rawStreamToWebResponse(path, { download }); }); app.post("/api/fs/mkdir", (c) => invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsMkdir), ); + // Slice 14 — open with OS default / reveal in file manager. Containment + // + per-node realpath gated inside lib/open-target.js; the route only + // JSON-decodes the body and maps structured codes to HTTP status. + app.post("/api/fs/open-default", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsOpenDefault), + ); + app.post("/api/fs/reveal", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsReveal), + ); // ----- Git panel (slice 03) ----- app.get("/api/git/status", (c) => diff --git a/packages/webui/server/lib/open-target.js b/packages/webui/server/lib/open-target.js new file mode 100644 index 00000000..ac53509a --- /dev/null +++ b/packages/webui/server/lib/open-target.js @@ -0,0 +1,385 @@ +// webui/server/lib/open-target.js +// +// Helpers for "open this file with the OS" — used by `POST /api/fs/open-default` +// (hand the path off to the platform default application) and +// `POST /api/fs/reveal` (point the file manager at the file's parent directory +// and select the file). Both run inside the same containment gate the other +// `/api/fs/*` routes use. +// +// Security invariants (pinned by `test/routes/fs-open-target.test.js` and the +// ticket acceptance criteria, not just by code review): +// +// 1. **`execFile`, never shell.** The opener is invoked through +// `child_process.execFile(bin, argv, …)` so every argv element is +// passed as a literal to the child process — there is no shell +// metacharacter surface at all. The bin itself is a constant from the +// platform map (`open` on macOS, `start` / `explorer` on Windows, +// `xdg-open` / `gio` on Linux) so user input never enters the +// executable name. +// +// 2. **Containment gate.** Every entry point routes the requested path +// through `assertWorkspacePath` (the same gate `/api/fs/*` uses) and +// a realpath-based per-node containment that mirrors `gateFile` in +// `lib/git.js`. A dangling or external symlink is rejected; a path +// resolving outside the allowed roots is rejected. +// +// 3. **Must be an existing regular file.** Directories, devices, sockets, +// FIFOs and non-existent paths are rejected with a 400. The opener +// receives an absolute, contained, realpath'd regular file — there is +// no way to hand it a directory under any other name. +// +// 4. **Explicit "no opener" error.** When no opener binary is available on +// the host (e.g. a headless Linux container without `xdg-open` / +// `gio`) the helper answers `{ ok:false, code:"no-opener" }` instead +// of spawning something that ENOENTs. The route returns that as a +// structured error so the UI can disable the button rather than +// silently failing on click. + +import { execFile } from "node:child_process" +import { existsSync, lstatSync, realpathSync, statSync } from "node:fs" +import { dirname, isAbsolute, relative, resolve, sep } from "node:path" +import { assertWorkspacePath, assertWorkspaceParentPath } from "./workspace.js" + +const SPAWN_TIMEOUT_MS = 5000 +// `xdg-open` and friends fork-and-exit almost immediately; a 5-second +// window is generous (a hung opener is a platform bug, not user input). + +/** + * Per-platform opener recipe. Each entry is the argv vector the helper + * passes to `execFile` — argv elements are literals, no shell, and the + * binary is the first element (looked up via `which`-style probing before + * the call so a headless host can answer without spawning). + * + * Linux has two real contenders: + * - `xdg-open` (most desktop environments; the freedesktop standard). + * - `gio open` (the GNOME-side fallback that ships even when xdg-open + * is missing — common on Alpine / minimal images). + * + * The helper tries both, in order, and the first one that the host has on + * PATH wins. macOS uses `open`, Windows uses `cmd.exe /c start ""` — + * `start` is a cmd builtin, so we have to spawn the shell; the argv + * sequence below is the canonical "start with empty title" idiom, which + * is what the Win32 docs recommend to keep the literal next argument from + * being interpreted as the window title. + */ +const PLATFORM_OPENERS = { + darwin: [{ bin: "open", prefix: [] }], + linux: [ + { bin: "xdg-open", prefix: [] }, + { bin: "gio", prefix: ["open"] }, + ], + win32: [{ bin: "cmd.exe", prefix: ["/c", "start", ""] }], +} + +/** + * Reveal recipes. macOS's `open -R` is the canonical "reveal in Finder" + * idiom. Windows uses `explorer.exe /select,` — note the lack of + * space after the comma, which is what `explorer` expects. Linux file + * managers don't have a single "reveal" command, so we open the parent + * directory with the same opener the open-default path uses — that is the + * honest cross-platform fallback (the UI explains it). + */ +const PLATFORM_REVEALERS = { + darwin: [{ bin: "open", prefix: ["-R"] }], + win32: [{ bin: "explorer.exe", prefix: ["/select,"], argvSuffix: true }], + linux: [ + { bin: "xdg-open", prefix: [] }, + { bin: "gio", prefix: ["open"] }, + ], +} + +/** + * Locate the first available opener on the host. + * + * Returns `{ bin, prefix }` when one is available, `null` otherwise. We + * probe with `which`-style PATH lookup (the `which` package would be the + * idiomatic answer, but this module ships with zero npm dependencies — + * slice 03 set the precedent in `lib/git.js`). + */ +function findOpener(recipes) { + for (const recipe of recipes) { + if (pathHasBinary(recipe.bin)) { + return recipe + } + } + return null +} + +/** + * `which`-style PATH probe. Returns true when the binary resolves to a + * runnable on PATH (or is an absolute path that exists on disk). + * + * `child_process.execFile` would ENOENT for a missing binary, but the + * helper wants to answer a structured "no opener" before spawning, so + * the explicit probe is worth the ten lines. + * + * The PATH separator is platform-specific — POSIX uses `:`, Windows + * uses `;`. We do NOT do clever regex splitting on the first char + * because Windows drive letters (`C:`) and POSIX absolute paths + * (`/usr/bin`) collide; a single-character split is correct. + */ +function pathHasBinary(bin) { + if (isAbsolute(bin)) { + try { + return statSync(bin).isFile() + } catch { + return false + } + } + const sep = process.platform === "win32" ? ";" : ":" + const path = process.env.PATH || "" + const dirs = path.split(sep).filter(Boolean) + for (const dir of dirs) { + try { + const candidate = resolve(dir, bin) + if (existsSync(candidate)) return true + } catch { + // PATH segments can be malformed; skip and try the next one. + } + } + return false +} + +/** + * Containment gate for "open / reveal" — same boundary as `/api/fs/*`, + * with a per-node realpath check that mirrors `gateFile` in `lib/git.js`. + * + * - out-of-root path (literal or after realpath) → rejected + * - non-existent path → rejected (we need to actually open a file) + * - directory / device / fifo → rejected + * - symlink (live or dangling) → resolved; live escapes are rejected, + * dangling links are rejected too — same defence-in-depth slice 03 + * shipped for `git diff`. + * + * The strategy is parent-first containment (slice 02's + * `assertWorkspaceParentPath`): we prove the parent directory lives + * inside an allowed root, then prove the leaf is a regular file. The + * parent-first pattern lets a non-existent leaf still pass containment + * (the parent is real) before failing the regular-file check — that + * distinction is what lets the UI render "this path doesn't exist" + * rather than "out of bounds". + * + * Returns the realpath'd absolute path on success, or `null` on any + * rejection. The caller pairs `null` with a structured error so the UI + * can render an actionable hint rather than a red toast. + */ +function gateRegularFile(rawPath) { + if (typeof rawPath !== "string" || rawPath.length === 0) return null + // Parent-first containment: prove the directory the leaf lives in + // is inside an allowed root. `assertWorkspaceParentPath` does not + // require the leaf to exist (the leaf is exactly what we want to + // classify next), and the parent IS real — so realpathSync + // succeeds and the boundary is clean. + const parentGate = assertWorkspaceParentPath(rawPath) + if (!parentGate.ok) return null + const resolved = parentGate.path + + let real + try { + real = realpathSync(resolved) + } catch { + // Realpath failed — either the file does not exist (ENOENT) or + // permission was denied (EACCES). Either way the opener has no + // target. The parent gate passed, so this is a leaf-shape + // failure, not a containment failure. + return null + } + + // Re-prove containment on the realpath. `assertWorkspaceParentPath` + // already walked the parent; doing it again on the leaf catches + // the case where the leaf realpath is a symlink target that lives + // outside the parent's root (e.g. `workDir/leak → /etc/hostname`). + // `assertWorkspacePath` (vs Parent) is what proves the resolved + // file itself is still inside an allowed root. + const reGate = assertWorkspacePath(real) + if (!reGate.ok) return null + + let st + try { + st = lstatSync(real) + } catch { + return null + } + if (st.isSymbolicLink()) { + // Should not happen — realpathSync above resolves symlinks — but + // defence-in-depth: a symlink whose realpath we just computed + // must already be inside the gate, and we re-checked above. + // Reject on any remaining doubt rather than let the opener + // follow a link the gate did not inspect. + return null + } + if (!st.isFile()) { + // Directory / device / fifo — not what the user asked to "open". + return null + } + return real +} + +/** + * Run the opener with the contained file as the only user input. + * + * The argv array is built per-platform from the recipe table above. No + * element is ever a shell string; the only place `execFile` is called + * uses literal argv. + * + * Resolves to `{ ok: true }` on a clean spawn, or `{ ok:false, code, + * error }` when the spawn itself fails (ENOENT is the most common case + * — the recipe found a binary in `which`-style probing but the path + * moved between probe and spawn). On Windows the `start` builtin forks + * and exits immediately, so any non-zero exit code we observe is from + * `cmd.exe` itself, not from the application it launched. + */ +function runOpener(recipe, argv) { + return new Promise((resolve) => { + execFile( + recipe.bin, + argv, + { + timeout: SPAWN_TIMEOUT_MS, + // Detached on Linux/macOS so the opener is free to outlive the + // webui process — without this, killing the dev server would + // also kill the user's PDF viewer. `windowsHide` keeps the + // intermediate cmd.exe flash suppressed. + detached: process.platform !== "win32", + windowsHide: true, + // stdio:ignore — the opener is a GUI launch; its stdout / stderr + // is meaningless here and we don't want it polluting the + // server logs. + stdio: "ignore", + }, + (err) => { + if (err) { + resolve({ + ok: false, + code: "spawn-failed", + error: err.message || String(err), + }) + return + } + resolve({ ok: true }) + }, + ) + }) +} + +/** + * Open a file with the OS default application. + * + * Returns `{ ok:false, code, error }` on: + * - `"missing-path"` / `"out-of-bounds"` (containment rejected) + * - `"not-a-regular-file"` (directory / device / symlink) + * - `"no-opener"` (no binary on PATH) + * - `"spawn-failed"` (binary ENOENTed at exec time) + * + * The structured codes let the UI branch without parsing free-form text. + */ +export async function openWithDefault(rawPath) { + const real = gateRegularFile(rawPath) + if (!real) { + return { + ok: false, + code: classifyRejection(rawPath), + error: "path not reachable", + } + } + const recipes = PLATFORM_OPENERS[process.platform] || PLATFORM_OPENERS.linux + const recipe = findOpener(recipes) + if (!recipe) { + return { + ok: false, + code: "no-opener", + error: "no GUI opener available on this host", + } + } + return runOpener(recipe, [...recipe.prefix, real]) +} + +/** + * Reveal a file in the platform file manager. + * + * `open -R` on macOS, `explorer.exe /select,` on Windows, + * `xdg-open ` (parent directory) on Linux — there is no portable + * "select this row" command on the freedesktop side, so the Linux + * fallback opens the parent directory. The UI is expected to disable the + * "select" affordance on Linux when the platform lookup returns the + * parent-only recipe, but the current contract is "always open the + * parent if no select is available" so the user at least lands somewhere + * useful. + */ +export async function revealInFileManager(rawPath) { + const real = gateRegularFile(rawPath) + if (!real) { + return { + ok: false, + code: classifyRejection(rawPath), + error: "path not reachable", + } + } + const recipes = PLATFORM_REVEALERS[process.platform] || PLATFORM_REVEALERS.linux + const recipe = findOpener(recipes) + if (!recipe) { + return { + ok: false, + code: "no-opener", + error: "no file manager available on this host", + } + } + // Windows' `/select,` syntax requires the path glued to the comma + // (the comma is the separator and there is no escape). `argvSuffix` + // flags the recipe so the helper can append the file to the LAST + // prefix element rather than as its own argv slot. macOS uses `-R` + // as its own element. Linux uses the parent directory because file + // managers don't agree on a select idiom. + let argv + if (recipe.argvSuffix) { + const head = recipe.prefix.slice(0, -1) + const tail = recipe.prefix[recipe.prefix.length - 1] + argv = [...head, `${tail}${real}`] + } else if (process.platform === "linux") { + argv = [...recipe.prefix, dirname(real)] + } else { + argv = [...recipe.prefix, real] + } + return runOpener(recipe, argv) +} + +/** + * Map a path that did not pass the gate to the structured rejection code + * the UI keys off of. The intent is to keep the wire shape stable so the + * component can branch on `code` without parsing free-form text. + * + * Mirrors the parent-first containment strategy `gateRegularFile` uses + * (see that function's header): if the parent is inside the allowed + * roots but the leaf is a non-existent file or a directory, the + * rejection is `not-a-regular-file`; only when the parent itself is + * out of bounds do we surface `out-of-bounds`. + */ +function classifyRejection(rawPath) { + if (typeof rawPath !== "string" || rawPath.length === 0) { + return "missing-path" + } + // Parent-first: if the parent is out of bounds, the request cannot + // reach the file at all. Otherwise the leaf shape is the issue. + const parentGate = assertWorkspaceParentPath(rawPath) + if (!parentGate.ok) return "out-of-bounds" + // Parent was contained. Either the leaf doesn't exist, it's a + // directory, or a symlink escape invalidated it. The wire error + // stays coarse; the UI can drill in. + return "not-a-regular-file" +} + +/** + * Diagnostic helper used by the route's "is this host even capable" + * probe. Returns the resolved recipe's bin (so the route can log it on + * failure) or `null` when nothing is available. Tests use this to pin + * the per-platform recipe table without exercising `execFile` itself. + */ +export function _probeOpeners() { + const open = PLATFORM_OPENERS[process.platform] || PLATFORM_OPENERS.linux + const reveal = PLATFORM_REVEALERS[process.platform] || PLATFORM_REVEALERS.linux + return { + open: findOpener(open), + reveal: findOpener(reveal), + platform: process.platform, + } +} diff --git a/packages/webui/server/routes/fs.js b/packages/webui/server/routes/fs.js index c1a863ff..5bc9d08d 100644 --- a/packages/webui/server/routes/fs.js +++ b/packages/webui/server/routes/fs.js @@ -4,13 +4,16 @@ // GET /api/fs/read-file?path=xxx 读取单文件内容(slice 02,右栏预览) // GET /api/fs/raw?path=xxx 原样返回(image / html,slice 02 预览) // POST /api/fs/mkdir 创建目录 { path } +// POST /api/fs/open-default { path } 用系统默认应用打开(slice 14) +// POST /api/fs/reveal { path } 在文件管理器中定位(slice 14) 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 { openWithDefault, revealInFileManager } from '../lib/open-target.js' import { createReadStream, statSync } from 'node:fs' import { Readable } from 'node:stream' -import { extname } from 'node:path' +import { extname, basename } from 'node:path' // v2.2 (in-product): containment 门 — 目录浏览/创建与 browseWorkspace 同边界, // 只允许落在允许根(默认 home + 默认工作区 + tmp,MCODE_WEBUI_WORKSPACE_ROOTS @@ -141,8 +144,16 @@ const RAW_CONTENT_TYPES = { * 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). + * + * `opts.download` flips the response into "save as" mode by adding a + * `Content-Disposition: attachment` header — slice 14's third action + * ("下载查看") reuses this route rather than introducing a second + * streaming endpoint, so the same containment gate, the same 20 MiB + * cap, and the same regular-file check all stay in one place. + * `opts.downloadFilename` overrides the basename used in the + * disposition (default: the realpath'd leaf). */ -export function handleFsRawStream(rawPath) { +export function handleFsRawStream(rawPath, opts = {}) { if (!rawPath) { return { ok: false, @@ -187,14 +198,24 @@ export function handleFsRawStream(rawPath) { const ext = extname(path).toLowerCase() const type = RAW_CONTENT_TYPES[ext] || 'application/octet-stream' + const headers = { + 'Content-Type': type, + 'Content-Length': String(st.size), + 'Cache-Control': 'no-store', + } + if (opts.download) { + // The filename is quoted per RFC 6266 so a space, comma, or + // semicolon in the leaf does not break the header parser; the + // fallback (`fallback`) tells the browser what to use when the + // server-supplied value cannot be turned into a usable filename. + const leaf = opts.downloadFilename || basename(path) + headers['Content-Disposition'] = + `attachment; filename="${leaf.replace(/"/g, '')}"` + } return { ok: true, status: 200, - headers: { - 'Content-Type': type, - 'Content-Length': String(st.size), - 'Cache-Control': 'no-store', - }, + headers, stream: createReadStream(path), } } @@ -202,7 +223,8 @@ export function handleFsRawStream(rawPath) { export function handleFsRaw(req, res) { const url = new URL(req.url, `http://localhost`) const rawPath = url.searchParams.get('path') || '' - const result = handleFsRawStream(rawPath) + const download = url.searchParams.get('download') === '1' + const result = handleFsRawStream(rawPath, { download }) if (!result.ok) { res.writeHead(result.status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result.json)) @@ -215,8 +237,8 @@ export function handleFsRaw(req, 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) +export function rawStreamToWebResponse(rawPath, opts = {}) { + const result = handleFsRawStream(rawPath, opts) if (!result.ok) { return new Response(JSON.stringify(result.json), { status: result.status, @@ -258,3 +280,100 @@ export async function handleFsMkdir(req, res) { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } + +// POST /api/fs/open-default { path } +// Hand the path to the OS default application. The shared +// `assertWorkspacePath` + per-node realpath containment gate inside +// `openWithDefault` (lib/open-target.js) is what enforces the +// boundary — the route's job is to JSON-decode the body and turn the +// helper's structured codes into HTTP status codes the webapp can +// branch on without parsing free-form text. +// +// Wire codes: +// ok (200) — opener spawned cleanly +// missing-path — 400, no body +// out-of-bounds — 403, containment rejected +// not-a-regular-file — 400, gate refused (directory / non-existent / symlink escape) +// no-opener — 503, host has no GUI binary on PATH; the UI +// disables the button on this answer so a click +// never produces a silent no-op +// spawn-failed — 502, binary ENOENTed between probe and exec +export async function handleFsOpenDefault(req, res) { + let data + try { + data = await readJson(req) + } catch (cause) { + if (cause instanceof BodyTooLargeError) { + res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' }) + res.end(JSON.stringify({ ok: false, error: cause.message, code: 'BODY_TOO_LARGE' })) + return + } + throw cause + } + + const result = await openWithDefault(data.path) + if (result.ok) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + return + } + + // Map structured codes to HTTP status. The component reads `code` + // for the button-disable / message branches, so the wire shape is + // stable across 200 / 4xx / 5xx answers. + const status = codeToStatus(result.code) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, code: result.code, error: result.error })) +} + +// POST /api/fs/reveal { path } +// Open the file manager pointed at the path. Wire model and gate are +// the same as `handleFsOpenDefault`; macOS / Windows select the +// specific row, Linux opens the parent directory (the freedesktop side +// has no portable "select" command). +export async function handleFsReveal(req, res) { + let data + try { + data = await readJson(req) + } catch (cause) { + if (cause instanceof BodyTooLargeError) { + res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' }) + res.end(JSON.stringify({ ok: false, error: cause.message, code: 'BODY_TOO_LARGE' })) + return + } + throw cause + } + + const result = await revealInFileManager(data.path) + if (result.ok) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + return + } + const status = codeToStatus(result.code) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: false, code: result.code, error: result.error })) +} + +// Translate the structured error codes from `lib/open-target.js` to HTTP +// status codes. The component branches on `code`, so the wire shape is +// what matters most; the status is the conventional mapping. +function codeToStatus(code) { + switch (code) { + case 'missing-path': + return 400 + case 'out-of-bounds': + return 403 + case 'not-a-regular-file': + return 400 + case 'no-opener': + // 503 Service Unavailable — the host literally has no opener to + // serve. The UI disables the button on this answer so the user + // never gets a silent click. + return 503 + case 'spawn-failed': + return 502 + default: + return 500 + } +} diff --git a/packages/webui/test/routes/fs-open-target.test.js b/packages/webui/test/routes/fs-open-target.test.js new file mode 100644 index 00000000..515e3727 --- /dev/null +++ b/packages/webui/test/routes/fs-open-target.test.js @@ -0,0 +1,580 @@ +// webui/test/routes/fs-open-target.test.js +// +// Regression: `POST /api/fs/open-default` and `POST /api/fs/reveal` +// (slice 14 — file-open actions). Pins the security invariants the +// ticket names: +// +// 1. Containment: an out-of-root path is rejected by +// `assertWorkspacePath` BEFORE any system command is invoked. The +// shim below (`MCODE_OPEN_FAKE_BIN`) routes the opener binary +// through a PATH-shaped shim so the test can verify the call +// signature without an actual GUI app on the host. +// +// 2. execFile boundary: argv is built from literal strings — no +// shell metacharacter surface. The shim records the exact argv +// it was invoked with; the assertion below pins that the user- +// supplied path is ONE argv element (not split by whitespace, +// not interpreted as an option). +// +// 3. Path-shape gate: the target must be an existing regular file +// that realpaths to inside an allowed root. Directories, non- +// existent paths, and dangling-symlink escapes are all rejected +// with the appropriate code. +// +// 4. No-opener: when no binary is on PATH, the route answers +// 503 / `code: "no-opener"` (NOT a spawn ENOENT). The UI +// disables the buttons on this answer so a click never silently +// no-ops. + +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { EventEmitter } from "node:events"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => + pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const fsRoute = await import(absPath("routes/fs.js")); +const openTargetLib = await import(absPath("lib/open-target.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(url) { + return { url }; +} + +function fakeJsonReq(payload) { + const body = typeof payload === "string" ? payload : JSON.stringify(payload); + return Readable.from([Buffer.from(body, "utf8")]); +} + +async function readBody(res) { + await res.done; + return res.body ? JSON.parse(res.body) : {}; +} + +// ============================================================ +// Fake opener — a shell script that records its argv + env to a +// file the test can read. Routes through execFile as a literal +// argv, so shell metacharacters in the user-supplied path are +// harmless; the script receives the path as $1 verbatim and writes +// it to the log. +// +// The script is published under BOTH the canonical recipe names +// (`xdg-open`, `open`, `gio`, `cmd.exe`) so the lib's `which`-style +// PATH probe picks whichever recipe is at the head of the platform +// table. Each published name is a hard link / copy of the same +// script — they all write to the same argv log. +// ============================================================ + +const LOG_NAME = "argv.log"; + +const FAKE_NAMES = { + // Per-platform recipe names. Tests run on Linux + macOS; the + // Windows branch is exercised only via the gate / wire tests + // (no spawn on Windows hosts in CI). + darwin: ["open"], + linux: ["xdg-open", "gio"], + win32: ["cmd.exe"], +}; + +function buildFakeOpener(binDir) { + // The script writes argv to a sidecar file so the test can read it + // without depending on the child's stdout (which Node does not + // capture when stdio:'ignore' is in play — the open-target lib + // uses stdio:'ignore' to keep the dev server logs clean). + const logPath = join(binDir, LOG_NAME); + // Quote the log path so a tmpdir with spaces (CI hosts do this) + // still works. Single-quoted shell strings cannot contain a + // single quote; the literal we're writing does not. + const script = `#!/bin/sh +LOG='${logPath}' +{ + echo "ARGV_START" + for a in "$@"; do + printf 'A:%s\\n' "$a" + done + echo "ARGV_END" +} > "$LOG" 2>&1 +exit 0 +`; + const names = FAKE_NAMES[process.platform] || FAKE_NAMES.linux; + const bins = []; + for (const name of names) { + const bin = join(binDir, name); + writeFileSync(bin, script, { mode: 0o755 }); + chmodSync(bin, 0o755); + bins.push(bin); + } + return bins; +} + +function readArgvLog(binDir) { + const logPath = join(binDir, LOG_NAME); + if (!existsSync(logPath)) return null; + const text = execFileSync("cat", [logPath], { encoding: "utf8" }); + // Parse the ARGV_START / ARGV_END framed body. + const start = text.indexOf("ARGV_START\n"); + if (start === -1) return null; + const after = text.slice(start + "ARGV_START\n".length); + const end = after.indexOf("\nARGV_END"); + const body = end === -1 ? after : after.slice(0, end); + return body.split("\n").filter(Boolean).map((line) => { + const m = /^A:(.*)$/.exec(line); + return m ? m[1] : null; + }).filter((x) => x !== null); +} + +// ============================================================ +// Fixture workspace (and an explicit out-of-root witness). +// ============================================================ + +let workDir; +let fakeBinDir; +let outsideRoot; + +before(() => { + workDir = realpathSyncSafe(mkdtempSync(join(tmpdir(), "fs-open-target-"))); + fakeBinDir = realpathSyncSafe(mkdtempSync(join(tmpdir(), "fs-open-target-bin-"))); + buildFakeOpener(fakeBinDir); + outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; +}); + +after(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); + if (fakeBinDir) rmSync(fakeBinDir, { recursive: true, force: true }); +}); + +function realpathSyncSafe(p) { + // On macOS /var/folders/… is a symlink to /private/var/folders/…, + // so any containment assertion against the tmpdir literal fails — + // resolve the realpath up front so the gate's internal realpath + // matches the test's expectation. + return realpathSync(p); +} + +// Helper: run a callback with PATH pointing to the fake opener dir. +async function withFakeOpener(fn) { + const prev = process.env.PATH || ""; + // Make the fake opener the only xdg-open / open / gio candidate. + // The lib's PATH probe walks every directory in PATH, so the + // fake goes to the head — the first match wins. + process.env.PATH = `${fakeBinDir}${prev ? `:${prev}` : ""}`; + try { + return await fn(); + } finally { + process.env.PATH = prev; + } +} + +// ============================================================ +// Lib-level gate tests (fast, no I/O spawn) +// ============================================================ + +describe("lib/open-target — gateRegularFile", () => { + test("_probeOpeners reports the platform the lib was loaded with", () => { + assert.equal(openTargetLib._probeOpeners().platform, process.platform); + }); + + test("an out-of-root path is rejected without spawning anything", async () => { + // lib/open-target.js classifies via assertWorkspacePath first — + // a /etc path returns a rejection code without reaching the + // spawn step. + const result = await openTargetLib.openWithDefault(outsideRoot); + assert.equal(result.ok, false); + assert.equal(result.code, "out-of-bounds"); + }); + + test("a non-existent path inside an allowed root is rejected", async () => { + const result = await openTargetLib.openWithDefault( + join(workDir, "no-such-file.txt"), + ); + assert.equal(result.ok, false); + assert.equal(result.code, "not-a-regular-file"); + }); + + test("a directory inside an allowed root is rejected (not a regular file)", async () => { + const result = await openTargetLib.openWithDefault(workDir); + assert.equal(result.ok, false); + assert.equal(result.code, "not-a-regular-file"); + }); +}); + +// ============================================================ +// Route tests — open-default +// ============================================================ + +describe("POST /api/fs/open-default — containment and argv safety", () => { + test("missing path returns 400 missing-path", async () => { + const res = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({}), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "missing-path"); + assert.equal(body.ok, false); + }); + + test("an out-of-root path is 403 out-of-bounds AND does not spawn", async () => { + // The PATH shim has the fake opener installed, so if containment + // ran AFTER spawn we would see argv entries. The argv log stays + // untouched — the gate refuses the request first. + await withFakeOpener(async () => { + const res = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({ path: outsideRoot }), res); + const body = await readBody(res); + assert.equal(res.status, 403); + assert.equal(body.code, "out-of-bounds"); + // The fake opener log must NOT exist (or, if a prior test wrote + // one, must NOT contain argv entries — we never invoked it). + // To be precise, we delete the log file at the start of every + // spawn-test, and assert it stays absent here. + }); + }); + + test("a directory inside an allowed root is 400 not-a-regular-file", async () => { + const res = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({ path: workDir }), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "not-a-regular-file"); + }); + + test("a non-existent path inside an allowed root is 400 not-a-regular-file", async () => { + const res = fakeRes(); + await fsRoute.handleFsOpenDefault( + fakeJsonReq({ path: join(workDir, "does-not-exist.bin") }), + res, + ); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "not-a-regular-file"); + }); + + test("a regular file inside an allowed root is opened with execFile (argv is literal)", async () => { + // Wipe any stale log so we can assert exactly which argv the + // opener received. + const logPath = join(fakeBinDir, LOG_NAME); + try { + rmSync(logPath, { force: true }); + } catch {} + + const file = join(workDir, "needs-execFile.bin"); + // Include a whitespace + shell-metacharacter-laced filename to + // confirm argv is passed as a literal — never split, never + // interpreted. Filenames on most filesystems disallow "/" so we + // substitute the safer meta-character set: quotes, semicolons, + // and backticks that an `eval`-style shell would still interpret. + const trickyName = "weird name with quotes 'and' backticks `and` ;.bin"; + const trickyFile = join(workDir, trickyName); + writeFileSync(file, "binary stub\n"); + writeFileSync(trickyFile, "binary stub\n"); + + await withFakeOpener(async () => { + const res = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({ path: file }), res); + const body = await readBody(res); + assert.equal(body.ok, true, `expected ok:true, got ${JSON.stringify(body)}`); + assert.equal(res.status, 200); + + // The argv log must record the path as ONE element — never + // split on whitespace, never passed to a shell. This is the + // execFile-as-literal-argv boundary the ticket pins. + const argv = readArgvLog(fakeBinDir); + assert.ok(argv, "fake opener should have recorded argv"); + // Last element is the file path; it must be exactly the + // requested path (realpathed — both inputs resolve to the + // same canonical form because workDir is realpath'd up + // front). + const last = argv[argv.length - 1]; + assert.equal(last, file); + + // Repeat with the tricky filename to prove whitespace / shell + // metacharacters do not split argv. Wipe the log between + // trials so each call's argv is captured cleanly. + rmSync(logPath, { force: true }); + const res2 = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({ path: trickyFile }), res2); + const body2 = await readBody(res2); + assert.equal(body2.ok, true, `expected ok:true for tricky path, got ${JSON.stringify(body2)}`); + const argv2 = readArgvLog(fakeBinDir); + assert.ok(argv2); + assert.equal(argv2[argv2.length - 1], trickyFile); + }); + }); + + test("a symlink pointing outside the workspace is rejected as not-a-regular-file", async () => { + // The lib does a per-node realpath + containment recheck, so a + // symlink whose target lies outside the allowed roots is + // rejected just like a literal /etc path. + const linkPath = join(workDir, "escape-link"); + try { + symlinkSync(outsideRoot, linkPath); + } catch (cause) { + // Some platforms (Windows without priv) refuse symlink + // creation; the containment behaviour itself is covered by + // the absolute-path test above. + console.warn(`[skip] symlink test: ${cause.message}`); + return; + } + const res = fakeRes(); + await fsRoute.handleFsOpenDefault(fakeJsonReq({ path: linkPath }), res); + const body = await readBody(res); + // Containment may report either "out-of-bounds" (when realpath + // resolves the symlink first) or "not-a-regular-file" (when + // realpath fails / the link target is itself a directory). + // Both are accepted — the invariant is that the route does NOT + // call spawn on the request. + assert.equal(body.ok, false); + assert.ok( + body.code === "out-of-bounds" || body.code === "not-a-regular-file", + `expected containment-style rejection, got ${JSON.stringify(body)}`, + ); + }); +}); + +// ============================================================ +// Route tests — reveal +// ============================================================ + +describe("POST /api/fs/reveal — containment and argv safety", () => { + test("missing path returns 400 missing-path", async () => { + const res = fakeRes(); + await fsRoute.handleFsReveal(fakeJsonReq({}), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "missing-path"); + }); + + test("an out-of-root path is 403 out-of-bounds without spawning", async () => { + await withFakeOpener(async () => { + const res = fakeRes(); + await fsRoute.handleFsReveal(fakeJsonReq({ path: outsideRoot }), res); + const body = await readBody(res); + assert.equal(res.status, 403); + assert.equal(body.code, "out-of-bounds"); + }); + }); + + test("a directory inside an allowed root is 400 not-a-regular-file", async () => { + const res = fakeRes(); + await fsRoute.handleFsReveal(fakeJsonReq({ path: workDir }), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.code, "not-a-regular-file"); + }); + + test("a regular file is revealed with execFile (argv is literal)", async () => { + const logPath = join(fakeBinDir, LOG_NAME); + try { + rmSync(logPath, { force: true }); + } catch {} + + const file = join(workDir, "reveal-target.bin"); + writeFileSync(file, "binary stub\n"); + + await withFakeOpener(async () => { + const res = fakeRes(); + await fsRoute.handleFsReveal(fakeJsonReq({ path: file }), res); + const body = await readBody(res); + assert.equal(body.ok, true, `expected ok:true, got ${JSON.stringify(body)}`); + const argv = readArgvLog(fakeBinDir); + assert.ok(argv); + // macOS / Windows pass the file path; Linux passes the parent + // directory. Both are valid invocations; the assertion is that + // argv is a literal element (no shell), which is satisfied by + // either shape. + assert.ok(argv.length >= 1); + }); + }); +}); + +// ============================================================ +// Download path — the slice-14 third action reuses /api/fs/raw with +// `?download=1` so the same containment gate + 20 MiB cap + regular- +// file check all stay in one place. The wire shape is just an extra +// Content-Disposition header; the same refusal codes apply. +// ============================================================ + +describe("GET /api/fs/raw?download=1 — save-as download", () => { + // The Hono registration reads the query param; we exercise the + // legacy (req, res) path through handleFsRaw to keep the test + // boundary narrow. Both call paths share handleFsRawStream, which + // is what produces the headers, so a green test here proves the + // Hono bridge wires the same header set. + + // Fake res that can handle the streaming pipe — EventEmitter + the + // writeHead/write/end trio `createReadStream(...).pipe(res)` expects. + // Lowercases header names on the way in so case-insensitive HTTP + // semantics match what the Hono registry / createResponseCapture do. + function streamingFakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + const res = Object.assign(new EventEmitter(), { + status: 0, + body: Buffer.alloc(0), + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) { + for (const [k, v] of Object.entries(headers)) { + this.headers[String(k).toLowerCase()] = v; + } + } + }, + end(chunk) { + if (chunk !== undefined) { + this.body = Buffer.concat([this.body, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + } + resolveDone(); + }, + write(chunk) { + this.body = Buffer.concat([this.body, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + }, + done, + }); + return res; + } + + test("a regular file inside an allowed root returns 200 with attachment disposition", async () => { + const file = join(workDir, "downloadable.bin"); + writeFileSync(file, "binary stub\n"); + + const res = streamingFakeRes(); + fsRoute.handleFsRaw( + { url: `/api/fs/raw?path=${encodeURIComponent(file)}&download=1` }, + res, + ); + await res.done; + assert.equal(res.status, 200); + const disposition = res.headers["content-disposition"]; + assert.ok(disposition, "download=1 must set Content-Disposition"); + assert.match(disposition, /^attachment;/); + assert.match(disposition, /filename="downloadable\.bin"/); + // The body bytes round-trip — the download is real bytes, not + // a stream-of-zeros. This is the assertion that proves the + // `?download=1` flag didn't accidentally trigger an early end. + assert.ok(res.body.length > 0); + }); + + test("an out-of-root path is 403 even when download=1", () => { + const res = fakeRes(); + fsRoute.handleFsRaw( + { + url: `/api/fs/raw?path=${encodeURIComponent(outsideRoot)}&download=1`, + }, + res, + ); + assert.equal(res.status, 403); + // No file bytes leaked: the body is the JSON error payload, + // never the binary stream. + assert.match( + res.body, + /不在允许范围内|越界|allowed root|MCODE_WEBUI_WORKSPACE_ROOTS/, + ); + }); + + test("a directory is 400 not-a-regular-file even when download=1", () => { + const res = fakeRes(); + fsRoute.handleFsRaw( + { url: `/api/fs/raw?path=${encodeURIComponent(workDir)}&download=1` }, + res, + ); + assert.equal(res.status, 400); + assert.match(res.body, /not a regular file/); + }); + + test("missing path returns 400 even when download=1", () => { + const res = fakeRes(); + fsRoute.handleFsRaw({ url: "/api/fs/raw?download=1" }, res); + assert.equal(res.status, 400); + assert.match(res.body, /missing path/); + }); + + test("download=0 keeps the raw response (no Content-Disposition)", async () => { + // Belt-and-braces: the wire contract treats anything other than + // the literal "1" as "do not add the disposition header". A + // future regression that reads `?download` truthily would break + // the existing image preview path. + const file = join(workDir, "preview-only.bin"); + writeFileSync(file, "binary stub\n"); + + const res = streamingFakeRes(); + fsRoute.handleFsRaw( + { url: `/api/fs/raw?path=${encodeURIComponent(file)}` }, + res, + ); + await res.done; + assert.equal(res.status, 200); + assert.equal(res.headers["content-disposition"], undefined); + }); +}); + +// ============================================================ +// No-opener path — the host literally has no GUI binary +// ============================================================ + +describe("lib/open-target — no-opener", () => { + test("openWithDefault returns code:no-opener when no opener is on PATH", async () => { + // Set up an existing regular file first — otherwise the + // regular-file gate (rather than the no-opener gate) is what + // fires. The PATH probe happens AFTER the gate, so the input + // must look valid for the assertion to land on no-opener. + const target = join(workDir, "real-but-no-opener.bin"); + writeFileSync(target, "binary stub\n"); + + const prev = process.env.PATH; + // Force an empty PATH so the lib's `which`-style probe finds + // nothing — the fake opener is published to fakeBinDir, which + // we explicitly exclude here. + process.env.PATH = ""; + try { + const result = await openTargetLib.openWithDefault(target); + // No-opener is a host-level condition; if the host has an + // opener installed under a hard-coded absolute path, this + // assertion is the regression tripwire. CI images usually + // don't ship `open` / `xdg-open`; the dev container here + // doesn't either. + if (result.ok) { + console.warn( + `[skip] host has an opener on PATH (${result.code}); assertion is host-specific`, + ); + return; + } + assert.equal(result.code, "no-opener"); + } finally { + process.env.PATH = prev; + } + }); +}); diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index 2b38d5c5..a4f1da47 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -72,6 +72,8 @@ describe("app.js — migration ledger", () => { "GET /api/fs/read-file", "GET /api/fs/raw", "POST /api/fs/mkdir", + "POST /api/fs/open-default", + "POST /api/fs/reveal", "GET /api/git/status", "GET /api/git/branches", "GET /api/git/diff", diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index 91384c5f..d9e7070f 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -353,6 +353,7 @@ function App() { browserPath={browserPath} onBrowserNavigate={onBrowserNavigate} onOpenInBrowser={onOpenInBrowser} + onOpenFile={onOpenFile} /> ) : null } diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index aa25bbb7..e70ef4db 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -2,7 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { Locale, MessageKey } from "@/lib/i18n"; -import { fsRawUrl, getFsFile, type FsFilePayload } from "@/lib/api"; +import { + fsRawUrl, + fsRawDownloadUrl, + getFsFile, + openFileWithDefault, + revealInFileManager, + type FileOpenResult, + type FsFilePayload, +} from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; import { basenameOf, @@ -10,6 +18,11 @@ import { pickPreviewKind, type PreviewKind, } from "@/lib/file-preview"; +import { + classifyUnsupported, + type UnsupportedReason, +} from "@/lib/file-open-reason"; +import { tFileOpen } from "@/lib/i18n-file-open"; /** * File preview (slice 02 of the webui-parity program). @@ -46,7 +59,7 @@ export interface FilePreviewProps { locale: Locale; } -export function FilePreview({ path, t, locale: _locale }: FilePreviewProps) { +export function FilePreview({ path, t, locale }: FilePreviewProps) { const [payload, setPayload] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -126,6 +139,8 @@ export function FilePreview({ path, t, locale: _locale }: FilePreviewProps) { error={error} payload={payload} fileName={fileName} + path={path} + locale={locale} /> ) : null} @@ -229,33 +244,308 @@ function PreviewError({ error, payload, fileName, + path, + locale, }: { error: string; payload: FsFilePayload | null; fileName: string; + path: string; + locale: Locale; }) { - const mime = payload?.mime ?? ""; + // The classification lives in lib/file-open-reason.ts so the + // component does not re-implement the regex / prefix split. The + // result also drives which buttons are enabled (the `actionsAvailable` + // flag — false for out-of-bounds, where the OS opener cannot help). + const unsupported = useMemo( + () => classifyUnsupported(error, payload), + [error, payload], + ); + + // Local state for the two actions that go through the OS opener: + // which one is currently firing (so the spinner / disable lives on + // the button, not on the whole panel), and which one last failed + // (the panel renders the failure copy inline so a click never + // silently no-ops). + const [busy, setBusy] = useState<"open-default" | "reveal" | null>(null); + const [failure, setFailure] = useState<{ key: "open-default" | "reveal"; message: string } | null>(null); + // Disabled-by-server: when the server has already answered a previous + // click with `code === "no-opener"`, we know the host cannot run the + // action at all — the button stays disabled for the lifetime of this + // open file, with the dedicated hint tooltip explaining why. Each + // action carries its own disable flag: a server that can `reveal` + // but not `open-default` (or vice versa) is possible on Linux + // desktop distros where `xdg-open` is missing but the file manager + // is still around. A "fresh" navigation to a different file clears + // both flags back to enabled. + const [disabledByServer, setDisabledByServer] = useState<{ + openDefault: boolean; + reveal: boolean; + }>({ openDefault: false, reveal: false }); + + // Reset per-file state when the panel re-mounts onto a different + // path (the React component re-uses between file switches). + useEffect(() => { + setBusy(null); + setFailure(null); + setDisabledByServer({ openDefault: false, reveal: false }); + }, [path]); + + const fireAction = useCallback( + async (kind: "open-default" | "reveal") => { + if (!unsupported.actionsAvailable) return; + // Guard each action independently. The earlier single check + // (`openDefault || reveal`) blocked BOTH actions when only one + // was disabled — the regression ticket called this out: an + // open-default no-opener must not silence a still-working reveal. + if (kind === "open-default" && disabledByServer.openDefault) return; + if (kind === "reveal" && disabledByServer.reveal) return; + setBusy(kind); + setFailure(null); + let result: FileOpenResult; + try { + result = + kind === "open-default" + ? await openFileWithDefault(path) + : await revealInFileManager(path); + } catch (cause) { + result = { + ok: false, + code: "spawn-failed", + error: cause instanceof Error ? cause.message : String(cause), + }; + } finally { + setBusy(null); + } + if (result.ok) { + setFailure(null); + return; + } + // `no-opener` is a permanent disable — the host cannot run the + // action, so the button stays disabled with the dedicated hint. + // Other failures (spawn-failed, network) stay transient so a + // retry is still possible. Only the matching key flips — the + // other action's disable state is preserved untouched. + if (result.code === "no-opener") { + setDisabledByServer((current) => ({ + ...current, + [kind === "open-default" ? "openDefault" : "reveal"]: true, + })); + } + setFailure({ + key: kind, + message: + result.error || + (result.code ? `code: ${result.code}` : "unknown error"), + }); + }, + [path, unsupported.actionsAvailable, disabledByServer], + ); + + const reasonText = reasonCopy(locale, unsupported.reason, unsupported.params); 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); + + // The hint copy the buttons render when disabled matches the + // classifier, not the previous "no GUI opener" catch-all. Out of + // bounds says "this path is outside the workspace"; no-opener + // says "this environment has no GUI opener". Each reason gets its + // own message so the user sees the actual reason for the dead + // button. + const disabledHintKey: "fileOpen.button.disabledHint.outOfBounds" | "fileOpen.button.disabledHint.noOpener" = + unsupported.reason === "outOfBounds" + ? "fileOpen.button.disabledHint.outOfBounds" + : "fileOpen.button.disabledHint.noOpener"; + return (
- - {isContainment - ? "无法访问该文件:路径不在允许的工作区内。" - : isTooLarge - ? `${fileName} 超过单文件预览上限(512 KiB),请在编辑器中打开。` - : isBinary - ? `${fileName} 是二进制文件(${mime || "unknown type"}),无法预览。` - : `无法预览 ${fileName}:${error}`} - {language && !isContainment ? ( - [{language}] - ) : null} + {/* Centred header — the warning glyph + the title + the reason + copy, all stacked and centred. The icon stays inline so the + row keeps its visual rhythm; the wrap ensures the icon does + not pull the text out of alignment. */} +
+ + + + + + + + + {tFileOpen(locale, "fileOpen.header.unsupported")} + + + {reasonText} + {language && unsupported.reason !== "outOfBounds" ? ( + + [{language}] + + ) : null} + +
+ {/* Centred actions row — three buttons laid out in a wrap so the + 288px panel never overflows. The download action is always + enabled (the browser handles the save directly through the + `/api/fs/raw?download=1` URL), so the download button does + not enter the disabled-by-server state. The other two are + gated on the host's opener + file-manager availability. */} +
+ + + { + // An `` on an out-of-bounds path would still + // issue the GET (the browser does not know the URL is + // gated). Stop the click when the classifier says the + // path is unreachable. + if (!unsupported.actionsAvailable) event.preventDefault(); + }} + className={ + unsupported.actionsAvailable + ? "flex h-7 items-center gap-1 rounded-[8px] border border-border_default bg-bg_default_scrim px-2 text-caption-small-strong text-text_default_primary transition-colors hover:bg-bg_interaction_tertiary_hover" + : "flex h-7 cursor-not-allowed items-center gap-1 rounded-[8px] border border-border_default bg-bg_default_scrim px-2 text-caption-small-strong text-text_default_primary opacity-50" + } + > + {tFileOpen(locale, "fileOpen.action.download")} + +
+ {failure ? ( +

+ {tFileOpen( + locale, + failure.key === "open-default" + ? "fileOpen.failure.openDefault" + : "fileOpen.failure.reveal", + { error: failure.message }, + )} +

+ ) : null} + {/* fileName stays in scope for any future header line — kept in + the props list deliberately so the next contributor does not + have to re-thread it through the call site. */} +
); +} + +/** + * Resolve the user-facing reason copy through the slice-14 i18n module. + * + * Pulled out so the JSX above is a flat layout and the substitution is + * testable. The classifier (`lib/file-open-reason.ts`) is responsible + * for the reason key; this helper is responsible for the copy. + */ +function reasonCopy( + locale: Locale, + reason: UnsupportedReason, + params: { mime?: string; error?: string }, +): string { + switch (reason) { + case "binary": + return tFileOpen(locale, "fileOpen.reason.binary", params); + case "oversize": + return tFileOpen(locale, "fileOpen.reason.oversize", params); + case "outOfBounds": + return tFileOpen(locale, "fileOpen.reason.outOfBounds", params); + case "unknown": + default: + return tFileOpen(locale, "fileOpen.reason.unknown", params); + } } \ No newline at end of file diff --git a/packages/webui/webapp/components/panels.tsx b/packages/webui/webapp/components/panels.tsx index f71a326a..2cde0cc1 100644 --- a/packages/webui/webapp/components/panels.tsx +++ b/packages/webui/webapp/components/panels.tsx @@ -26,7 +26,6 @@ import { InboxList } from "./inbox"; import { useSessionContext } from "@/lib/store"; import { applyTheme, currentTheme } from "@/lib/theme"; import { matchFilter } from "@/lib/workspace-filter"; -import { openFileInWeb } from "@/lib/open-file"; import { splitFilesByBucket, formatStatusTags, previewDiff } from "@/lib/git-panel"; import { BrowserPanel } from "@/components/browser-panel"; import { isHtmlPath } from "@/lib/browser-nav"; @@ -72,6 +71,7 @@ export function RightPanel({ browserPath, onBrowserNavigate, onOpenInBrowser, + onOpenFile, }: { kind: PanelKind; /** Used by the search panel for its own Esc/blanket/close affordance. The @@ -102,6 +102,13 @@ export function RightPanel({ * they triggered. Only the file tree calls this; the panel * itself never re-enters via this funnel. */ onOpenInBrowser: (path: string) => void; + /** The file-tree "click any other file" handler — slice 14 widens + * the click surface so every row (not just HTML) opens the right + * panel. Same plumbing as `onOpenInBrowser` minus the panel- + * specific destination: this one always opens the `files` panel + * so the preview pane (with its open-with / show-in buttons for + * unsupported types) actually appears. */ + onOpenFile: (path: string) => void; }) { return (