Repository navigation
feat(explorer): file explorer panel and a read-only code surface - #210
Conversation
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.
83b0883 to
9931f11
Compare
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
|
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 The segment walk is the part I'd have expected to be wrong and isn't. Three calls I want to name because they're the ones a later reader will be tempted to undo:
Verified on the merge result before merging, not on the branch alone:
Two notes, neither blocking:
Dogfooding it since 2.3.0 across two merges shows, and the Release: https://github.com/amirlehmam/wmux/releases/tag/v2.6.0 |
… 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
|
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.
|
…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
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+Xtoggles a panel on the right of the window, rooted at the focusedpane's live cwd.
promoted to a real tab on edit. The same idea as the markdown preview already
in the app, generalised.
opens in a new read-only
codesurface with a line-numbered gutter.terminal.
mapper and saved layout.
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
trustedWindowsCwdbefore anything else touches it, soa Git Bash
/c/Users/...report and a PowerShell junction alias land on the samespelling — which matters, because that spelling is what main jails against.
How reads are secured
Everything goes through
resolveInRootinsrc/main/explorer-fs.ts.A renderer-supplied root would not be a jail — a compromised renderer would
simply pass
C:\.root\link\subfollowslinkbefore a leaf-only lstat ever runs. libuv maps Windows reparse pointsonto symlinks, so
mklink /Jjunctions are covered by the same check.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.tsis a sibling tomarkdown-file.tsand deliberately does notimport 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
.pngout 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:
than reusing
markdown.*, which is gated on the markdown whitelist and wouldreject every ordinary source file the tree lists.
handed to the shell.
Verification
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
promptssurface from #207 and thecodesurface coexist cleanly: the preview-tab reuse is scoped to
markdown | code, so a prompts tab is never mistaken fora preview.
Docs
CLAUDE.mdgains the three main-process files, theExplorer/andCode/component entries, the preload namespaces and the IPC channels; the README gains
the explorer's keyboard table and two rows in the feature table.