Skip to content

feat(webui): file-open actions — any file opens the panel, three ways out (webui-parity slice 14) - #55

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

fengzhi09 merged 2 commits into
mainfrom
feat/file-open-actions

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Slice 14 of the webui-parity program, from the user's report: clicking a file in the tree did nothing useful for types that cannot be previewed, and the panel had no way out.

  • Any file click opens the right-hand panel — including types with no preview. Slice 12's openFileInWeb remains the single action; only page.tsx calls it (pinned by a test).
  • Centred unsupported state with a distinct bilingual reason (binary / oversize / out-of-bounds / unknown), plus three ways out: 用默认应用打开 · 在资源管理器中打开 · 下载查看 (matching the DSH reference).
  • Two gated endpoints: POST /api/fs/open-default and POST /api/fs/reveal. Containment first proves the parent is in an allowed root, then realpathSync + a second containment recheck (catches symlink escapes), then lstatSync requires an existing regular file. Spawn is execFile(bin, argv) only — no shell, no option-injection surface. Structured codes map to 400/403/503/502, including a no-opener probe so an absent opener is reported rather than failing silently.
  • 下载查看 reuses the existing gated route (/api/fs/raw?download=1) rather than adding a second streaming path — containment, the 20 MiB cap, and the regular-file check all stay in one place; only Content-Disposition is added.

Acceptance (independent agent) — first pass FAIL, then PASS

The first pass failed on two gaps, both fixed before this PR: the 下载查看 action did not exist at all, and the empty state was left-aligned. Re-verification passed all five items:

  • Download works: click delivers the file — Content-Disposition: attachment; filename="archive.zip", 23 bytes, md5 identical to source. Out-of-bounds: the anchor is aria-disabled with preventDefault (instrumented: zero fetches on click) and raw?download=1&path=/etc/hostname → 403 with no bytes.
  • Centred state now reads as the reference's centred empty state, with the two opener actions the ticket also requires.
  • Both non-blocking bugs from the first pass are gone: with open-default disabled for lack of an opener, reveal now fires its own request and surfaces its own 503 + banner instead of silently no-op'ing; the out-of-bounds state no longer shows the "no GUI opener" copy.
  • Containment re-probed across all three actions (out-of-root, symlink escape, directory, missing, empty body): 403/400 as appropriate, the PATH-shimmed opener log is empty after every refusal (zero system-command invocations), argv stays literal, and the download path cannot fetch an out-of-root file.
  • No regression: closed-panel click still opens the panel; md/code/image render; .html rows still route to the browser iframe; only page.tsx calls openFileInWeb.

Disclosed, non-blocking

Download failures surface through the browser's own download UI (the panel adds no banner by design); the wire answer is a 4xx. In the out-of-bounds state the per-action tooltip branch is unreachable because the server never marks those actions as server-disabled — the reason line above the buttons carries the explanation.

Gates

test:webapp 558/558 · webapp:typecheck 0 · repo typecheck 0 · check:source ✓ · full server suite 1833 pass / 0 fail / 2 skipped

slice14 added 2 commits September 27, 2026 23:50
…e 14)

Widen the click surface so every file row opens the right panel and the
preview pane renders four distinguishable unsupported-state reasons
(binary / oversize / out-of-bounds / unknown). Two new server endpoints
(`POST /api/fs/open-default`, `POST /api/fs/reveal`) hand the file off
to the OS default application or point the file manager at it, gated by
the same containment + per-node realpath check the other /api/fs/*
routes use. execFile-only, no shell. Bilingual strings in a new i18n
module to avoid colliding with other slices' dictionaries.

Server:
- lib/open-target.js: cross-platform opener with PATH probe, argv-safe
  execFile, parent-first containment that distinguishes 'out of bounds'
  from 'not a regular file' so the UI can render an actionable hint
  rather than a generic refusal.
- routes/fs.js: handleFsOpenDefault + handleFsReveal + structured code
  mapping (missing-path/out-of-bounds/not-a-regular-file/no-opener/
  spawn-failed) -> HTTP status. JSON body cap honoured.
- app.js: Hono registrations + OWNED_ROUTES ledger entry.

Webapp:
- lib/file-open-reason.ts: pure classifier shared between the React
  surface and tests; one regex owns the containment predicate so the
  PreviewError block and the unit suite cannot drift.
- lib/i18n-file-open.ts: bilingual strings (en + zh, no orphan keys).
- components/file-preview.tsx: PreviewError renders the reason, the
  two action buttons, the disabled-by-server reason tooltip, and a
  transient failure banner so a click never silently no-ops.
- components/panels.tsx + app/page.tsx: every non-HTML row now opens
  the right panel through the page-level onOpenFile callback (which
  both publishes the path and opens the panel); panels.tsx stays
  passive, the test pins no setPanel call inside it.
- lib/api.ts: openFileWithDefault + revealInFileManager raw-fetch
  helpers (request() throws on non-OK and would lose the structured
  code field).

Tests:
- test/routes/fs-open-target.test.js: containment, directory/non-existent
  rejection, symlink escape, argv-literal guarantee, no-opener PATH
  probe. 15/15.
- test/file-open-reason.test.ts + test/i18n-file-open.test.ts:
  classifier and i18n symmetry, placeholder substitution.
- test/open-file.test.ts: panels.tsx must NOT call setPanel; the
  page-level callback is the only owner of the click side effect.
- test/server/app-hono.test.js: OWNED_ROUTES ledger updated for the
  two new routes.
…e 14 fix)

Three changes addressing acceptance feedback:

1. **Third action "下载查看"** — adds a download button to the
   PreviewError. Reuses /api/fs/raw?download=1 (Content-Disposition:
   attachment) so the same containment gate + 20 MiB cap + regular-file
   check stay in one place. New fsRawDownloadUrl helper + i18n keys
   (fileOpen.action.download, fileOpen.action.download.aria). The
   button is always enabled; for out-of-bounds paths it sets
   aria-disabled and preventDefault so the click never fires a doomed
   GET. New server tests pin containment refusals + the header shape.

2. **Centred empty state** — PreviewError now renders vertically
   centred in the panel's full height (header glyph + reason + actions
   stacked). The old left-aligned card read as a small status banner;
   the centred layout matches the DSH reference and the ticket's
   empty-state intent.

3. **Per-action disable / hint** — the fireAction guard used to
   block both open-default and reveal when EITHER was disabled, so a
   host that could reveal but not open-default would silently no-op
   the reveal too. Now each action is gated on its own
   disabledByServer key. The disabled tooltip also matches the
   classifier: outOfBounds says "this path is outside the workspace";
   noOpener says "this environment has no GUI opener" — the two
   reasons no longer share the same copy. New i18n keys
   fileOpen.button.disabledHint.outOfBounds and
   fileOpen.button.disabledHint.noOpener; test pins their
   distinctness.

Gates: webapp:typecheck 0; root typecheck 0; check:source passes
(inventory regenerated); webapp suite 558/558; server suite 937/937
(+5 new download tests, all 15 prior slice-14 tests still green).
@fengzhi09
fengzhi09 merged commit 41d54c2 into main Sep 27, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/file-open-actions branch September 27, 2026 17:05
weekbin added a commit that referenced this pull request Sep 28, 2026
Bring chore/sync-upstream-0.5.5 up to date with main so the pull request is
mergeable again. main advanced by nine webui-parity changes (#55-#63) while
this branch was open, and the earlier CI run on 13f52a8 was green across
ubuntu, macos and windows.

The only conflict was docs/tui-capabilities.md: the 0.5.5 sync and the
webui slash-command work each appended independent sections after the
capability matrix. Both are kept, in that order, so the document still
describes the TUI, the webui and the shared engine.

release/public-source.json and tsconfig.standalone.json are regenerated from
their sources rather than taken from the automatic merge.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant