8000 feat(explorer): file explorer panel and a read-only code surface by derekneely · Pull Request #210 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

feat(explorer): file explorer panel and a read-only code surface - #210

Merged
amirlehmam merged 1 commit into
amirlehmam:masterfrom
derekneely:feat/file-explorer
Aug 28, 2026
Merged

amirlehmam merged 1 commit into
amirlehmam:masterfrom
derekneely:feat/file-explorer

Conversation

@derekneely
@derekneely derekneely commented Aug 28, 2026 •
Copy link
Copy Markdown
Contributor

cmux has a file tree. This implements one for wmux, along with the read-only
code view it opens into.

The workflow it came from: with a Claude session running in a pane, I wanted to
read the file it had just touched without moving away from that pane. The tree
does that.

It follows the conventions already in the repo rather than bringing new ones in.
The design calls are all open — happy to change any of them.

What it does

Ctrl+Shift+X toggles a panel on the right of the window, rooted at the focused
pane's live cwd.

  • Single-click opens a preview tab — reused in place by the next click,
    promoted to a real tab on edit. The same idea as the markdown preview already
    in the app, generalised.
  • Markdown goes to the markdown surface that already exists. Everything else
    opens in a new read-only code surface with a line-numbered gutter.
  • Full keyboard navigation — arrows, Home/End, Enter, Esc back to the
    terminal.
  • Panel width and expansion state persist per root, through every session
    mapper and saved layout.
  • i18n across all 18 shipped locales.

The root is the pane's terminal, not its active surface

A pane showing a markdown tab still belongs to the folder its shell is in. The
cwd is normalised through trustedWindowsCwd before anything else touches it, so
a Git Bash /c/Users/... report and a PowerShell junction alias land on the same
spelling — which matters, because that spelling is what main jails against.

How reads are secured

Everything goes through resolveInRoot in src/main/explorer-fs.ts.

  • The renderer sends a surfaceId and a relative path, never an absolute one.
    A renderer-supplied root would not be a jail — a compromised renderer would
    simply pass C:\.
  • The walk lstats every segment, not just the leaf: root\link\sub follows
    link before a leaf-only lstat ever runs. libuv maps Windows reparse points
    onto symlinks, so mklink /J junctions are covered by the same check.
  • Containment is path.relative, with a Windows-spelling prefix check beside it.
    The root is realpath'd on the way in, so 8.3 short names and junction aliases
    are already gone by then.

code-file.ts is a sibling to markdown-file.ts and deliberately does not
import from it or change it. Markdown's extension whitelist stays exactly as
narrow as it is, for exactly the callers it already has. The threat model here 8000 is
the inverse: this module has no whitelist, because the whole point is to read the
files that whitelist rejects, and what stands in its place is the path jail
above. Its binary deny-list and content sniff are a UX filter — they keep .png
out of the tree and mojibake out of the pane — not the boundary. That distinction
is written into the file header so a later reader doesn't relax the wrong thing.

Two things are deliberately narrower than they look:

  • Reveal-in-Explorer and open-in-default-app get their own IPC pair rather
    than reusing markdown.*, which is gated on the markdown whitelist and would
    reject every ordinary source file the tree lists.
  • A file whose default verb executes what it contains is refused rather than
    handed to the shell.

Verification

npx vitest run     2498 passed | 6 skipped (162 files)   ← 2286 on v2.5.0 (151 files)
tsc -p tsconfig.node.json                                 clean
npm run lint       14 errors / 13 warnings                ← unchanged from v2.5.0

200 tests across eleven new files cover the jail, the path policy, the tree and
expansion helpers, keyboard navigation, the preview-tab state machine, layout
capture and restore, and i18n key parity across every locale — plus additions to
the existing split-tree, surface-slice and session-persistence suites.

Dogfooded continuously since v2.3.0 across the 2.4.0 and 2.5.0 merges. The
prompts surface from #207 and the code surface coexist cleanly: the preview-
tab reuse is scoped to markdown | code, so a prompts tab is never mistaken for
a preview.

Docs

CLAUDE.md gains the three main-process files, the Explorer/ and Code/
component entries, the preload namespaces and the IPC channels; the README gains
the explorer's keyboard table and two rows in the feature table.

cmux has a file tree. This implements one for wmux, along with the read-only
code view it opens into, staying inside the conventions already here rather
than introducing new ones.

`Ctrl+Shift+X` toggles a panel on the right of the window, rooted at the
focused pane's live cwd. Single-click opens a file into a reusable preview
tab that is replaced in place by the next click and promoted to a real tab
on edit; markdown goes to the markdown surface that already exists, and
everything else to a new read-only `code` surface with a line-numbered
gutter. Full keyboard navigation, panel width and expansion state persisted
per root, and i18n across all 18 shipped locales.

The root is the pane's TERMINAL cwd, not its active surface's: a pane
showing a markdown tab still belongs to the folder its shell is in. It is
normalized through `trustedWindowsCwd` first, so a Git Bash `/c/Users/...`
report and a PowerShell junction alias land on the same spelling.

Reads are jailed in main, in `explorer-fs.ts`'s `resolveInRoot`. The
renderer sends a surfaceId and a RELATIVE path and never an absolute one —
a renderer-supplied root would not be a jail, since a compromised renderer
would simply pass `C:\`. The walk lstats every segment rather than only the
leaf, because `root\link\sub` follows `link` before a leaf-only lstat ever
runs; libuv maps Windows reparse points onto symlinks, so `mklink /J`
junctions are covered by the same check.

`code-file.ts` is a sibling to `markdown-file.ts` and deliberately does not
import from it or change it: markdown's extension whitelist stays exactly as
narrow as it is, for exactly the callers it already has. The threat model is
the inverse one — this module has no whitelist, because the whole point is
to read the files that whitelist rejects, and what stands in its place is
the path jail above. Its binary deny-list and content sniff are a UX filter,
not the boundary.

Two things the explorer offers are deliberately narrower than they look.
Reveal-in-Explorer and open-in-default-app go through their own IPC pair
rather than the `markdown.*` one, which is gated on the markdown whitelist
and would reject every ordinary source file the tree lists; and a file whose
default verb executes what it contains is refused rather than handed to the
shell.

200 tests across eleven new files cover the jail, the path policy, the tree
and expansion helpers, keyboard navigation, the preview-tab state machine,
layout capture and restore, and the i18n key parity across every locale, on
top of additions to the existing split-tree, surface-slice and
session-persistence suites.
@amirlehmam
amirlehmam merged commit 24cb98b into amirlehmam:master Aug 28, 2026
amirlehmam added a commit that referenced this pull request Aug 28, 2026
Ships the file explorer panel and the read-only code surface (#210).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oDkmEEpP6S31RXpxK656
@amirlehmam
Copy link
Copy Markdown
Owner

Merged, and shipped in v2.6.0.

What made this an easy yes is the direction the trust flows. The renderer sends a surfaceId and a relative path and main derives the root itself — that inversion is the whole thing, because a renderer-supplied root isn't a jail at all, it's a parameter. And explorer-roots.ts hangs off the existing report_pwd call site rather than adding a second cwd source, so there is no way for the tree's idea of where a pane is to drift from ssh-detect's. One report, two readers. That's the shape #143 and #128 both had to be corrected into, and it arrived that way here.

The segment walk is the part I'd have expected to be wrong and isn't. lstat-ing only the leaf is the mistake almost everyone makes — root\link\sub has already followed link by then — and leaning on libuv mapping reparse points onto symlinks so one predicate covers mklink /J is exactly right on this platform. Same for rel === '..' || startsWith('..' + sep): a bare startsWith('..') also eats ..foo, and the user's experience of that is a folder in the tree that silently refuses to open, with nothing to grep for.

Three calls I want to name because they're the ones a later reader will be tempted to undo:

  • No jailed markdown read. Writing it, then deleting it on review, was correct. "Jailed to a pane root" isn't consent — and the pane root is where the user's actual work lives, so it's the worst place to hand out unattended write access. Save As stays the consent gate.
  • EXEC_EXT as a deny-list separate from BINARY_EXT. Two lists that look alike and do different jobs is usually a smell; here it's the point. One is a UX filter, one is a boundary, and the headers say which is which so nobody relaxes the wrong one. The third group in it — .chm, .appref-ms, .diagcab, .library-ms — is the one an extension-by-extension reading never reaches, and I'd not have caught it.
  • The TOCTOU note stated at its widest. "One text file read from outside the root, by an attacker who already has write access inside it and can win the race" is the honest framing, and writing down that the note used to say the smaller of the two is worth more than the fix.

Verified on the merge result before merging, not on the branch alone:

  • npx vitest run — 2498 pass / 6 skipped, 161 files (2286 / 151 on v2.5.0)
  • tsc -p tsconfig.node.json clean, vite build clean
  • eslint — 14 errors / 13 warnings, unchanged from v2.5.0, and zero in the new files
  • verify:resources clean

Two notes, neither blocking:

  • showHidden is useState(false), so the toggle resets on remount while width and expansion persist. Fine as-is; just the one bit of panel state that doesn't survive, if you ever want it to.
  • .js is in EXEC_EXT, which is correct on Windows — WSH will happily run one — but it does mean the most common source file in this repo can't be sent to an editor from the context menu. Single-click still views it in the pane and reveal still works, so it degrades the right way. Flagging it because it'll get reported eventually and the answer is "deliberate".

Dogfooding it since 2.3.0 across two merges shows, and the prompts/code coexistence being scoped to markdown | code rather than "any non-terminal" is the detail that would have bitten in a month. Thanks for this one.

Release: https://github.com/amirlehmam/wmux/releases/tag/v2.6.0

amirlehmam added a commit that referenced this pull request Aug 29, 2026
… edit

Two features on top of #210.

── +N/-N in the tree ────────────────────────────────────────────────────────
No new diff engine. `diff-provider.ts` already answered this question and
already returned {path, status, additions, deletions}, with a git backend and
a session-snapshot backend it picks between itself — so this adds a caller and
inherits the #141 coalescing for free. Two providers would eventually disagree,
and the user would be looking at a DiffPane and a tree describing one folder
differently.

`explorer:diff-stats` takes a surfaceId, never a cwd: `diff:get-files` answers
the same question from a renderer-supplied absolute path, which predates the
jail and is the pattern #210 exists to reject. The new channel goes through
`explorerRootFor`, the gate #210 wrote once — one gate, six callers.

The rollup is flat and keyed on POSIX rel-paths, covering every changed file
AND every ancestor directory, because the tree lists directories lazily: a
rollup that could only see loaded rows would report `src/ +12/-3` purely
because that is the part the user had expanded.

The baseline is labelled in the panel. git means "everything uncommitted",
snapshot means "since the session started" — genuinely different questions, and
a column of numbers that silently means one or the other cannot be acted on.

Agent attribution reuses the hook stream wmux already receives: wmux-hook.js
already extracts tool_input.file_path. With no hooks there are simply no dots
and every number still works — the same "degrade to honest silence" rule #207
follows.

── Editing ──────────────────────────────────────────────────────────────────
CodePane's header said read-only was a property of the component's shape rather
than a flag. That stopped being true, so it is rewritten rather than left to
mislead. The editor is a plain <textarea>: CodeMirror or Monaco roughly doubles
a renderer bundle already at ~1.8 MB to serve "edit a thing or two".

This partially REVERSES #210, deliberately, and file-grants.ts records why.
That PR refused a jailed read that mints a write grant on the grounds that
"jailed to a pane root" is not consent — right, while the pane was read-only.
The editor's transaction is different: the user clicks a row, types, presses
Ctrl+S. The rule is now "a write lands only on a path opened into a live pane
in THIS window, this session, through the jail, and only if it has not changed
on disk since". Narrower than "anything under the root", wider than "only what
a dialog returned".

markdown-grants.ts -> file-grants.ts, mechanical: it was always a generic
Map<webContentsId, Set<path>>, and two grant sets are two answers to "may this
be written", whose drift is a silent write to a file one of them would have
refused. Both surfaces now check the one set.

`explorer:read-markdown` is new — the handler #210 wrote and deleted, restored
with the consequence it declined, so a .md from the tree is saveable too. Jail
AND the extension whitelist AND the mint. The unjailed markdown:read-file still
mints nothing; that is the distinction, and it is why this had to be a separate
channel rather than a flag.

Three things the write path gets right that a naive save does not:
  - expectedMtimeMs. The premise is a user editing in a folder an agent is
    working in, so "changed underneath me" is routine. Refused, never merged,
    never resolved by picking a winner.
  - CRLF. A <textarea> normalizes to LF, so a one-character edit in a CRLF file
    rewrites every line — visible in the very +N/-N column above, and in the
    next commit. Endings are restored from disk.
  - Encoding. UTF-16LE and BOMs are probed from the file being overwritten,
    not round-tripped through the renderer. The size cap is measured on encoded
    bytes, since UTF-16 doubles most source text.

── Also ─────────────────────────────────────────────────────────────────────
fetchDir's duplicated stale-unmark and openOnce's 150 lines are decomposed;
behaviour unchanged, and the source-level pins are rewritten to assert what has
to be true rather than to count a line that is now shared.

+80 tests (2578 total). All 18 locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138oDkmEEpP6S31RXpxK656
@amirlehmam
Copy link
Copy Markdown
Owner

Follow-up shipped in v2.7.0 — two things built directly on this PR, and one of them reverses a decision we both praised above. Flagging you since the reasoning was yours.

1. +N/-N in the tree

The tree now carries change counts per file, rolled up into folders — including folders that have never been listed. That last part is the only interesting constraint: the panel lists directories lazily, so a rollup that walked the loaded tree would report src/ +12/-3 because that is the part the user had expanded. It is a flat map keyed on the POSIX rel-paths listDir already puts on the wire, with an entry for every changed file and every ancestor.

No new diff engine. diff-provider.ts already answered exactly this and already returned {path, status, additions, deletions} — so this adds a caller and inherits the #141 coalescing for free. Two providers would have drifted into describing one folder two ways.

explorer:diff-stats takes a surfaceId, never a cwd. diff:get-files answers the same question from a renderer-supplied absolute path; it predates the jail and is the exact pattern this PR exists to reject, so it is not reused. The new channel goes through explorerRootFor — your gate, now six callers instead of five.

The baseline is labelled in the panel, because the provider's two backends answer different questions: git compares against HEAD, the snapshot against session start. A column of numbers that silently means one or the other can't be acted on.

Attribution rides the hook stream wmux already receives — wmux-hook.js was already extracting tool_input.file_path. No hooks, no dots, numbers still work.

2. The code surface is editable — which reverses your markdown-grant call

You wrote a jailed markdown read that mints a grant, then deleted it on review, and I called that "correct" up-thread on the grounds that jailed-to-a-pane-root isn't consent. That was right for a read-only pane. It stopped being right the moment the pane could edit, and in the most annoying possible shape: the user would have hit "I opened it in wmux, why can't I save it?" on exactly one of the two file types, for reasons invisible from the UI.

So explorer:read-markdown is back, with the consequence you declined. What makes it acceptable is that the jail is no longer carrying the argument alone:

  1. resolveInRoot — inside the pane's folder, no symlinked segment
  2. the grant set — and opened into a live pane in this window, this session
  3. expectedMtimeMs — and unchanged on disk since it was read

Narrower than "anything under the root", wider than "only what a dialog returned". markdown-grants.ts became file-grants.ts (it was always a generic Map<wcId, Set<path>>) so both surfaces check one set — two sets are two answers to "may this be written", and that drift shows up as a silent write to a file one of them would have refused. The unjailed markdown:read-file still mints nothing; that distinction is why this had to be a separate channel rather than a flag.

Your note that read-only was structural rather than a flag did survive, just relocated: draft === null is the read-only mode. There is no boolean beside the buffer that can disagree with it, and nothing outside the component can put a pane into edit mode.

Two traps in the write path that would each have been a bug report:

  • A <textarea> normalises its value to LF. Not a quirk — it is the spec'd API value. A one-character edit in a CRLF file rewrites every line, which on this release is visible twice: in the +N/-N column above, and in the user's next commit. Endings are restored from the file being overwritten.
  • UTF-16LE. readCodeFile already decoded it because Windows tooling emits it; writing back naively converts the file to UTF-8 silently. The encoding is probed from disk rather than round-tripped through the renderer — the file being overwritten is the authority on its own spelling, and a wire field would be one more renderer-supplied input to distrust. The size cap moved onto the encoded buffer for the same reason: UTF-16 doubles most source text, so a string-length check passes a file the read side then refuses to reopen.

Two things of yours I had to touch

  • fetchDir's stale-unmark existed in two copies (reply path, rejection path) and is now one helper. explorer-state.test.ts was pinning it by counting the delete, which would have passed at 2 whether or not the catch path still called the helper — so it now asserts that both paths go through dropIfStale, plus a named test for the rejection path specifically, since that is the half a reader stops looking for after finding the first.
  • openOnce was 150 lines at complexity 35 and is decomposed into the steps its own comments already numbered. Every race note is preserved verbatim, including the add-before-close ordering — your 556 lines of tests are what made that safe to do.

openInPreviewTab now requires (surfaceId, relPath) for both types rather than code only. That is deliberate and must not be softened into "fall back to the unjailed read when relPath is missing": it would make a .md sometimes mint a grant and sometimes not, decided by the call site, with nothing visible to tell the user which they got.

+80 tests (2,578 total), all 18 locales, lint unchanged.

Release: https://github.com/amirlehmam/wmux/releases/tag/v2.7.0

amirlehmam added a commit that referenced this pull request Sep 1, 2026
…sh (#214)

All six aborts in #214 land on a `will-quit` line in the reporter's own
main.log, to the second. `logDiagnostic('will-quit', ...)` is the first
statement in that handler, so the process reached shutdown, wrote the line,
and aborted inside it — six times across thirteen quits, at PTY counts from
2 to 7. A coin-flip race, which is why no reproduction sequence was found,
and not the file explorer (#210) it was attributed to.

The mechanism was already written down in this repo, in the `session-end`
comment: killAll() fires N node-pty ConPTY exit callbacks and the process
then walks into Node's environment teardown; a napi call that loses that
race throws off the top of the stack and the CRT calls __fastfail(7).
pty-crash-guard closes the route where the JS callback throws — it cannot
close this one, where JS is never reached.

- quit-sequence.ts: a pure decision — drain briefly so those callbacks land
  in a healthy environment, then leave via app.exit() so whatever did not
  land never sees the teardown. A quit with no PTYs still exits normally.
- resolveSpawnCwd never returns undefined: node-pty's default is "inherit
  wmux.exe's own cwd", which after a crash relaunch is C:\Windows\system32.
  That is why Claude Code filed transcripts there and `claude --continue`
  answered "no saved sessions" — the report's worst symptom in practice.
- saveSession is now actually atomic. It unlinked the live session.json
  before renaming the temp over it, a window with no session file at all;
  dying there is what brought surfaces back with new ids and no customTitle.
- uncaughtExceptionMonitor (not uncaughtException — it must not suppress)
  records a JS-side death, so a report can tell one from a native abort.
- `wmux crash-report` now says "during: shutdown", so the next reporter is
  not left converting six local timestamps to UTC by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gc7dfsbZd1JPJhEoeMrgaj
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.

2 participants

0