An in-app code editor, the way BrowserModal is an in-app browser.
Status: built, including git. CodeMirror 6, modal-only, with the terminal
integration. The figy edit CLI shim is still not wanted — see
Decisions for what was settled and Phases for what is
and isn't done.
Reference: the screenshot in issues/ — Cursor's editor pane with a
file tree pinned to the right, file tabs across the top, and a breadcrumb bar
under them. That's the shape we're copying. What follows is how it should be
built here, and what it needs to be worth opening twice.
Related: WINDOWS-SUPPORT.md and
LINUX-SUPPORT.md for the per-platform assumptions any
filesystem-touching feature has to respect, and
CODE-INTELLIGENCE.md for how the editor gets
smarter without one.
Settled before the build started, and recorded here because each one closes off a direction the rest of the document still discusses.
| Question | Decision |
|---|---|
| Editing engine | CodeMirror 6. Monaco's 5 MB and worker wiring aren't worth the VS Code feel here. |
| Modal or pane | Modal only. Docking it as a PaneContainer pane is not planned. |
| Git integration | Reversed — built. Tree decorations, gutter marks, a diff view, file-level staging, history and fetch/push. See Git. Hunk-level staging, branch switching and pull are still out. |
figy edit CLI shim |
Not wanted. |
| Existing browser modal | Left alone. The drag/resize hook was written for the editor and the browser keeps its own copy; see the note in useDraggableModal.ts. |
Three things the plan below got wrong, corrected in the build:
- There is no fuzzy scorer to reuse. The plan said quick open should borrow
the autocomplete engine's ranking. That engine matches command-line tokens by
prefix (
matchesPrefix), which is right for shell completion and useless for a file finder — nobody typessrc/components/Editor/EditorModal.tsxto find it, they typeedmod.services/fuzzy.tsis a small scorer written for this. - Cursor positions aren't restored across launches. Open tabs and the active tab are; a per-buffer cursor would have meant either writing text into the store on every keystroke or a second persistence path, and neither earned it. Within a session, switching tabs preserves cursor, selection, folds and undo.
- No image preview. Binary files get a card naming the file and its size with a way out to the file manager. Showing the image itself needs Tauri's asset protocol enabled, which is a config and capability change this feature shouldn't smuggle in.
The editor must not be a native child webview. That is the one decision worth making before any code is written, and it goes the opposite way to the browser.
BrowserModal positions a real platform webview underneath React chrome
because it has to render arbitrary web content it does not control. Everything
awkward about that file is a consequence: rectToBounds shipping a DPI factor
because Rust and WebView2 disagreed about scale, the modal hiding the webview
mid-drag because a one-way IPC hop can't keep up with a pointer, the
placeholder that only ever shows while moving, the resize grip exiled to its own
status bar because the webview paints over anything above it, and
browser_layout.rs existing at all because GTK stacks child webviews instead of
floating them.
A code editor renders our own content. Put it in the app's own webview as
ordinary React and every one of those problems disappears — no bounds
arithmetic, no scale factor, no Linux container, no repaint lag, no z-order
fights, no focus handoff across process boundaries, and the theme tokens in
styles.css apply directly. It also means the editor works identically on all
three platforms on day one, which the browser did not.
So: React component tree, a text-editing engine bundled as JS, and a small set of new Rust filesystem commands. The interesting risks move from "can we position it" to "can we not lose the user's work" — which is the right place for them to be.
Two real candidates.
| CodeMirror 6 | Monaco (VS Code's editor) | |
|---|---|---|
| Bundle | ~250–400 KB min for core + a few languages, tree-shakeable, per-language dynamic import | ~5 MB, plus separate worker bundles |
| Build integration | Plain ESM, Vite handles it with no config | Needs vite-plugin-monaco-editor or hand-rolled worker wiring; AMD legacy leaks through |
| Offline | Trivially self-contained | Self-contained, but only after the worker paths are right in dev and in the Tauri bundle |
| Theming | Extension-based; a theme is a data structure, so --ft-* tokens map straight in |
Own token/theme system; CSS vars need a bridge |
| Feel out of the box | Excellent editing primitives; some VS Code chrome (minimap, peek) doesn't exist | Literally VS Code, minimap and all |
| Language support | Lezer grammars, ~30 first-party packages | TextMate grammars + full TS/JS IntelliSense built in |
| Mobile/IME/accessibility | First-class, rebuilt for CM6 | Adequate, desktop-shaped |
Recommendation: CodeMirror 6. FigyTerm is a ~10 MB terminal that starts
fast; a 5 MB editor engine with a worker fleet is the wrong trade for a modal
that opens beside a shell. CM6 gives us multi-cursor, folding, bracket matching,
autoclose, incremental highlighting, a real search/replace panel, undo grouping
and a proper extension system, and it lets languages arrive by dynamic import so
opening a .tsx doesn't pay for Rust and Python.
What we give up, and the honest answer for each:
- Minimap — no first-party equivalent. Community add-ons exist
(
@replit/codemirror-minimap); Phase 5 at the earliest, and arguably not missed in a modal this size. - TS IntelliSense — Monaco ships it free. For us it was an LSP client, and
it is now written:
LSP.mdfor the design,LSP-TASKS.mdfor what was built. It is off unless asked for, so the default experience is still highlighting, bracket-aware indent and word/path completion — which cover the "peek and patch a file next to my shell" case this is for.CODE-INTELLIGENCE.mdremains the cheap tier, and is the only one that works for languages no server covers.
If we later decide the VS Code feel is non-negotiable, the swap is contained:
everything CM6-specific should live behind src/components/Editor/EditorSurface.tsx
and nothing else should import from @codemirror/*.
┌──────────────────────────────────────────────────────────────────────────┐
│ ● Cta.tsx × │ Features.tsx ×│ Faq.tsx ×│ + ⟲ ⤢ × ← drag bar │
├──────────────────────────────────────────────────────────────────────────┤
│ ← → frontend › components › Cta.tsx ⌕ ⌥ ⊞ (toggle explorer) │
├────────────────────────────────────────────────┬─────────────────────────┤
│ 1 import { Icon } from "./Icon"; │ ▸ .github │
│ 2 import { site } from "@lib/site"; │ ▾ frontend │
│ 3 │ ▾ components │
│ 4 export function Cta() { │ Cta.tsx │
│ 5 return ( │ Faq.tsx │
│ ⋮ │ Hero.tsx │
│ │ ▸ src │
│ (CodeMirror surface — gutter, folds, │ ▸ src-tauri │
│ multi-cursor, search panel) │ (virtualized tree) │
├────────────────────────────────────────────────┴─────────────────────────┤
│ Ln 18, Col 12 · TSX · LF · UTF-8 · ⏎wrap · 3 files ◢ │
└──────────────────────────────────────────────────────────────────────────┘
The git columns the screenshot shows in the tree, and the branch name in the status bar, are both there now — the layout had left room for them.
- Modal frame — drag, resize, maximize, Escape-to-close, backdrop click,
the same manners as
BrowserModal, minus every native-webview workaround. The drag/resize logic inBrowserModal.tsx:258-327is worth extracting to a shareduseDraggableModalhook rather than copied; the browser then loses ~70 lines. - File tabs — dirty dot, middle-click close, drag to reorder, overflow
scroll,
⌘1-9to jump. Cap at 12 open buffers, LRU-evict clean ones. The diff gets a tab here too — see Diff view. - Breadcrumb — path segments from the workspace root, each a click target that opens a directory picker for its siblings. Back/forward navigate the file history, not the directory.
- Explorer, right side — as in the screenshot, and resizable via
react-resizable-panels, already a dependency (PaneContainer.tsxuses it). Collapsible to zero with⌘B; width persisted. It shares that column with Source Control and folder search, picked by a two-icon switcher in the toolbar rather than by a toggle each: with a toggle per panel the honest states were "tree shown", "source control shown" and "both buttons look off but a panel is open", which is what a radio group is for. Clicking the panel you are already on collapses the column, as an activity bar does. - Status bar — language, encoding, line ending, cursor position, indent setting, git branch, and the resize grip. Clicking language, encoding, line ending or indent opens a picker for it. The indent picker sets what new indentation is and leaves the file alone: a file with mixed indentation usually has it for a reason somebody else decided, and rewriting every line on a menu click turns a setting into a whole-file diff.
src/stores/editorStore.ts buffers, tabs, explorer expansion, dirty state
src/services/editor-fs.ts typed wrappers over the new Tauri commands
src/services/editor-lang.ts extension → dynamic language import + icon
src/services/editor-session.ts persistence (open tabs, cursors, tree state)
src/services/git.ts typed wrappers over the git commands
src/services/diff.ts unified diff → blocks, plus the word diff
src/hooks/useGitStatus.ts one coalesced `git status`, shared by three views
src/components/Editor/
EditorModal.tsx frame, tabs, breadcrumb, status bar
EditorSurface.tsx the only file that imports @codemirror/*
FindPanel.tsx find/replace, rendered into CodeMirror's panel
GoToLine.tsx go to line, with a preview
FileExplorer.tsx virtualized tree, git-decorated
ContextMenu.tsx the shared right-click shell
QuickOpen.tsx fuzzy file finder
GlobalSearch.tsx ripgrep-style results panel
SourceControl.tsx changes/history tabs, fetch and push, commit box
CommitHistory.tsx the history list, and which pane is on screen
CommitDetail.tsx one commit: title, body, files, Back
ChangeTally.tsx "24 edited · 6 new · 1 deleted", as chips
EditorSettingsModal.tsx the editor's own preferences
editorIndent.ts indentation guides and the active block
DiffView.tsx working tree vs HEAD
src/hooks/useFileWatcher.ts external-change subscription
editorStore is zustand, matching terminalStore / workspaceStore.
Persistence follows services/settings.ts: a versioned JSON blob in
localStorage under figy-term-editor, merged over defaults, so a schema
change degrades instead of throwing.
One rule worth stating up front: buffer text does not live in zustand. CM6 owns the document; the store holds metadata (path, dirty, cursor, language, scroll). Putting a 5,000-line file in a React store and re-rendering the tree on every keystroke is the standard way these editors end up feeling slow.
There is already dead code to wire up: filesystem::operations::list_directory
and git::operations::get_git_status both exist, are both complete, and neither
is registered in lib.rs's invoke_handler. The frontend placeholders in
services/filesystem.ts and services/git.ts are stubs returning empty values.
Phase 1 mostly finishes something half-built.
New module src-tauri/src/commands/fs.rs:
| Command | Notes |
|---|---|
fs_list_dir(path, show_hidden) |
wraps the existing function; adds git status per entry and a .gitignore flag |
fs_read_text(path) |
returns content + detected encoding + line ending + whether truncated |
fs_write_text(path, content, line_ending, expected_mtime) |
atomic: temp file in the same directory, then rename. expected_mtime is the conflict check |
fs_stat(path) |
size, mtime, readonly, is_binary sniff |
fs_create(path, is_dir) / fs_rename / fs_delete(path, to_trash) |
explorer context menu |
fs_search(root, query, opts) |
walks with ignore, matches with regex, streams results as events |
fs_reveal(path) |
Finder / Explorer / xdg-open on the parent |
The git commands live in their own module, src-tauri/src/commands/git.rs over
src/git/operations.rs:
| Command | Notes |
|---|---|
git_status(dir) |
branch, upstream divergence and every changed file, from status --porcelain=v2 --branch -z -uall |
git_file_hunks(dir, path) |
which lines differ from HEAD, from the @@ headers of a --unified=0 diff |
git_file_diff(dir, path) |
the unified diff of the working tree against HEAD, for DiffView |
git_stage / git_unstage / git_discard |
add, reset -q HEAD, checkout -q; all take repo-relative paths |
git_commit(dir, message) |
commits the index, returning git's own summary line |
git_log(dir, skip, limit) |
one page of history, from a %x1f-delimited --format |
git_commit_detail(dir, sha) / git_commit_diff(dir, sha, path) |
a commit's message body and files, and one file's diff at it |
git_fetch(dir) / git_push(dir) |
the only two calls that leave the machine — see below |
New module src-tauri/src/commands/fs_watch.rs using notify — one recursive
watcher per open workspace root, debounced ~150 ms, emitting editor://fs-change.
notify maps to FSEvents, inotify and ReadDirectoryChangesW, which behave
differently enough (coalescing, rename pairs, inotify watch limits) that the
debouncer should emit invalidations ("this subtree changed") rather than
trying to replay precise events.
New crates: notify, ignore, regex. All small, all cross-platform, none
pulling a second GUI toolkit in the way the Linux browser container did.
No tauri-plugin-fs, and no new capability entries — these are our own commands
with our own path checks, which is the point of the next section.
The features that make an editor trustworthy are unglamorous, so they're listed as requirements rather than left to Phase 5 good intentions.
- Path confinement. Every command takes a path and canonicalizes it, then rejects anything outside the open workspace roots. Symlinks are resolved before the check, not after.
- Atomic writes. Write to
.<name>.figytmpin the same directory,fsync, then rename over the target. A crash mid-save must not truncate a file. - Conflict detection. Saving sends the mtime the buffer was loaded at. If it no longer matches, the save is refused and the user is offered overwrite / reload / diff — never a silent clobber. The watcher raises the same prompt when a file changes under a clean buffer (auto-reload) or a dirty one (ask).
- Encoding and line endings preserved. Detect UTF-8/UTF-16 BOM and CRLF on
load; write back what was there. A Windows user opening a CRLF file and saving
it must not produce a whole-file diff. This is exactly the class of bug
WINDOWS-SUPPORT.mdis about. - Binary and large-file guards. Sniff for NUL bytes in the first 8 KB; binaries get a "N MB binary file" card, images get a preview, nothing gets loaded into CM6 blind. Over 5 MB: open read-only with highlighting off, and say so in the status bar. Over 50 MB: refuse with a "reveal in Finder" out.
- Read-only files open read-only with the reason shown, rather than accepting edits that will fail at save.
- Crash-safe drafts. Dirty buffers are journalled to app data on a debounce and offered back on next launch.
- Delete goes to the trash by default, not
unlink.
A modal that only edits files is a worse VS Code. These are the features that only make sense because there's a shell three inches away, and they're what should make this the thing people open FigyTerm for.
-
Click a path in terminal output, open it here. xterm's web-links addon is already loaded; a second link provider for
path,path:line, andpath:line:colturns every stack trace,tscerror,grep -nhit andgit statusline into a jump target. This is the headline feature. -
The editor's root follows the focused pane.
AppShellalready tracksliveCwdsper pane; the explorer opens on the active pane's cwd, and switching panes offers (not forces) a root change. -
Open in editor, from anywhere — status bar path, explorer, palette, and a
figy edit <file>shim alongside the existing installer, so the editor is reachable from inside the shell it's sitting next to. -
Run this file in the focused pane.
⌘↵sends a sensible command for the buffer's language to the active terminal (node,python,cargo run,sh), after saving. It's a terminal — running things is the point. -
Open a terminal here. Explorer context menu → new pane rooted at that directory, using
createTab(cwd), which already exists. -
Git-aware everywhere. Modified/untracked badges in the tree, changed-line marks in the gutter, a unified diff against HEAD, and file-level staging with a commit box. See Git.
-
Quick open (
⌘P) over the workspace, using the same fuzzy scoring the autocomplete engine already implements inservices/figy-autocomplete-engine.tsrather than a second ranking algorithm with different manners. -
Global search (
⌘⇧F) with streamed results grouped by file.A hit carries two columns, and the difference matters.
columnindexes the match insidetext, which is only a window around the hit on a long line;lineColumnis the real column in the file, and is what "go to" is given. Using one for the other put the caret in the wrong place on exactly the lines where precision is the point.The jump itself is queued rather than applied, because a buffer's CodeMirror state is built asynchronously — its grammar is a dynamic import. The effect that drains the queue is keyed on a counter (
goToToken) and not only on which buffer is active: a jump within the file already in front changes neither the active buffer nor the tab count, so it never ran at all. Picking a second hit in the open file did nothing, and so did clicking the samepath:linein terminal output twice. -
Session restore. Open tabs, cursor positions, scroll offsets, explorer expansion and modal geometry come back exactly as they were.
-
Scratch buffers.
+on the tab strip opens an untitled buffer with a language picker — for the paste-and-reformat case that currently meansvim /tmp/x.
App-level, added to the table in services/shortcuts.ts (and mirrored in
menu.rs, per the note at the top of that file):
| Action | macOS | Elsewhere |
|---|---|---|
| Toggle editor | ⌘⇧E |
Ctrl+Shift+E |
E is free in both schemes — worth re-checking against the table when this
lands, since it's the kind of thing that collides silently.
Editor-scoped bindings, handled inside the modal, which stopPropagations like
BrowserModal does. These may use plain Ctrl off macOS, because no shell
has focus while the editor does — the reasoning in shortcuts.ts for avoiding
bare Ctrl doesn't apply here, and using anything other than the conventional
editor chords would be worse.
| Action | macOS | Elsewhere |
|---|---|---|
| Save / Save all | ⌘S / ⌥⌘S |
Ctrl+S / Ctrl+Alt+S |
| Indent / outdent | Tab / ⇧Tab |
same |
| Quick open | ⌘P |
Ctrl+P |
| Cut / copy / paste | ⌘X / ⌘C / ⌘V |
Ctrl+X / Ctrl+C / Ctrl+V |
| Find / replace in file | ⌘F / ⌥⌘F |
Ctrl+F / Ctrl+H |
| Next / previous match | ↵ / ⇧↵ in the find field |
same |
| Match case / word / regex | ⌥⌘C / ⌥⌘W / ⌥⌘R |
Alt+C / Alt+W / Alt+R |
| Replace / replace all | ↵ / ⌘↵ in the replace field |
Enter / Ctrl+Enter |
| Global search | ⌘⇧F |
Ctrl+Shift+F |
| Go to line | ⌘G |
Ctrl+G |
| Close tab | ⌘W |
Ctrl+W |
| Toggle explorer | ⌘B |
Ctrl+B |
| Toggle comment | ⌘/ |
Ctrl+/ |
| Add next occurrence | ⌘D |
Ctrl+D |
| Move / copy line | ⌥↑↓ / ⇧⌥↑↓ |
Alt+↑↓ / Shift+Alt+↑↓ |
| Run file in pane | ⌘↵ |
Ctrl+Enter |
| Nth tab | ⌘1-9 |
Ctrl+1-9 |
Escape closes the innermost thing — context menu, then find panel, then quick open or go-to-line, then the diff, then the side panel, then the modal — and a dirty buffer prompts instead of closing.
Which it doesn't get for free. The modal is hidden rather than unmounted when it
closes, so on reopen the focused element is whatever had focus before — usually
the terminal underneath, which then swallows every keystroke, including ⌘C and
⌘V. EditorModal focuses the surface (or, with no file open, the modal itself,
which is why it carries tabIndex={-1}) on the transition to visible, and
AppShell hands focus back to the active pane on every route out.
Cut, copy and paste are the platform's: CodeMirror listens for the copy, cut
and paste DOM events and the webview delivers them — on macOS by way of the
Edit menu's predefined items, which is what makes those chords work in a WKWebView
at all. The context menu has no keystroke to ride on, so EditorSurface also
implements them against navigator.clipboard, following CodeMirror's own rules
(a bare cursor means the whole line; one clipboard line per cursor on paste when
the counts agree).
A menu accelerator on macOS is translated before the key reaches the webview, so a chord in the menu cannot be claimed here — it can only be given up or routed. Three were being silently eaten.
⌘His the app menu's Hide item, and a system convention worth keeping. Replace is⌥⌘Fon macOS for that reason, as it is in every macOS editor;Ctrl+His free elsewhere and stays.⌘Wwas the Window menu's predefined Close item, which closed the whole window — unsaved buffers and all — where every editor closes the current tab.menu.rsreplaces it with a custom item that emitsmenu://close-window;AppShellcloses the editor's tab while the editor is up and the window otherwise. Off macOS the predefined item isAlt+F4and collides with nothing, so it is left alone.⌘Z/⇧⌘Zwere the Edit menu's predefined Undo and Redo, sohistoryKeymapnever ran: the chord becameundo:on the WKWebView, which runs WebKit's undo manager over a DOM CodeMirror rewrites underneath it. Custom items now emitmenu://undoandmenu://redo, andAppShellgives the editor first refusal throughhistoryRef— a callback rather than a counted prop, because undo is held down and two presses in one React batch would collapse into one undo. Declined when the text doesn't hold the keyboard (the caret is in the search field, say), and thendocument.execCommandgives the focused field the webview's own undo, which is what the menu item used to do for it. Off macOS the Edit menu carries only Copy and Paste, soCtrl+Zreaches CodeMirror already.
Cut, copy, paste and select all stay predefined and untouched — those genuinely are the webview's to perform.
editorIndent.ts, written rather than taken from
@replit/codemirror-indentation-markers. Partly one fewer dependency in what
is already the largest chunk of the bundle, but mostly because the whole of it
is two rules and both are judgement calls every implementation makes
differently.
Which guides a line gets.
A line's guides come from its own indent. A blank line has none of its own, so it borrows the smaller of the indents either side of it.
Smaller, not larger: a blank line between a nested block and the statement after it belongs to whichever is shallower, and drawing the deeper one runs a guide past the end of the block it was describing. At the top or bottom of a file there is no neighbour to borrow from and no block to describe, so a trailing blank line gets nothing.
Which guide is active. The innermost block containing the cursor, drawn several times brighter, along that block's whole extent rather than only on the cursor's line — the point of it is to show where the block ends.
A line that opens a block belongs to the block it opens.
Resting on function outer() { lights the guide running down its body, not the
one around the function itself, because the body is what you are about to be
looking at. The consequences are worth spelling out, because they are what
separates this from a highlight that is merely decorative:
| Cursor on | Lit |
|---|---|
function outer() { |
the function's body, all of it |
a line inside an if |
that if's body |
| a blank line inside it | the same — the block does not break |
the } closing the if |
the enclosing function body, not the if |
| a sibling block at the same depth | nothing; only the block you are in |
| a top-level line | nothing; there is no guide to brighten |
Both rules go through one effectiveColumns, deliberately. When the drawn
guides and the active highlight worked the indent out separately they disagreed
around blank lines, and a highlight landing one level off the guide it is
supposed to be brightening reads as a rendering bug rather than as a different
answer to a hard question.
The block's extent is traced by walking out while the indent holds, bounded at 5,000 lines each way: in a file that is one enormous indented block that walk is otherwise the whole file, on every cursor move.
It deliberately does not consult the syntax tree. Guides are a visual summary of the whitespace, and a reader comparing them against the text expects them to agree with what is in the file — including where the file's indentation is inconsistent, which is exactly when they are most useful. A tree-driven version would draw the indentation the parser thinks ought to be there.
Drawn as one pseudo-element per line carrying --cm-indent-depth and
--cm-indent-width: a repeating gradient steps a hairline every level, and the
element's own width is what stops it at the last one rather than ruling the
whole line. So a line twelve levels deep costs one style attribute instead of
twelve widgets. The width is in ch, which in a monospaced editor is exactly
one column at any font size. The active guide is a second pseudo-element
painted over the faint one at the same column — a repeating gradient cannot
colour one of its own stripes differently — and it exists only on the lines of
the block the cursor is in, so an idle file draws none.
Depth, width and the active level are all in one decoration, keyed on all
three. Decoration.line compares by identity, so a fresh one per line would
make every visible line's decoration distinct and defeat CodeMirror's diffing —
every line torn down and rebuilt on every keystroke. And they cannot be two
layered decorations, because both would want to set style and only one of
them can.
It rebuilds on selectionSet as well as the usual three, because moving the
cursor moves which guide is active — that is the whole feature. Over the visible
lines only, which is the same order of work as the active-line highlight drawn
beside it.
ContextMenu in components/Editor/ is the shared shell: fixed positioning
clamped to the window after measuring, and dismissal on an outside press,
Escape, blur, resize or scroll. The press listener runs in the capture phase
because the modal stops mousedown propagation on its own container, and Escape
is stopped there rather than let through — otherwise one press closed both the
menu and the editor.
The text surface's menu carries cut/copy/paste, select all, undo/redo, find, go to line, save and the path actions; the file tree's carries open, new file and folder, rename, move to folder, the two copy-path variants, reveal and move-to-trash.
Two ways, because one of them doesn't scale. Dragging a row onto a folder
moves it there; a file's row means the folder that file is in, the header and
the empty space below the tree both mean the project root — otherwise there is
no way to drag something back out once the tree fills the panel — and a closed
folder springs open after 600ms of being hovered, so a drop two levels down
doesn't mean abandoning the drag to click a chevron. Illegal targets (into
itself, into its own descendant, back where it already is) refuse the drop
rather than accepting it and doing nothing, which is what canMoveInto is for.
"Move to Folder…" in the context menu is the other way: a fuzzy-filtered
list of every folder in the project, which is how you move a file across a tree
too tall to drag through. Its list comes from fs_list_files — already there
for ⌘P, capped and .gitignored on the Rust side — plus the folders the tree
has read, since a folder with no files in it appears in no file list.
Either way, renamePath does the work and the new path comes back from the
backend canonicalised. That path goes to editorStore.pathMoved, which
re-points every open tab at or under what moved, and rewrites the remembered
expansions. A rename that didn't do this left the tab showing the old name and
saving to the old path.
dragDropEnabled: falseintauri.conf.jsonis what makes any of this work. With it enabled — the default — wry installs a drag handler on the webview whoseperformDragOperationreturnsYESwithout callingsuper(wry/src/wkwebview/drag_drop.rs), because Tauri's listener consumes every event to publishtauri://drag-drop. WebKit therefore never sees the drop and nodropevent reaches the page: HTML5 drag and drop is silently dead, tab reordering included. The app reads no OS file drops, so nothing is given up by turning it off.
Reversing the decision at the top of this document. It was made on the grounds that git is a feature of its own, which is true — but a terminal's editor is opened because something was wrong in a file, and the first question about a file next to a shell is which lines you changed. Without that, the tree is a file manager.
git/operations.rs runs the git binary. This is a terminal: git is already
installed, already configured, and already the thing the user's credential
helper, core.hooksPath, worktrees, submodules, LFS filters and
.gitattributes are set up for. libgit2 would mean a C build on three
platforms and a second implementation that disagrees with all of it — for a
status list and a diff. A process per refresh is the cheaper mistake.
Porcelain v2, not v1. v1's XY path lines need the rename form guessed
from context and cannot say whether HEAD is detached; v2 states both and is
documented as stable for machine reading. -z because a filename can contain a
newline, -uall because a folder marked untracked with unmarked files inside it
reads as a bug, and --no-optional-locks on every invocation so a status
refresh can't take the index lock from a git the user is running in the pane
behind.
v2's records are positional, and getting the field count wrong is the one mistake in this file that doesn't announce itself:
1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>
u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
Skip one field too few and the path comes back as
4595984f3c8850d67e1b0496fe95ba7901dc9df4 src-tauri/src/lib.rs — which is not a
path, but is a perfectly good String. Nothing errors, the tree shows a
plausible name, and then every git argument built from it fails: the diff, the
staging, all of it. That is exactly what happened, so the counts are named
constants with the record shapes above them, and there are tests at the bottom
of git/operations.rs for each shape plus a path with a space in it.
Opening ~/project/src on a repo rooted at ~/project is normal, and git
reports paths relative to the top level either way. So the directory argument
is confined to a workspace root as every filesystem command is, and file
arguments cross as repo-relative strings — git's own output form, checked by
relative_arg for .., absolute paths and leading dashes. repoRelative in
services/git.ts is the inverse, and it matches case-insensitively off Linux
for the same reason sameFolder does.
That means git legitimately reports and acts on files the filesystem commands
would refuse. It's the repository the editor was opened inside; scoping status
to a subdirectory would report something no git command agrees with.
useGitStatus runs it and everything reads from there: the tree's badges, the
panel's lists and the gutter's fetch. Three components each running git status
on the same watcher event would be three processes for one answer. It coalesces,
because the watcher fires in bursts — a git rebase or an npm install under a
watched tree would otherwise fork a hundred times.
The refresh trigger is the file watcher, not a poll — but that took two fixes, both of which had the panel showing a repository state that was no longer true.
.git was excluded from the watcher outright, on the grounds that it churns
and that none of it is a file anybody has open. Both true, and the conclusion
was still wrong: nothing under .git being reported meant the panel only ever
refreshed after an action taken in the editor itself, so a commit in the pane
behind left it listing files that were already committed. Four things inside it
are now let through and the rest is not — HEAD, index, refs/… and
packed-refs, which are the four git status reads. objects/ and logs/ are
the churn and say nothing a ref does not; *.lock is excluded by name, because
index.lock appears and vanishes around every single git command. There are
tests, because too narrow means a commit goes unnoticed and too wide means every
git status feeds the watcher back into itself.
That last risk is closed from the other end too: our git status runs with
--no-optional-locks, so reading the repository cannot rewrite the index.
And refresh starved. It re-armed its 250 ms window on every call, so a
burst of requests closer together than the window pushed the run out
indefinitely — which is exactly what a commit produces, git writing the index,
the refs and the reflog in quick succession. A run already scheduled is now left
alone. There is nothing to coalesce: the run reads the repository as it finds
it, so a later request wants precisely what the pending one is already going to
fetch. A ceiling brings it forward if it has somehow been waiting a second.
gitTick still covers the gap between the editor's own action finishing and
the watcher event landing, which is long enough to look broken.
One case the watcher cannot see: a worktree or a submodule, where .git is a
file pointing elsewhere and the repository's state lives outside the watched
tree. The editor's own actions still refresh; an outside commit needs the panel's
refresh button.
Its own column between the line numbers and the fold arrows, driven by the @@
headers of git diff --unified=0 HEAD. Only the headers are read, so a file
with a large diff stays cheap. A hunk covering no lines of the working file is a
pure deletion — there is nothing left of it to mark, so it gets a wedge on the
line the gap is now above.
Marks are a GutterMarker with an elementClass rather than a toDOM: on a
new file where every line is added, that's the difference between one CSS rule
and twenty thousand divs. They're mapped through edits rather than dropped —
stale the moment you type, but a mark that slides with its line is much closer
to the truth than one anchored to a byte offset, and the next save refetches.
Untracked files are the one case the backend doesn't answer: it returns
untracked: true and the surface marks every line, because it has the document
in front of it and the backend would have to read the file again just to count.
SourceControl is shaped like GitHub Desktop's Changes pane, which means the
index is not on screen. One flat list of what changed, a checkbox per file
saying whether it goes in the next commit, and a summary/description box at the
bottom with Commit N files to <branch>. No staged/unstaged groups, no stage
and unstage buttons.
Rows show the file's name only, not its folder. The path is in the tooltip
and in the diff header above, which is where you are once a file is open;
carrying a dim src/components/Editor on every row of a 300px column spends
most of the width on the part that repeats.
That is a real trade and it costs something. Git's index is a genuine third state, and a file half-staged from the terminal cannot be represented here — this panel commits all of that file or none of it. What it buys is the question most people actually have, "which of these am I committing right now", answered without first having to learn what an index is. VS Code's two-group layout answers a different question well; this answers the common one.
So committing has to make the index match the checkboxes: commitFiles in
EditorModal unstages everything unticked, stages everything ticked, then
commits. The unstage half is the one that's easy to forget and the one that
matters — without it, a file staged from the terminal and then unticked here
would still end up in the commit.
The checkbox state is tracked as the excluded set, not the included one.
With included, a file appearing while you type a commit message — a build
touching something, a save in another tab — would arrive unticked and be
silently left out. Excluding by exception means anything new is in by default,
which is both what GitHub Desktop does and the safer direction to be wrong in.
Discard is the only action here that destroys work, so it confirms, and it
says which of two different things it is about to do: git checkout for a
tracked file, and a move to the trash for an untracked one. git clean
would be the obvious spelling and it is the wrong one — it unlinks, which would
make "discard" the only unrecoverable button in the editor.
Deliberately absent:
- Hunk-level staging. A diff editor with selectable ranges, a patch builder
and an
apply --cachedround-trip. A feature of its own, and a bad fit for a 300px column — and with the index hidden there is nowhere to put the result. - Branch switching, and pull. Checkout fails in a dozen interesting ways and a pull can leave a conflict needing a merge editor; both belong in the shell that is three inches away, and both would need a UI for the failure modes rather than for the happy path. Fetch and push are here — see Fetch and push.
- A chord for the panel.
⌘⇧Gis CodeMirror's find-previous and⌃⇧Gis not free everywhere; the toolbar button and the status-bar branch are the way in.
A second tab in the panel, CommitHistory, and a drawer a commit opens
into: the subject as a heading, the author and date, any branch or tag pointing
at it, the message body, then the files with their +/− counts — and a Back
button.
The drawer replaced an expanding row, which is what this was first. Folding a commit open in place looked like the cheaper interaction and wasn't: a commit message is a paragraph, so unfolding one pushes every commit below it off a 300px column. The list you were reading is gone either way — but with none of the room the message needs, and no way back except finding the row again. Replacing the pane admits what is happening, gives the message the full width, and has somewhere to put a Back button.
It slides in from the right, which is not decoration: two panes with no
transition between them read as "the panel was replaced", and the movement is
what says the list is still behind this. Under prefers-reduced-motion it
simply appears, which is the correct behaviour there rather than a degraded one.
The body is folded above six lines or four hundred characters — both,
because either alone gets it wrong: six lines of prose that wrap three times
each is a wall of text by any measure, and one 900-character line is a wall of
text with one newline in it. Read more unfolds it. Folded by max-height
rather than by cutting the string: slicing at 400 characters lands mid-word,
mid-list and mid-code-fence, and expanding it then reflows everything below it.
A height with a fade over the bottom shows a real partial line, which is what
says there is more. A thorough commit message is the reason to open a commit
and also the thing that pushes its file list off the bottom of a 300px column.
Clicking a file in the drawer opens its diff in the same diff-check tab, with
the short SHA in the header — without it, "what I changed" and "what that commit
changed" are the same file with the same counts and no way to tell them apart.
A commit that disappears under an amend or a rebase closes the drawer rather
than describing an object that no longer exists.
Paged, not infinite. git log will hand over forty thousand commits and nobody
is scrolling to the end of them, so it is a page and a button. A reload starts
from the top and discards what was already loaded rather than splicing: a
new commit shifts the whole list down by one, so "refetch page 0 and merge"
duplicates a row at every page boundary.
The log format is %x1f-delimited with %x1e between records — unit and record
separator, not anything printable, because a commit subject can contain any
character a person can type and every delimiter that looks safe turns out to be
in somebody's commit message. A merge is detected from %P having more than one
parent, which is also why its diff and file list are asked for with
--first-parent: git show on a merge prints nothing at all by default.
commit_detail gets the body and the file list from one git show — the format
output first, terminated by %x1e, then the --name-status records — and the
line counts from a second, --numstat, joined on the path. Two calls because
those are two output formats rather than two columns of one; asking for both at
once prints one block after the other, which is more parsing than running it
twice.
Both of those shapes were checked against real output rather than assumed, and both had a trap in them:
- With a non-empty
--format, git separates the two outputs with a NUL and a newline, so the first name-status record arrives as"\nM".'\n'is not a status letter, so it fell through to the default and reported the first file of every commit as modified whatever it really was. The status is trimmed before its letter is read, and there is a test for exactly that string. --numstat -zputs a rename's paths in their own fields and leaves the inline path empty —1\t0\t\0old\0new\0— which is not what the non--zformat looks like. A binary file is-on both counts, meaning "no lines to count", not "nothing changed".
revision_arg refuses anything that isn't hex before it becomes a git argument.
These SHAs come from this module's own output, so like relative_arg it is
there for the abnormal case — and it is what stops a revision ever being read as
an option.
tally in services/git.ts, drawn by ChangeTally, shown under both the
Changes list and a commit's file list — one place decides the grouping, so a
working tree and a commit of the same shape read identically.
Git has eight status letters and nobody wants a legend for them, so they collapse into the four things people say out loud:
| Shown | From | Why |
|---|---|---|
| new | added, untracked, copied |
A copy is a file that wasn't there before, whatever git knows about where its contents came from |
| edited | modified, typeChanged |
A file becoming a symlink is a strange edit, not a fifth category |
| deleted | deleted |
|
| renamed | renamed |
Kept apart: a rename is neither new nor edited, and folding it into either overstates what changed — R100 moved a file and touched nothing in it |
conflicted gets its own count, being a state to resolve rather than a change
that has happened. Empty counts are dropped, and the whole row is suppressed
when one kind accounts for everything: "3 files changed · 3 edited" is the same
sentence twice.
The chips are coloured by GitChange through the same git-* row classes the
tree uses, so "new" is the green the change gutter draws an added line in.
One palette, three places.
The Changes tab counts every changed file rather than the ticked ones: the tally describes the working tree — "this is what you have done" — while the checkboxes are about the next commit, and recounting on each tick would have the two answering the same question.
git log marks each commit unpushed, from rev-list @{upstream}..HEAD. Asked
of git rather than inferred from the ahead count and the list's order: "the
newest N are the unpushed ones" holds only while the history is linear, and a
branch that has merged its upstream back in is exactly the case where somebody
wants to know what is still local.
Nothing is marked when there is no upstream. On an unpublished branch every commit is unpushed, which is a true fact about five hundred rows and a useful one about none of them — the Publish button says it once instead.
It is one colour in the four places it is mentioned — the history row's arrow, the drawer's tag, the branch counter and the Push button — so they read as one fact rather than four. Amber rather than red: it is something to do, not something wrong.
Two buttons, not one "sync". They do different things — one reads, one writes —
and a single button that guesses which you meant is a button that occasionally
pushes when you wanted to look. Push publishes the branch when it has no
upstream, choosing origin, or the only remote, and otherwise refusing: picking
between three remotes on the user's behalf is how a branch ends up on the wrong
one.
Pull is deliberately absent. A pull merges, a merge conflicts, and a conflict needs somewhere to be resolved — a merge editor is a feature of its own and not one this panel has. Fetch tells you that you are behind and the shell is three inches away.
These are the only calls in the module that can block forever: a credential
prompt with no terminal to show it in, an SSH passphrase, a host that accepts
the connection and then says nothing. So they go through git_network rather
than git, which differs in exactly two ways, both of them the point:
GIT_TERMINAL_PROMPT=0, so git fails fast instead of waiting for a username nobody can type. A configured credential helper still works, which is the case that matters.- A 120-second deadline, because that cannot stop ssh prompting, and a
panel stuck on "Pushing…" with no way out is worse than one that says it gave
up and suggests running it in the terminal once.
GIT_SSH_COMMANDwithBatchMode=yeswould prevent that one cause specifically, and was rejected: it overrides a user's owncore.sshCommand, and a timeout covers every cause rather than the one we thought of.
Output is returned on success too, not just on failure. Git reports a
successful push on stderr — 3b11460..a1b2c3d main -> main is the
confirmation — and "Everything up-to-date" is an answer. It lands in a row that
is always mounted and animates open, because inserting it into the flow snapped
the tabs and the whole file list down by its height the instant a fetch
finished. The buttons' labels do not change while they run either: a centred
icon-and-text group re-centres itself as "Fetch" becomes "Fetching…", so the
icon slides sideways and the button appears to twitch. The spin and the
disabled state say it is working without moving anything.
A commit's SHA and the branch name open on the remote. git-forge.ts turns a
remote URL into web addresses, and it is kept apart from git.ts because it is
guesswork of a particular kind: a remote URL says where to fetch from, and
nothing in git says what a commit looks like in a browser. So it is a table of
conventions with a fallback, and it is honest about what it cannot answer — a
local-path clone has no web page behind it, parseRemote returns null, and the
SHA renders as plain text. A link that 404s is worse than no link.
The four spellings a remote arrives in are all handled, including the SSH
shorthand git@host:user/repo.git, which is not a URL and cannot be given to
new URL() — the colon there is a separator, not a port. GitLab's /-/commit/
and Bitbucket's /commits/ differ from GitHub's /commit/; an unrecognised
host gets GitHub's, which Gitea and Forgejo also use, so the fallback is right
more often than it is wrong.
origin/ is stripped from a tracking ref before it becomes a branch URL — the
remote has no branch called that — but only when the prefix matches a remote we
know, so feature/code-editor keeps both halves.
The SHA gets a background. Monospace hex on its own does not read as something you can press; the chip is what says "this is an object, and it goes somewhere". It opens in the system browser rather than the app's own, because a commit page is something people send to a colleague and open beside four other tabs.
DiffView renders git diff's own output rather than recomputing one. The diff
on screen is then exactly the one git diff prints in the pane behind,
including whatever diff.algorithm and .gitattributes have to say about it —
and the first thing anyone does when a diff looks wrong is check it against
git diff. It takes the editor's whole column, not the side panel's: 300px of
unified diff is not reading, it's guessing.
Always the working tree against HEAD, with no staged/unstaged toggle. That follows from the panel: the question is "what would committing this record?", and the index isn't something the panel exposes, so a control for switching between two halves of it would be answering a question nothing else here asks.
services/diff.ts turns the output into blocks and marks the words inside a
changed line. This is the one thing computed locally rather than asked of git,
because git's line diff simply doesn't contain it: --word-diff is a
different output format, not an addition to this one, so using it would mean
running and parsing a second diff per file — and then lining its runs up with
rows that came from the first.
It matters more than it sounds. A line whose only difference is a → the
reads as two entirely different lines when the whole row is tinted; the point of
marking the words is that the eye goes straight to them. Three details make the
difference between that and noise:
- Token granularity. Words, runs of whitespace, and every other character
on its own. Splitting by word alone reports
foo.bar→foo.bazas one changed token covering the lot; splitting per character marksa→theas three separate specks. - Runs are joined. The subsequence legitimately matches the spaces
between words, so
the deferred part→both there nowcomes back as three highlights with two gaps punched through them — accurate, and it reads as stripes.joinRunsabsorbs whitespace that sits between two changed tokens, which is what every tool that does this does. - Dissimilar lines aren't paired. A removal and an addition that happen to
be adjacent are not necessarily versions of each other, and highlighting the
handful of tokens a deleted function shares with an unrelated new one
(
const,(,)) picks out noise and hides the fact that the whole line is different. Below 30% shared non-whitespace, both lines are left plainly added and removed.
The comparison is an LCS table over tokens, capped at 400 per side; past that it trims the common ends and calls the middle changed, which is the same answer for one edit in the middle of a line and is instant. A minified bundle on one line would otherwise lock the window up to highlight something nobody can read.
Both remembered in the session, both changed from the diff's own header.
Unified or split. Neither is better — unified is compact and reads top to
bottom, split shows what a line was beside what it is, which matters when
both sides are long. This is why the parser produces blocks rather than a
flat row list: a run of removals followed by a run of additions is one change,
and split needs it as one thing to zip the two sides together. A flat list can
be rendered unified but not split. Each layout then flattens the blocks its own
way — and unified groups the removals before the additions rather than
alternating them, because -, -, +, + is how a diff reads while
-, +, -, + makes a two-line change look like two one-line changes. The
blank on one side of a split row is not a gap to skip; it is where lines were
added or removed rather than changed, so it's drawn hatched.
Style is not a colour scheme. The presets differ in what they show, so that whichever tool someone already reads diffs in, this can look like it:
| Rows | Words | Signs | Numbers | |
|---|---|---|---|---|
github (default) |
tinted | darker tint | + / − |
old and new |
gitlab |
tinted | tint + underline | + / − |
old and new |
vscode |
stronger band, text left alone | stronger band | none | new only |
delta |
tinted | tint + bold | none | both, boxed |
plain |
none | none | + / − |
none |
plain is git diff in a terminal, and deliberately has no word highlight: a
terminal has none, and a preset that adds one isn't this preset.
Each preset sets a handful of custom properties and the structural rules read
them. --diff-cols is the grid, and it lists one track per visible item —
a preset that drops a column also removes it from layout with display: none,
because an item in a 0px track still spills its text over its neighbour.
It lives in the tab strip, as a tab called diff-check — deliberately
extension-free, because it is not a file and a tab reading lib.rs beside the
tab for lib.rs would be two tabs claiming to be the same thing. It is a tab
and not a buffer: it has no document, nothing to save and nothing to reorder,
and putting it in the store alongside real files would mean every path that
iterates buffers first asking whether this one is really a file. What it needed
from being a tab was somewhere visible to live and a way to be closed — before
this it covered the editor with no representation at all, so clicking a file
tab lost it with nothing to click to get it back.
Hence two pieces of state rather than one: diffTarget is whether the tab
exists, diffFocused is whether it is in front. Collapsing them would mean
switching to a file destroyed the diff, which is not what a tab does. Every
route that opens a file clears the focus flag, which is why it is done inside
openPath rather than at each of the six call sites.
The toolbar above is scoped to the file pane, and while the diff is in front the file-scoped half of it goes quiet: the breadcrumb crumbs, Save, and the Markdown preview toggle. All three describe or act on the active buffer, so with a diff on screen they were naming and saving the tab you had before it — the breadcrumb most visibly, reading out a path for a file you are not looking at. The workspace chip stays, because it is the workspace switcher rather than part of a path, and Previous/Next file now step out of the diff rather than paging a hidden pane.
Side-by-side is what that gives up. In a modal this size, two gutters and two 40-column panes is worse than one readable column.
Both were CodeMirror's stock dialogs, recoloured. Both are now the editor's own markup, for the same reason: the library's versions are correct and say nothing.
FindPanel is still CodeMirror's panel where it matters — createPanel
hands the component the panel's DOM to render into, so the search state, the
highlighting of every match, the open/close lifecycle and the ⌘F-while-open
behaviour stay the library's. What's reimplemented is what you can see, and the
part worth having is the count: without "3 of 17" there is no way to know
whether Enter is about to wrap. Going through createPanel rather than floating
a widget over the editor keeps two things that are easy to lose — the match
highlighting, which CodeMirror only draws while its panel is registered as open,
and the layout, since a panel pushes the text down instead of covering the first
two lines of it.
Two details that are not obvious:
- Typing must not walk the selection forward.
findNextsteps from the selection, so driving it per keystroke means typingfoolands on the thirdf. Each edit of the query re-searches from where the caret was when the panel opened; only Enter moves that mark on. - The count needs a re-render on every keystroke, which is the one thing
EditorSurfaceotherwise refuses to do. It's gated on the panel existing.
GoToLine replaces a dialog that was an unstyled text field and a "go"
button. It says where it will land ("Line 412 of 890"), says when a number is
past the end before Enter rather than after, and scrolls the editor as you type
without moving the caret — cancelling puts the view back. The accepted syntax is
CodeMirror's own and kept deliberately: 412, 412:8, +20, -20, 50%.
EditorSettingsModal, opened from the gear in the toolbar or the text's context
menu. Its own modal rather than a page in the app's Settings dialog, for two
reasons that both come down to where it lives:
- That dialog is Headless UI's, which makes
#rootinert while it is open — the exact problemOverlayPortalexists to work around. Opening it from inside the editor would freeze the editor behind it. - These settings are judged by looking at the thing they change. Font size and line height are decided by reading the code next to them, which needs the code still on screen rather than covered by a full-window dialog.
Every control applies immediately and there is no Save button: there is nothing to batch, and a checkbox you have to confirm is a checkbox that lies about what the editor currently looks like.
There is no chord for it. ⌘, is the app's own Settings, and on macOS that
is a native menu accelerator — translated before the webview sees the key — so
the editor could not claim it even if that were the right call. See
Chords the native menu owns.
The editor was rendering in the terminal's font at the terminal's size. That is a setting chosen for reading a shell; code usually wants a different size and often a different face, and a preference that can only be right for one of two panes is not one setting, it is two.
So the editor has its own, with a sentinel: an empty family or a zero size means "follow the terminal". A sentinel rather than copying the terminal's value in at first run, because then changing the terminal's font still moves the editor for anyone who never chose one — which is what somebody who never chose would expect.
Each toggle that changes an extension gets a compartment — guides, bracket
closing, word completion. The alternative is rebuilding every open buffer's
EditorState when a checkbox moves, which throws away its undo history, its
folds and its selection to change the colour of some hairlines.
Only the buffer in front is reconfigured. The others are immutable states in a
map, and they carry a settingsVersion so one notices on activation that it
missed a change — the same trick themeVersion already used for the theme.
Both paths go through one settingEffects, so they cannot drift apart and
leave one buffer with guides and another without.
Word wrap is seeded from the setting and then owned by the session, because the status bar's toggle is a per-file override: changing the default must not undo a toggle made two minutes ago.
Indentation is the fallback, not an override. A file's own indentation
still wins — detectIndent now returns null rather than guessing two spaces
when a file has nothing indented in it, which is the one case where the user's
preference is the only evidence there is.
The stored blob is user-editable, so loadSession merges field by field over
the defaults rather than taking settings whole: a session written by an older
build has none, and a missing field must become a default rather than an
undefined reaching a CSS property. Numbers are clamped for the same reason —
a hand-edited lineHeight: 0 would collapse every line to nothing.
CM6 themes are data, so the whole thing is one EditorView.theme built from the
existing --ft-* tokens plus a syntax palette, subscribed to themeStore the
way BrowserModal subscribes for setBrowserTheme. New CSS goes in
styles.css as an .editor-* block following the existing .browser-*
convention — same file, same naming, no CSS modules introduced for one feature.
The syntax palette needs picking deliberately for both themes rather than lifting One Dark, since the light theme here is a genuinely light UI and most ported dark palettes are illegible on it.
- Virtualize the tree. A
node_modulesexpansion is 40,000 rows. - Lazy languages.
editor-lang.tsmaps extension →() => import(...), so the initial chunk carries no grammars. - Never re-render the surface.
EditorSurfacemounts once per buffer and talks to CM6 through effects and refs; store subscriptions are selector-scoped. - Debounce the watcher and coalesce to subtree invalidations.
- Cap the search stream at ~2,000 results with a "showing first N".
- One
git statusper watcher burst, coalesced inuseGitStatus, read by the tree, the panel and the gutter alike. Capped at 5,000 changed files. - Only the
@@headers of a per-file diff are parsed for the change gutter, so a file with a large diff costs the same as one with a small one.
Mostly the same list WINDOWS-SUPPORT.md and LINUX-SUPPORT.md already warn
about, applied to a feature that touches files harder than anything else in the
app.
is_hiddeninoperations.rs:28is a dot-prefix check. That's right on macOS/Linux and wrong on Windows, where hidden is a file attribute. Needs a#[cfg(windows)]branch readingFILE_ATTRIBUTE_HIDDEN.- Path separators in the breadcrumb and in every path we display or join.
- Case-insensitive filesystems: two tabs must not open for
Cta.tsxandcta.tsx. Compare canonicalized paths. - Windows file locking — a save can fail because something else holds the file. Surface the real error; don't retry forever.
- Windows long paths (>260 chars) need the
\\?\prefix or the operation fails in ways that read as "file not found". - inotify watch limits on Linux: a big tree can exhaust them. Detect the error and fall back to polling the visible subtree with a warning.
- Trash:
NSFileManager/ Shell API /gio trash. If it isn't available, say "permanently delete" in the prompt rather than quietly doing it.
What was built, against the plan above.
Phase 0 — Groundwork ✅
-
useDraggableModal, written for the editor (the browser keeps its own copy — deliberate) - CodeMirror 6 dependencies; production bundle confirmed
-
filesystem/operations.rsfinished and registered — it was complete but unwired
Phase 1 — Read-only editor ✅
-
fs.rs:fs_list_dir,fs_read_text,fs_stat, plus path confinement -
EditorModalframe: tabs, breadcrumb, status bar, drag/resize/maximize -
EditorSurfacewith highlighting, gutter, folding, fold placeholders -
FileExplorer, virtualized, right side, resizable, hidden-file toggle - Theme bridge;
⌘⇧Ein the shortcut table, the native menu and the palette - Binary and too-large cards
Phase 2 — Editing that can be trusted ✅
-
fs_write_text: temp-file-and-rename,fsync, permissions preserved, mtime conflict check - Encoding (UTF-8/BOM/UTF-16) and line endings detected and written back unchanged
- Dirty state, close prompts, save / save-all, crash-safe drafts with a recovery prompt
-
fs_watch.rs+useFileWatcher: reload clean buffers, flag dirty ones - Multi-cursor, autoclose, comment toggle, indent detection, find/replace
- Word completion from the open document, in place of an LSP
Phase 3 — Terminal integration ✅
- xterm link provider:
path,path:line,path:line:colin output open here - Root defaults to the focused pane's cwd; a button offers it when they diverge
- "Open in Terminal" from the tree;
⌘↵saves and runs the file in the focused pane - Palette entry; session restore of the open folder and tabs
-
figy editshim — not wanted
Phase 4 — Finding things ✅
- Quick open (
⌘P) with its own fuzzy scorer, over a.gitignore-aware file list -
fs_searchstreamed toGlobalSearch, grouped by file, with case/word/regex - Context menu: new / rename / delete-to-trash / copy path / reveal / open terminal
- Drag-and-drop move within the tree
- Replace-in-files — not built
Phase 5 — Git and polish ✅ (the git half was deferred, then built)
-
commands/git.rsover porcelain v2: status, per-file hunks, diff, stage, unstage, discard, commit - Tree decorations, gutter change marks, branch in the status bar
-
DiffView: unified and split layouts, word-level highlighting inside changed lines, five presets (GitHub, GitLab, VS Code, Delta, plaingit diff) -
SourceControlpanel, GitHub Desktop-shaped: one changes list, a checkbox per file, summary/description,Commit N files to <branch> - History tab: paged
git log, a commit opening a drawer with its message and files, each file opening a diff at that revision - Fetch and push, with a deadline so a credential prompt can't hang the panel
- A find/replace panel with a match count, and a go-to-line overlay that previews
- Language, encoding, line-ending and indent pickers; word wrap toggle
- Indentation guides, with the cursor's block highlighted
- An editor settings modal, with the editor's own font rather than the terminal's
- The active tab scrolls into view
- Scratch buffers with save-as
- Outline, multi-root workspaces, a keymap section in Settings, minimap
Phase 6 — Later
- Stage/revert hunks, branch switching, pull, dock-as-pane
- Language servers — diagnostics, hover, completion, signature help, go-to-definition, references, rename, formatting and code actions, off by default and opt-in per language. Design in
LSP.md, build inLSP-TASKS.md - Code intelligence without a server —
CODE-INTELLIGENCE.md: outline, project index, diagnostics harvested from the build already running in the pane behind. Still worth having, and now alongside LSP rather than instead of it — it is the tier that works for languages no server covers
Worth naming so they don't creep in.
- Not an IDE. No debugger, no test runner UI, no extension host. There's a terminal right there.
- LSP is opt-in, and stays that way. It was Phase 6 and it is now built —
see
LSP.mdandLSP-TASKS.md— but the argument that kept it out for so long still holds: a language server is a heavyweight child process,rust-analyzeron a large repository is measured in gigabytes, and this is a terminal that starts fast. So it is off by default, started per language on the first buffer that needs one, stopped when idle, and never on a file opened from a terminal link — that path exists to be fast. Nothing starts because a file happened to open. - No AI features here. Whatever comes later, it shouldn't ride in on the editor's first version.
- No remote/SSH editing. The path-confinement model assumes local paths.
- Not a
vim/nanoreplacement. People with a.vimrcwill keep using it; this is for the times you don't want to.
cargo test covers the two things in here that are silently wrong when wrong:
the porcelain v2 field counts and the unified-diff hunk headers. Everything else
is a manual matrix, run per platform (macOS, Windows, Linux):
- Open a 5,000-line file: highlighting correct, scrolling smooth, no jank on keystroke.
- Save a CRLF file:
git diffshows only the line you changed. - Edit a file in
vimin the pane behind, with the buffer clean, then dirty: reload / prompt behave as specified. - Expand
node_modules: no freeze. kill -9the app with a dirty buffer: the draft comes back.- Click a
tscerror path in terminal output: correct file, correct line. - Try to open
/etc/hostswith a workspace root of~/project: refused. - Theme toggle with the editor open: both themes legible, no flash.
- Change the font size in editor settings: the open buffer and every background tab follow, and none of them loses its undo history or its folds.
- Set a font in editor settings, then change the terminal's: the editor keeps its own. Clear it again and the editor follows the terminal once more.
- Twenty files open, then
⌘1-9and Previous/Next file: the tab you land on scrolls into view, and does it visibly rather than teleporting. - Browser and editor both in picture-in-picture, side by side: clicking the editor must not blank the browser's page. Then drag the editor over it — it must blank, and come back when the editor moves off.
- Indentation guides line up with the text at every font size, continue through a blank line inside a block, and stop at the shallower level between blocks.
- The active guide follows the cursor, covers the whole block rather than one line, and does not light a sibling block at the same depth.
- In a repo: a changed file is badged in the tree, its changed lines marked in the gutter, and its name in the panel is a name — not a name with an object hash in front of it.
- Untick a file, commit, and check
git log --stat: only the ticked files are in it, including when one of the unticked ones was staged from the terminal. - Discard an untracked file: it is in the trash, not gone.
- Commit from the panel: the file list empties and the history gains a row without touching Refresh. Then commit from the terminal behind it: the same.
- A commit that has not been pushed is marked in the history, and the Push button says so.
- History: a commit's subject, author and relative date are right, a merge is marked, and its files open a diff at that revision rather than the working copy's.
- The drawer's first file shows the status it actually has — an added first
file must not read as modified — and its
+/−totals matchgit show --shortstatfor the same commit. - The tally adds up to the file count, a rename is counted as neither new nor edited, and the row disappears when every file changed the same way.
- A long commit body folds with a fade and
Read more; a three-line one shows whole, with no button. - Push on a branch with no upstream publishes it; push with no remote, and with
two remotes and no
origin, both say so rather than doing something. - A one-word change shows one highlight, not one per word with the spaces
punched out, and its
+/−counts agree withgit diff --numstat. - Every diff preset in both themes, unified and split: no column spilling into the next, and the hatched filler only where a side genuinely has no line.