README.md
August 14, 2026 · View on GitHub
███████╗███╗ ███╗ █████╗ ██╗ ██╗ ██████╗ ██╗ ██╗██╗
╚══███╔╝████╗ ████║██╔══██╗╚██╗██╔╝ ██╔════╝ ██║ ██║██║
███╔╝ ██╔████╔██║███████║ ╚███╔╝█████╗██║ ███╗██║ ██║██║
███╔╝ ██║╚██╔╝██║██╔══██║ ██╔██╗╚════╝██║ ██║██║ ██║██║
███████╗██║ ╚═╝ ██║██║ ██║██╔╝ ██╗ ╚██████╔╝╚██████╔╝██║
╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝
[A NATIVE DESKTOP GUI FOR ZMAX // THE WAY MACVIM WRAPS VIM]
zmax-gui is a native desktop GUI for the
zmax editor — the Rust Emacs
port (a Helix/Vim-style modal core built out toward Spacemacs). It wraps the
zmax modal-editing core in a windowed front-end, the way MacVim wraps the Vim
CLI editor: the same editor underneath, a native window on top. Free and open
source.
Architecture
A thin Tauri v2 shell that runs the zmax binary in an embedded PTY terminal
(zpwr-embed-terminal) filling the
window, wrapped in the shared zgui-core app baseline (ZGui.appShell: command palette, colour
schemes, settings, CRT/splash). The editor is the same modal core; the window, chrome and theming are
the GUI. Standard MenkeTechnologies GUI layout — see GUI_APP_ARCHITECTURE.md in the meta repo.
The host is thin with one deliberate exception: the office and PDF engines link into it as rlibs so that project search, replace and git blame can see inside binary documents in-process — and so that a document can be opened in the IDE rather than handed to an external viewer. See Documents are searchable, Documents are viewable and Documents are blamable.
zmax-gui/
├─ app/src-tauri/ Tauri host: terminal + fs + window + open-intake + project commands
│ ├─ terminal.rs PTY spawn/write/resize/kill — the editor's, the floating shell's, and
│ │ one per tmux tile
│ ├─ fs_ops.rs list_dir/home_dir — backs the Open dialog
│ ├─ window_ops.rs fullscreen / translucency (blur) / focus
│ ├─ project.rs fuzzy find-files, find-in-files (regex), tree file ops, recent files,
│ │ file stats, git status/branch/diff — the project workbench backend
│ ├─ editor_tools.rs bookmarks, project search & replace, go-to-symbol, TODO/markers
│ ├─ doc_search.rs binary-document search & lossless replace (docx/odt/xlsx/ods/pptx/odp/pdf)
│ ├─ doc_blame.rs git blame at document-address granularity (xlsx/ods cell, pdf page)
│ ├─ git_tools.rs git blame, per-file history + show-commit, stage/unstage/discard, file compare
│ ├─ git_ext.rs git branches (list/checkout/create) + stash (save/list/pop/drop/show)
│ ├─ text_tools.rs file cleanup/convert, sort lines, find-definition, batch rename
│ ├─ edit_ops.rs align columns on a delimiter + language-aware comment toggle
│ ├─ encoding_ops.rs detect + transcode a file's character encoding (UTF-8/16, Latin-1)
│ ├─ git_more.rs repo-wide log, show-commit, diff two revisions, commit graph
│ ├─ workbench_ext.rs persisted snippets + project code-stats (files/lines by extension)
│ ├─ open_intake.rs CLI / Finder / mvim:// file opens → :open in the PTY
│ ├─ bus.rs GUI Automation Bus socket: host commands + webview appshell.* verbs
│ ├─ commands.rs the host command list the bus advertises (+ its withheld exceptions)
│ └─ txn.rs content snapshots, the on-disk transaction journal, and the tree witness —
│ what makes a mutating verb reversible, an interrupted run recoverable, and
│ a finished run able to name the files its undo did NOT reach
├─ crates/
│ ├─ zmax the editor — vendored submodule, built → bundled sidecar
│ ├─ zpwr-embed-terminal shared PTY engine (submodule)
│ ├─ zpwr-file-browser shared multi-pane file browser: `crate/` (fs_* commands, watcher) + webui
│ ├─ zpwr-i18n shared 27-locale i18n runtime + catalogs (submodule)
│ ├─ zoffice-core office engine (docx/odt/xlsx/ods/pptx/odp): rlib + mountable view
│ └─ zpdf-core PDF engine: rlib + mountable viewer
├─ scripts/
│ ├─ mvim terminal launcher (open files in the running window)
│ ├─ copy-{webui,embed-terminal,i18n,file-browser,doc-views}.mjs sync shared webui into frontend/
│ ├─ clean/bust/rebuild/nuke/ship-check/deploy.sh the shared app lifecycle scripts
│ ├─ run-js-tests.mjs one discovery path for every JS suite (pnpm test + test:js)
│ ├─ i18n-{sort-catalogs,catalog-audit,extract-seed}.mjs catalog sort · completeness audit ·
│ │ derive this app English seed
│ └─ prepare-{zmax,stryke}-sidecar.mjs stage the bundled binaries
└─ frontend/
├─ index.html · main.js mounts ZGui.appShell + the fullscreen terminal
├─ menu.js the MacVim GUI surface (all zgui widgets → PTY)
├─ editor-state.js editor state reconstructed FROM the PTY stream (the return path)
├─ editor-hud.js buffer/tab bar + status strip + minimap, driven by that state
├─ panels.js · panels.css the project workbench overlays (quick-open, find-in-files, …)
├─ doc-view.js the in-app document pane over mountZpdf / mountZoffice
├─ i18n-seed.js · i18n-seed/ this app English seed, merged UNDER the loaded locale
├─ fb-backend.js Tauri fs bridge + host shims for the shared file browser
├─ verbs.js the TYPED bus surface: the workbench as reversible verbs
├─ plan-panel.js Batch Plan: the shared arrangement grid + the transactional runner,
│ its disk journal, and the interrupted-run recovery prompt
├─ plan-domain.js its grid DOMAIN (operations × files) for zpwr-clip-engine
├─ vocabulary.test.cjs drives all three command publishers headlessly (see below)
├─ wiring.test.cjs what those surfaces actually invoke: PTY geometry, document search
├─ lib/zgui-core the shared widget library (submodule)
└─ lib/zpwr-clip-engine the shared arrangement-grid engine (submodule)
Project workbench
On top of the MacVim menu surface, the app adds an IDE-style project workbench — all reachable
from the ⌘K command palette (and dedicated shortcuts). Every result is opened by driving the
editor (:open <path>:<line>:<col>); the OS-side work (walking the tree, grepping, filesystem
mutations, git) lives in the Rust project.rs / editor_tools.rs / git_tools.rs / git_ext.rs /
text_tools.rs / edit_ops.rs / encoding_ops.rs / git_more.rs / workbench_ext.rs commands, so
results are fast and the editor stays the single source of truth.
- Quick Open (
⌘P) — fuzzy file finder over the project tree (VCS/build dirs pruned), boundary- and run-aware ranking; type to filter,↑/↓/Enterto open. - Find in Files (
⇧⌘J) — project-wide text search with regex, match-case and whole-word toggles; click a match to jump to its exactline:col, or ★ to bookmark it. Binary documents are searched too, in the same query and the same ranked list — see Documents are searchable below. - Search Documents — the documents-only pass (
doc_search.rs), for when the question is about the documents rather than the code: the walk is restricted to office packages and PDFs up front, and the two things only this pass can express are yours to set — which formats (a toggle per.docx/.odt/.xlsx/.ods/.pptx/.odp/.pdf; none on means all of them) and whether to match case, plus hidden files. Each hit is addressed the way its format is addressed (¶12,Sheet1!B14,slide 4,p. 7), and picking one opens the document in the IDE, at that address — see Documents are viewable. No regex toggle: the engines are substring scanners, so the backend rejects a regex query outright rather than matching it literally. - Search & Replace (
⇧⌘H) — project-wide replace with regex (including$1capture references), match-case and whole-word; a live preview of every before → after line, then Replace All rewrites the matching files on disk (confirmed first). Oversized files, and binaries with no document engine behind them, are skipped like the search; supported documents are previewed and rewritten losslessly. - Go to Symbol (
⇧⌘O) — a workspace outline picker: functions, structs/classes/enums/traits, types, modules, methods and Markdown headings across the tree (Rust, JS/TS, Python, Go, C/C++, Ruby, shell, Lua, stryke/Perl, Markdown); type to filter,Enterto jump. - Find Definition (
⇧⌘D) — jump to where an exact symbol name is declared (not every occurrence): reuses the Go-to-Symbol language rules to locatefn/struct/class/def/… sites across the tree; type a name,Enterto jump. - TODO / Markers (
⇧⌘T) — a scan forTODO/FIXME/HACK/XXX/BUG/NOTE/OPTIMIZE/WARNINGcomment markers across the tree; filter and jump to each. - Bookmarks (
⌘B) — a persisted list of namedfile:linemarks (survives restarts); add from a file picker or the ★ button on a search hit, jump on click, remove per-row or Clear. - Recent Files (
⌘E) — a persisted MRU list (survives restarts; every open, from any route, is recorded), filterable, with Clear. - Project Files (
⇧⌘E) — a tree file manager: New File / New Folder, Rename, Duplicate, Delete (confirmed), and File Info (line/word/char/byte counts) via the right-click menu; click a file to open it. - File Browser — the shared multi-pane
zpwr-file-browser(same component as zemail / ztranslator / zstation), opened as a full-screen overlay: multiple panes and tabs, sortable + resizable columns, fuzzy filter, color labels, folder-tree sidebar, text/hex/image quicklook + preview pane, git status, dedup, diff, grep, compress/extract, hash, xattrs, disk-usage and live fs-change watch. Double-click (or Enter) opens the file in the zmax buffer — the browser's "open" is wired to drive the editor, not the OS default app. Backed by the crate'sfs_*Tauri commands + the directory watcher (zpwr_file_browser::commands); the front end is synced intofrontend/bycopy-file-browser.mjs, bridged throughfb-backend.js. Esc or the bar's ✕ closes it. - Batch Rename — rename every file whose base name matches a find → replace rule (literal or
regex with
$1capture refs); a live preview of everyfrom → to(collisions flagged), then Rename All applies it on disk (confirmed). Files stay in their directory. - Sort Lines — reorder a file's lines on disk: reverse, ignore-case, numeric and
unique (a sorted
uniq) toggles, with a dry-run preview of the line-count delta; the file is reloaded in the editor after apply. - File Cleanup — normalise a file: convert line endings (LF/CRLF), trim trailing whitespace, expand tabs → spaces or tabify leading indent, and ensure a final newline; a preview reports the changed-line count and byte delta before apply. Binary/oversized files are skipped, like the search tools.
- Align Columns — align every line of a file on a delimiter (literal or regex), the way Emacs
align-regexplines up=signs,:map keys or//trailing comments into one column; a preview reports how many lines participate and change before apply. - Comment / Uncomment (
⇧⌘/) — toggle line comments over a line range using the language's comment prefix (//,#,--,;,", chosen by extension). If every non-blank line is already commented it uncomments, else it comments; the end line is pre-filled to the file length. - File Encoding — detect a file's character encoding (BOM, UTF-8, UTF-16LE/BE, Latin-1) and line ending, then transcode it to UTF-8, UTF-16LE/BE or Latin-1 (UTF-8 output is BOM-free; UTF-16 output is BOM-prefixed); a preview shows the source → target and byte delta.
- Snippets (
⇧⌘I) — a persisted named text library; pick one to insert it into the editor via bracketed paste (multi-line bodies land verbatim, no auto-indent), add / remove / Clear. - Git Changes — the current branch +
git statuslist; click a file for its unified diff; Stage / Unstage / Discard (confirmed) each file inline, Refresh, jump to Blame, or open it in the editor. - Git Blame (
⇧⌘B) — per-line commit / author / date for a chosen file (git blame--line-porcelain); click a line to jump there. - Document Blame (
⇧⌘Y) — the same question for a binary document, answered at the document's own addresses instead of at lines. See Documents are blamable. - File History — the commit log touching a file (
git log --follow); click a commit for the diff it introduced (git show), or open the file. - Repository Log — the whole repo's commit history (
git log, newest first, with ref decorations); click a commit for the full diff it introduced across all files (git show). - Commit Graph — the ASCII branch graph across all refs (
git log --graph --oneline --decorate --all) in a read-only pane. - Diff Revisions — a unified diff between any two revisions (
git diff <a> <b>), branches / tags / hashes, optionally scoped to one path; both revisions are flag-guarded. - Compare Files — a unified diff between any two files picked from the tree
(
git diff --no-index, so it works outside a repo too). - Git Branches — the local branches (most-recently-committed first, current flagged); click to
checkout (confirmed), or New Branch to create and switch (
checkout -b). Ref names are flag-guarded. - Git Stash — the stash list; click an entry for its patch (
stash show -p), Pop (apply + drop, confirmed) or Drop (confirmed) per entry, and Stash Changes to save the working tree (including untracked) with an optional message. - Project Stats — a read-only report of file / line / byte counts across the tree, broken down by extension (binary and oversized files skipped for line counting).
- Batch Plan — the shared arrangement grid over the project: paint which of the reversible operations to apply to which files, then run the whole painting as one transaction that rolls every applied step back if any step fails. See Batch Plan.
- Interrupted runs — the same transaction, after the app stopped existing. A Batch Plan journals itself to disk step by step, so a run the app died inside is enumerated at the next launch and unwound on request — newest step first, and refusing any file that changed since. See The transaction outlives the process.
- The reach the undo does not have — a run witnesses the tree before its first step, so when it finishes it can name the files that moved inside its window that no step recorded and it therefore cannot put back. The paths it can are verified the other way, by content, against the run's own pre-image — so an unwind ends with a byte-level receipt rather than its own word for it. See The reach the undo does not have.
Every list, picker and filter in these panels is the shared fzf matcher (ZGui.fzf) with the
matched characters highlighted — the same ranking and the same highlight as the ⌘K palette and the
file browser, so a query means the same thing wherever it is typed. (The highlight is suppressed
while a panel's regex toggle is on: marking a regex's literal characters would mark the wrong ones.)
All surfaces are modal overlays (like the Open dialog) built from zgui-core widgets — no docked pane, so the embedded terminal is never reflowed.
Documents are searchable
Ordinary project search skips any file whose first bytes contain a NUL, which is every office
package and PDF — they are zip or binary containers. zmax-gui links
zoffice-core and
zpdf-core as rlibs into the Tauri host, so the
walker gets a second branch: a file whose extension names a supported format is parsed in-process
and contributes hits to the same result list as the source files around it. One query returns hits
from main.rs and from spec.docx and from budget.xlsx together. There is no subprocess spawn and
no IPC per file, and document parsing fans out across a thread pool (the text branch stays
single-threaded and unchanged). Two commands reach this: Find in Files, where document hits join
the source hits in one ranked list under the grep branch's options, and Search Documents, which
walks only the document formats and lets you pick which of them.
| Format | Search hit locates | Replace |
|---|---|---|
.docx, .odt | paragraph (¶12) | lossless package rewrite |
.xlsx, .ods | sheet + A1 cell (Sheet1!B14) | lossless package rewrite |
.pptx, .odp | slide (slide 4) | lossless package rewrite |
.pdf | page + on-page rect (p. 7) | whole text runs only — see below |
Lossless is the literal claim and it is pinned by a test: after a replace, every zip entry other than the edited XML part is byte-identical, so styles, images, themes and revision marks survive a rewrite that a parse-and-re-serialize round trip would silently drop.
Four behaviours differ from the text branch, and each is surfaced in the UI rather than hidden:
- Literal only. Every engine
findis a substring scan, so the document branch is skipped when regex or whole-word is on, and the standalone Search Documents panel rejects a regex query outright instead of matching it literally (it offers no regex toggle at all). - PDF replace matches whole runs, not substrings. Searching
getUserNamein a PDF whose text run readsgetUserNameFromDbfinds the hit and cannot rewrite it. Those rows are still listed, with an honest0and the reason, rather than dropped as a silent no-match. - Replace is case-sensitive even when the search that found the hit was not, because the package rewrite edits raw XML text nodes. A case-insensitive replace says so in the preview.
- Parse failures are reported, not swallowed: a corrupt package appears as its own row with the engine's message, so it never looks like "no matches".
Documents are viewable
Searching a .docx and blaming a .pdf in-process is only half the story if looking at the hit
still means leaving for Preview or LibreOffice. It no longer does. Both engines ship a mountable
view — zoffice-core's webui/zoffice-view.js and zpdf-core's frontend/js/zpdf.js — and
zmax-gui mounts them into a modal pane, over the same rlibs the walker already uses, through one
bare app command per engine (zoffice_invoke / zpdf_invoke, registered in lib.rs). No
subprocess, no third-party viewer, no export step.
The pane is the reason the address the search found is worth carrying: it is spent on arrival.
A p. 7 hit lands on page 7; a Sheet1!B14 hit opens the spreadsheet's Data view; a paragraph or
slide hit is searched for in the opened document. Handing the file to the OS default application
could never do any of that — an external viewer cannot be told to jump to ¶12, which is why the
old path could only put the address on the clipboard.
| Extension | Pane |
|---|---|
.pdf | zpdf-core viewer — pages, text layer, outline, markup |
.docx, .odt, .xlsx, .ods, .pptx, .odp | zoffice-core view — document / data / analysis |
| anything else | the OS default application, with the address on the clipboard |
That last row is not a leftover: a format neither engine reads (.epub, .key, …) still opens, and
so does a document whose pane fails to mount. The fallback is the exception path, not the default.
Scriptable like everything else here — zmax.doc.open, zmax.doc.close and the zmax.doc.state
query are on the automation bus, alongside the pane's own zoffice.view.* verbs, so one stryke
script can open a document in the IDE and read it back without a screenshot.
Documents are blamable
git blame answers "who last changed this line". A .xlsx or a .pdf has no lines — git sees one
binary blob and reports Binary files differ. The prevailing workaround is a textconv diff driver
that shells out to pandoc or unoconv once per file per revision and flattens the document to a
throwaway line stream, so the number that comes back is a line of the rendering, not an address in
the document.
Document Blame (⇧⌘Y, or the ⌘K palette) answers it in the document's own coordinate system:
Sheet1!B14 a3f91c2e 2026-03-04 <author> quarterly figures
p. 7 5d10ba71 2026-01-19 <author> redraft the appendix
The walk reuses what the search branch already built. git log --follow yields the revisions that
touched the document (and the path it had at each, so a rename does not break the history);
git show <rev>:<path> materializes each blob; each revision is parsed in-process by the same
zoffice-core / zpdf-core rlibs described above — no pandoc, no unoconv, no converter
subprocess per revision. Each address is then attributed to the newest revision whose content at
that address differs from its predecessor's, which is why editing one cell does not re-blame the
cells beside it even though their bytes inside the zip moved too. Rows carry the same DocLocator
the search rows do, so an address renders identically in both panels.
| Format | Blame address |
|---|---|
.xlsx, .ods | sheet + A1 cell (Sheet1!B14) |
.pdf | page (p. 7) |
Two limits, both surfaced in the panel rather than hidden:
- Only stable addresses are blamed. Paragraph and slide indices shift when content is inserted
above them, so index-keyed attribution would mis-blame every unit below an insertion — wrong in a
way that looks right.
.docx/.odt/.pptx/.odpare refused with that reason stated, pending content-hash alignment between adjacent revisions. Cells and pages do not move. - The revision walk is capped (the panel reports how many revisions it walked out of how many
exist). The oldest revision in the window has no predecessor to compare against, so addresses that
did not change inside the window are marked
≤— changed at or before that commit, not by it. Revisions that fail to parse are listed as skipped, because a revision that could not be read is a gap in the attribution rather than a non-event.
On prior art, since the surrounding claims here are narrow on purpose. Address-granular diff
for spreadsheets is not new: ExcelCompare emits
DIFF Cell at Sheet1!A3, Git XL and JetBrains'
ExcelDiffer do cell-by-cell workbook
comparison. Address-granular authorship is not new either — xltrail
answers "who changed this value, when and why?" per cell. What is unclaimed elsewhere is the
combination this panel occupies: git-backed (a real repository, not a proprietary cloud store),
multi-format (spreadsheets and PDF, not Excel-only), and in-editor (a panel in the editor,
not a web SaaS). No editor in the category does authorship on a binary document at all: VS Code
renders .docx as Binary file not shown,
JetBrains' Diff Viewer treats Office
files as binary, and Zed, Neovim, Emacs and Sublime have no office/PDF blame path.
Dry run is measured, not predicted: each document is genuinely re-serialized into a temp file beside itself and the count is taken from what the engine actually did, then the temp is discarded. On apply, that temp is renamed over the source — same directory, so the replace is atomic and a failure part-way through can never leave a half-written document on disk.
Transform by Example and Reshape by Example carry an Apply to selector with the same reach:
a synthesized rule can run over the buffer (the :%s bridge, unchanged) or over the project's
documents. Only a literal replace rule can cross that boundary — the other rules emit whole-line
patterns, and a paragraph or a cell is not a line — so the rest are refused with that reason stated,
never silently applied to nothing.
Proving a rule before it fires
Both surfaces also carry Verify. A rule derived from two before → after rows is, until it runs,
a guess about every line it has not seen; the live preview only exercises it against lines the author
typed into the sample box, which is the one place it is guaranteed to behave. The buffer path then
writes :%s/…/…/g into the PTY with nothing reading back, so the first evidence of a rule that was
too broad is the damage.
Verify runs the same rule over the real project through the host's replace_project dry run
(apply: false — it reads every text file in scope and rewrites none) and reports what the rule
moves: the number of matches, the number of files, the before→after of each line, and how many of the
shown lines the rule matched but left byte-identical. The counts are complete rather than sampled —
the host totals every match before it caps the preview list, so a truncated row list still carries an
exact total — and because a substitute only rewrites what its pattern matches, every line absent from
that count is one the rule provably does not touch.
The one thing it will not do is approximate. A rule is checked only when it has an exact equivalent
in the host's regex flavour: literal replace, wrap, and every reshape (whose anchored pattern is
already the one each example was verified against, so the check runs the rule itself rather than a
re-derivation of it). The three case rules emit vim's \U / \L / \u replacement operators,
which the host's regex engine has no counterpart for; rather than measure some other rule and present
the number as this one's, Verify says so. Editing any row clears a standing report, so a proof can
never outlive the rule it was measured for.
Editor state, reconstructed from the PTY stream
Every GUI wrapper in the MacVim / gVim / neovim-GUI lineage needs the editor to cooperate: MacVim
links Vim as a library, nvim --embed is a cooperation protocol, and the rest of the field is
built on some RPC socket. zmax exposes none of that — no control socket, no --embed, no IPC. So
until now the channel was one-way: the GUI wrote keystrokes into the PTY and nothing ever came back,
which is why there was no buffer bar, no live position and no minimap.
frontend/editor-state.js closes the loop without asking the editor for anything. It reads the
same bytes the terminal pane already receives and rebuilds enough of the screen to read the editor's
own statusline and bufferline back off it. The editor is unmodified and unaware.
- Why a screen model, not a regex. zmax renders through a cell-diff backend: it emits
CSI row;col Hand then only the glyphs that changed, wrapped in synchronized-output markers. A statusline is assembled from a dozen scattered writes, so reconstructing it requires a grid. The grid is a deliberate subset of a terminal — cursor motion, erase, SGR foreground, OSC titles — and the parser is a state machine, because the PTY reader frames on 4 KiB with no regard for escape boundaries. - Why not xterm.js. The pane's xterm instance belongs to
zpwr-embed-terminal, a crate four other apps embed. Exporting its internals to reachbuffer.activewould fork a shared component for one app's feature; a private subset parser forks nothing and costs one pass over bytes already in this process — no second process, no polling, no LSP. - What is recovered: mode, file path, modified / read-only, cursor
line:col, scroll percentage, selection count, line ending, encoding, per-severity diagnostic counts (carrying the colour that is the severity — every severity prints the same glyph), and the buffer list when the bufferline is on. The focused view's statusline is found by its mode token rather than by a fixed row offset, because the workbench inserts panels below it and an unfocused split blanks its mode. - What is not, and is not claimed: the buffer text (only the visible viewport crosses the PTY,
and only as diffs), the total line count, and which diagnostic sits on which line. Those fields
read
null.
frontend/editor-hud.js spends it: a live buffer/tab bar, a status strip, and a ZGui.codeMinimap
with the cursor marked. Clicking a tab is the clearest proof the channel really is bidirectional —
the editor has no "switch to buffer N", only next/previous, so a tab bar is impossible without
knowing which buffer is active. The minimap's density comes from the file on disk, since the buffer
text never crosses the PTY, and its cursor band is exactly one line wide because the cursor line is
exact while the scrolled viewport is not carried by the stream at all.
The return path also makes the outbound path faster. Every burst used to open with a defensive
Esc plus a 50 ms wait for the editor's esc-disambiguation window, because the GUI could not know
the mode. Now menu.js skips both when the reconstruction is trusted and says normal mode —
gated on trust, never on the cached mode alone: the GUI's own writes mark the reading stale until the
next observed statusline, since between a write and the redraw the mode is exactly what is unknown.
MacVim-style GUI
The GUI wraps the modal core the way MacVim wraps Vim. Every surface is a zgui-core widget; each
action is bridged to the editor by writing an ex-command into the PTY (the GUI never edits files
itself, it drives zmax). zmax (a Helix fork) has both buffers and a real vim tabpage
family, so the GUI drives each with its own menu — the Buffers menu cycles/closes open buffers,
the Tabs menu manages tabpages (each holds its own split layout).
- Menu bar (
ZGui.menubar) — File / Edit / Search / Text / Extract / Align / Structure / View / Buffers / Window / Tabs / Folds / Marks / Bookmarks / Macros / Snippets / Code / Spell / Abbrev / Git / Help. - Search menu — in-buffer engine commands (distinct from the file-based Find-in-Files workbench):
whole-buffer regex Replace (
:%s, delimiter auto-chosen so a/in the pattern is safe), case-preserving Replace (vim-abolish:%S—foo/Foo/FOO→bar/Bar/BAR), Count Matches (:count-matches), and Clear Search Highlight (:nohlsearch). - Text menu — in-buffer, live-selection line transforms bridged into the PTY (distinct from the
file-based align-columns / whitespace panels in the project workbench, which act on a picked file):
comment / uncomment the selected lines (
SPC c c→toggle_comments); sort lines, with reverse / numeric / unique variants (:sort-lines [--reverse|--numeric|--unique]); sort the ranges in the selection (:sort); sort paragraphs (:sort-paragraphs); hard-wrap the selection to the configured width (:reflow); and reindent / dedent by a shiftwidth (:indent-lines/:dedent-lines). - Extract menu — regex extraction over the selection, bridged into the PTY: replace the selection
with the http(s) URLs / email addresses / IPv4 addresses / numbers / double-quoted strings it contains,
one per line (
:extract-urls/:extract-emails/:extract-ips/:extract-numbers/:extract-quoted); and extract every substring between a start / end delimiter pair from a prompt (:extract-between <start> <end>, each delimiter shellword-quoted). - Align menu — the vim
SPC x acolumn-alignment family bridged into the PTY, acting on the primary selection's rows (distinct from the workbench's file-based Align-Columns panel, which aligns a picked file on disk — the same in-buffer-vs-file split as the Text menu): align the selection's cursor columns (align_selections); align each row at a fixed target character —=/:/,/;/&/.(numeric), the paired brackets()[]{}, or the arithmetic operators (align_at_equals…align_at_arithmetic); and align at a prompted single character (left / right,align_left_at_char/align_right_at_char) or a prompted regexp (align_at_regex). - Structure menu — the vim
SPC kparedit/sexp structural-editing family bridged into the PTY (plus the split verb onSPC j s): sexp navigation — beginning / end of sexp, up to parent, next / previous sexp, forward / backward to the enclosing paren, matching paren, copy sexp; slurp / barf forward and backward (paredit_slurp_forward…paredit_barf_backward); wrap with parens, unwrap (splice), raise, transpose, split, join, convolute, absorb (wrap_sexp/paredit_splice/paredit_raise/paredit_transpose/paredit_split/join_selections/paredit_convolute/paredit_absorb); splice-killing forward / backward and insert-sexp before / after (paredit_splice_kill_forward/…_backward,paredit_insert_sexp_before/…_after); and delete sexp / symbol forward and backward. The submap's generic vim reuses (visual select, undo/redo, mode switches, paste) are omitted — they already live on the Edit menu and are not structural ops. - Code menu — language-server actions bridged into the PTY: go to definition / references /
type definition, hover docs, peek definition, signature help, document / workspace symbol pickers
(
SPC s j/SPC s S), the refactor set — rename symbol, code action, organize imports, implement / override members, generate code (SPC l r/a/O/i/v/g) — next/previous diagnostic, format document, restart language server. - Spell menu — vim's spell-check family bridged into the PTY: suggest corrections for the word under
the cursor (
z=), jump to the previous / next misspelling ([s/]s), add a word to the dictionary or mark it misspelled and undo that (zg/zw/zug), edit the wordlists by typing words (:spellwrong/:spellrare/:spellundo), and list the known-good words / show wordlist info (:spelldump/:spellinfo). - Abbrev menu — vim/emacs abbreviation-table commands bridged into the PTY: list every defined
abbreviation (
:list-abbrevs); define a global / both-mode / insert-mode / command-mode abbreviation from a lhs + expansion prompt pair (:define-global-abbrev,:abbreviate,:iabbrev,:cabbrev); remove one for both / insert / command mode (:unabbreviate/:iunabbreviate/:cunabbreviate); expand every abbrev in the region (:expand-region-abbrevs); clear all (:abclear) or kill every table (:kill-all-abbrevs); and load / save the table to a file (:read-abbrev-file, reusing the Open browser /:write-abbrev-file, reusing the Save-As path prompt). - Git menu — zmax-vcs actions bridged into the PTY: Magit status, stage / unstage file, line blame, buffer-vs-HEAD diff, next/previous/reset hunk, stash / pop, and merge-conflict resolution (3-pane resolve, keep ours / theirs, next conflict).
- Window menu — vim's
C-wsplit-window family bridged into the PTY (each key backed by a real editor command): split horizontally / vertically, focus the split to the left / down / up / right (C-w h/j/k/l), move the current split to an edge (C-w H/J/K/L), rotate splits forward / reverse and exchange with the next (C-w w/R/x), grow / shrink height and width and equalize (C-w +/-/>/</C-w =), maximize by closing the others (C-w o), close the split (C-w q), and undo the last layout change (C-w u, winner-undo). - Tabs menu — vim's tabpage family bridged into the PTY (real tabpages, distinct from buffers —
each carries its own split layout): new tab / new tab with file (
:tabnew, the latter reusing the Open file-browser), close / close-others (:tabclose/:tabonly), next / previous / first / last (:tabnext/:tabprevious/:tabfirst/:tablast), move to end / to position (:tabmove), run an ex-command in every tab (:tabdo), and the visual list / switch picker (:tabs). - Folds menu — vim's
z-family fold ops bridged into the PTY: toggle / open / close the fold at the cursor (za/zo/zc), open / close all folds (zR/zM), create a fold over the selection (:fold), delete one / all folds (zd/zE), and walk to the next / previous fold (zj/zk). - Marks menu — vim's position-and-register family bridged into the PTY: set / go-to / list /
delete marks (
:mark,`{x}goto,:marks,:delmarks[!]); jumplist back / forward (C-o / C-i), list / clear jumps (:jumps,:clearjumps), recent-files picker (:oldfiles); and registers show / set / clear / clear-all (:registers,:set-register,:clear-register). - Bookmarks menu — zmax's persistent-bookmark family bridged into the PTY (distinct from the
transient marks above): JetBrains-style line bookmarks — toggle at point, next / previous, jump via a
picker (
SPC r t/n/N/j); focus the Bookmarks tool window (SPC W b); and the emacs bookmark file I/O — save / load the bookmark store to a path (:bookmark-write/:bookmark-load, via the Save-As prompt and the Open file browser). - Macros menu — vim's keyboard-macro family plus the Spacemacs
SPC Kkmacro tree bridged into the PTY: record into a register / stop (q{reg}/q), replay a register / the last one / re-run the last ex-command (@{reg}/Q/@:); the macro ring — cycle next / previous, view / swap / delete the head macro (SPC K r n/p/L/s/d); the macro counter — increment / insert-and-increment (SPC K c a/c); and save the last macro to a register (SPC K e r). - Snippets menu — the PTY-native snippet library bridged into the PTY (distinct from the workbench
Snippets panel): insert a snippet via the fuzzy picker (
:Snippets) and open the library editor to create / edit / delete snippets (:snippets). - Toolbar (
ZGui.buttonBar) — new / open / save / buffer nav / find / replace / go-to-def / format / git status / list marks / replay macro / toggle fold / comment lines / list tabs / split / full screen. - Command palette (
⌘K) — every menu action, fuzzy-searchable, and each one also callable from a script or a saved command chain (see Scriptable). - Cmd-shortcuts — ⌘S save, ⇧⌘S Save As, ⌘O open, ⌘W close buffer, ⌘N new, ⌘Z/⇧⌘Z undo/redo, ⌘F find, ⌘G/⇧⌘G next/prev, ⌘{ ⌘} buffer cycle, ⌃⌘F full screen.
- Tmux tiling (
⌘K▸ Tmux) — the sharedZGui.tmuxoverlay, wired byfrontend/tmux-config.jsso each tile is a separate editor: its own xterm on its own backend PTY session (term_session_*), with the editor exec'd into it exactly as the fullscreen one is.C-bis the prefix (C-b cnew window,%/"split), and the always-on editor pane hides while the overlay is up. - Floating shell (
⌘K▸ Terminal) — a scratch login shell in its own pane on top of the IDE, on its own PTY (shell_term_*), so it never disturbs the editor's. Its geometry tracks the pane: the PTY is spawned at the size the pane is actually drawn at and re-sized whenever that changes, through the same cell-metric fit the embedded terminal uses for its own PTY (zpwr-embed-terminal'swindow.zpwrTermFit), so full-screen programs —vim,less,htop— draw to the right width instead of to the geometry the pane happened to have at boot. - Open / Save As / Help dialogs (
ZGui.modal+ZGui.treefile browser). - Right-click context menu in the editor (
ZGui.contextMenu). - Drag-and-drop files to open (
ZGui.fileDrag). - Full screen + translucent background (window-vibrancy); Preferences panel.
- Open from the terminal / Finder /
mvim://URL, forwarded into the running window (single-instance + deep-link). Usescripts/mvim file….
Out of scope (no surface in a PTY/WebView host — they need a native text view): native font rendering
(ligatures, thin strokes, antialias), Touch Bar, macOS Services, Force Click / dictionary lookup,
trackpad gesture pseudo-keys, find-pasteboard sharing. A passive always-on tabline strip is
omitted on purpose — a faithful one needs editor↔GUI introspection the raw PTY doesn't expose, and a
drifting strip would lie about state; the Tabs menu + the on-demand :tabs picker (rendered by the
editor itself) cover switching without that risk.
Scriptable: every GUI action is a bus verb
The app opens its GUI Automation Bus socket at startup (bus.rs), so a stryke script reaches it
by name — App::open("zmax-gui"), or App::here() from a hook running inside the app. The surface
is published over two routes and verbs() returns their union:
| Route | What it exposes | Dispatched by |
|---|---|---|
| host | the app's own #[tauri::command] surface — project + document search / replace, blame, git, text and editor tools, the file browser, the terminal | the host's own IPC, by name |
| webview | appshell.<id> — the shell's built-ins plus every command the GUI publishes: the whole MacVim menu tree, the project workbench overlays, and the shell's own commands | automation-host.js in the webview |
commands.rs is the host list, and NOT_ON_BUS is its explicit exception list — the bridge's own
zgui_bridge_reply / zgui_bridge_event plumbing, withheld because a script that could reach them
would be able to resolve another caller's in-flight request or forge an event, plus log_diagnostic
(below), which reports on the app rather than driving it. A drift-guard test requires every
registered command to appear in exactly one of the two lists, so adding a command forces a
deliberate decision instead of a silent omission.
The webview half turns on one detail. The appShell registers an appshell.<id> verb for each
command published through setCommands; its older setPaletteItems entry point fills the ⌘K
palette and registers nothing. All three of the app's publishers — main.js (the shell's own
commands), menu.js (the menu tree) and panels.js (the workbench) — go through setCommands, and
because that call replaces the vocabulary each publisher hands over the union rather than its own
slice.
Every id is locale-independent: zmax.<action> for a menu command (naming the entry in the
ZmaxMenu.actions bridge table, the same key the native menu uses for its item ids and
accelerators) and zmax.panel.<surface> for a workbench overlay. An id derived from a label would be
a translated string, so switching language would rename every verb and break every saved script and
command chain that referenced one. frontend/vocabulary.test.cjs drives the three real publishers
headlessly and pins all of it: the union is published, through setCommands, with no id claimed by
two different actions, and with every id unchanged across a locale switch.
The toolkit checks the same two rules from its side. On every publish the appShell audits the
vocabulary for a row with no id (it shows in ⌘K but can never become an appshell.<id> verb)
and for an id containing whitespace (the fingerprint of a translated label used as an id), and
records what it finds in window.ZGui.diagnostics and on a zgui:diagnostic document event. It
prints nothing. main.js forwards each note to the log_diagnostic host command, which appends it
to zmax.log — the file the Settings ▸ Diagnostics Open log file button reveals. Nothing reaches
the terminal. zmax-gui's own vocabulary raises none of them — every published row carries a stable
zmax.* id — so the channel is there for a future regression, not for a current one.
frontend/wiring.test.cjs is the other half of that: the vocabulary test proves the rows exist, this
one drives the real main.js / panels.js against a stubbed Tauri host and asserts what they send
to Rust — the floating shell's PTY geometry (at spawn and on every later resize) and the documents
search with its formats filter.
Reversible verbs: a refactor is a transaction
The two routes above describe what a script can reach. This section is about what happens when a step fails halfway.
The bus classifies every verb as pure, inverse or irreversible, and a transaction decides on
that classification: an inverse verb is journaled so it can be compensated, an irreversible one
is refused before it runs. A palette row declares nothing, so it defaults to irreversible — right
for a button, and a ceiling: it means a multi-step project edit driven from a script either succeeds
or is left half-applied.
frontend/verbs.js publishes the workbench itself as a typed, parameterised surface on top of
that: reads and previews as pure, and every file mutation as inverse with a real undo().
| Class | Verbs | Compensation |
|---|---|---|
pure | zmax.project.* (find files, search, symbols, markers, stats), zmax.git.* reads, zmax.doc.blame, zmax.txn.{snapshots,interrupted,coverage,record}, and every *.preview dry run | none needed — nothing is written |
inverse | zmax.replace.apply, zmax.rename.apply, zmax.sort.apply, zmax.cleanup.apply, zmax.align.apply, zmax.comment.apply, zmax.encoding.apply, zmax.doc.replace, zmax.file.{create,rename,copy,delete}, zmax.git.discard | a content snapshot taken before the mutation (txn.rs) and written back by undo() |
inverse (paired) | zmax.git.stage / zmax.git.unstage, zmax.bookmark.add, zmax.snippet.add | the opposite command |
irreversible | zmax.git.{checkout,createBranch,stashSave,stashPop,stashDrop}, zmax.editor.{open,ex}, zmax.doc.{open,close}, zmax.txn.unwind | none — repository-wide state, the editor's own buffers, or (for the unwind) a rewrite of the whole tree a transaction touched |
A mutating verb learns the exact paths it is about to touch from its own dry run, snapshots
those, then applies. So zmax.replace.apply snapshots the files its preview named — source files and
binary documents alike — and undo() restores every one of them byte for byte.
Three refusals are load-bearing, because the alternative in each case is a verb that looks reversible:
- A truncated preview refuses to run. If the result cap cut the file list short, the snapshot would cover a prefix of what the mutation edits, so the verb rejects the call instead of half-covering itself.
- A mutation that changed nothing releases its snapshot and reports no token, so a later abort cannot rewrite a file that verb never touched.
- A compensation refuses a file the world moved past. A mutation that landed seals its
snapshot (
txn_seal), recording a SHA-256 of what it left at every path it took. A sealedtxn_restorecompares each file to that fingerprint, and where they differ it does not write: it parks the pre-image beside the file as<name>.zmax-undo-<token>and reports the path underconflicted. So an abort cannot destroy an edit that arrived after the step ran — the user's own, a formatter-on-save, an LSP code action, or another instance driving the same tree — and cannot lose the pre-state either.conflictedis not a failure; declining is the correct outcome, and the status line says how many files were left alone rather than claiming a clean rollback.
frontend/verbs.test.cjs drives the real surface against the real automation.js and asserts the
protocol at the wire level — which paths were snapshotted, that the snapshot happens between the
dry run and the apply, that an abort compensates newest-first, and that an irreversible verb is
refused inside a transaction before it runs.
The transaction outlives the process
automation.js journals a transaction in the webview's memory. That unwinds a step that failed
while the app is alive, and it is nothing at all if the app dies mid-run — which is the one moment a
forty-file refactor most needs its pre-images. A partially rewritten tree is then just a tree, and
the snapshots that could put it back are orphans nothing can attribute.
So a run also writes itself to disk, through txn.rs's journal: txn_open before the first
step, txn_append after each step lands (never before — a step that has not run has nothing to
compensate), txn_close at either end. Each write goes down as a temp file that is flushed to the
device and then renamed over the old one, so a crash sees the previous journal or the next one and
never a torn file.
A journal with no recorded outcome is a transaction that was interrupted:
| command | verb | what it does |
|---|---|---|
txn_pending | zmax.txn.interrupted (pure) | every run that was never closed, newest first |
txn_unwind | zmax.txn.unwind (irreversible) | compensate one, newest step first, through the sealed restore |
At launch the app checks for them and says so once, as a toast; ⌘K ▸ Interrupted runs lists
each with its label and step count and unwinds one on click. It is never automatic — unwinding
rewrites files, and a run that was interrupted is not always a run that was unwanted, so the app
reports what it found and the choice stays the user's. Every restore on that path is the sealed,
conflict-refusing one, so a file touched since the crash is left alone with its pre-image parked
beside it.
frontend/plan-journal.test.cjs asserts the wire protocol: that a landed step seals after the
mutation and before the result is returned, that steps are journalled after they land and in run
order with their own tokens, that both the committed and the aborted path close the journal, that a
declined file is surfaced rather than absorbed into "compensated", and that a host with no txn_*
backend still runs on the in-memory journal alone. txn.rs's own tests drive the crash end to end:
open, two steps, no close — then find the journal and unwind it back to the original bytes.
The reach the undo does not have
Everything above is the transaction's account of itself, and a transaction can only ever account for what its own steps named. The tree is not the transaction's: the checkout is shared with the editor, with save hooks, with an LSP writing caches, and — in the setup this app is built for — with the other instances of it running in the other tmux panes. So "rolled back 12/12" can be true about the run and false about the tree, and nothing in the paperwork above can tell the difference.
A run that names its roots gets a witness first: every file under them stamped by length and mtime, with no content read. When the run closes, the stamps are taken again and diffed, the paths the steps declared are subtracted, and what is left is the run's undeclared reach — real changes, inside the run's window, that no compensation covers. They are reported, not compensated; naming them is the only honest thing a transaction can do about a file it never recorded.
The declared half is verified the other way round, exactly, by content: each declared path is hashed
against the run's own pre-image — the blob recorded by the earliest step that touched it, since a
later step recorded what the previous one left, not what the run started from. Equal is
at_preimage; unequal is a divergent entry naming both hashes. After an unwind an empty
divergent is byte-level proof that the tree is back where the run found it, rather than the
restore's own word for it.
| command | verb | what it does |
|---|---|---|
txn_open(label, roots) | — | witnesses roots before the first step; omitting them keeps the older, unwitnessed behaviour |
txn_coverage | zmax.txn.coverage (pure) | declared / undeclared / at_preimage / divergent for one run |
txn_journal | zmax.txn.record (pure) | the whole record, receipt included |
The receipt is computed inside txn_close, while the pre-image blobs it hashes against still exist,
and stored on the journal — so it outlives the snapshots and a peer process can read back what a
finished run could and could not take responsibility for. zmax.txn.recovered carries the same two
numbers, so a subscriber learns in one message that a run was undone and how much of the tree that
undo never reached. witnessed: false means the run named no roots: an empty undeclared there
means "nobody looked", never "nothing else moved", and the Batch Plan's status line prints nothing
rather than a zero for it.
The two mechanisms are split on cost, not on taste. Stamping is one syscall per file; hashing is every byte. Measured over this repository's own tree as it stood at the time — 28,297 files with 2.89 GB under them — the witness cost 782 ms to take and 641 ms to diff, where hashing the same files in parallel cost 12.7 s per pass. The exactness that buys is spent where correctness needs it: on the handful of declared paths, where a stat comparison would not be good enough. The stat half's honest limit is a rewrite landing in the same nanosecond at the same length; it is a false negative in the undeclared half only, and the declared half cannot miss it.
Batch Plan: paint the refactor, run it as one transaction
⌘K ▸ Batch Plan is the user-facing half. It embeds the shared zpwr-clip-engine arrangement
grid — the same canvas renderer, model and interaction layer the DAW uses — driven by zmax-gui's
own domain (frontend/plan-domain.js). Lanes are the reversible operations, columns are the
project's files in run order, and a painted cell means "apply this operation to this file".
Run executes the whole painting inside one bus transaction, column by column (everything for one file, then the next — the order the grid reads in). If any step fails, every file already rewritten is restored and the panel reports how many steps were compensated and how many were not. A plan is persisted, so a painted cell whose file has since disappeared is dropped and counted rather than silently retargeted at whatever file now occupies that column.
Hooks editor
⌘K ▸ Hooks editor opens the shared Monaco surface
(zpwr-hooks-editor + ZGui.hooks) over
the app's own hook points — editor started / restarted, file opened / saved, project opened, search
run, git committed, terminal spawned, locale changed, app quit — each one a stryke script the host
runs through run_stryke_hook, with the stryke LSP wired in for completions and diagnostics.
The bar names the runtime that will actually execute those scripts: sidecar.rs's resolution order
is STRYKE_BIN, then the bundled sidecar, then PATH, and the strip shows which one answered (full
path on hover). When nothing answers it says stryke: not found in red — hooks and the LSP cannot
run, and saying so up front is the difference between a diagnosis and "my hook did nothing".
Bundled binaries (self-contained)
The app bundles both the zmax editor and the stryke runtime as Tauri externalBin sidecars —
it never depends on either being on the user's PATH. Before each dev/build,
scripts/prepare-{zmax,stryke}-sidecar.mjs stage the binaries into
app/src-tauri/binaries/<name>-<target-triple> (the name externalBin requires); at runtime
sidecar.rs resolves the sidecar beside the executable (or the dev staging dir) and the editor is
launched by absolute path, with STRYKE_BIN exported to the bundled stryke. The staged binaries are
gitignored build artifacts.
- zmax — vendored as the
crates/zmaxsubmodule and built by the prep script (cargo build --bin zmax); override withZMAX_SIDECAR_BIN. - stryke — pulled from the latest strykelang
GitHub release for the host triple (cached by release tag); falls back to a local stryke offline;
override with
STRYKE_SIDECAR_BIN.
Build
git submodule update --init --recursive # zgui-core, zpwr-clip-engine, zpwr-embed-terminal, zpwr-file-browser, zpwr-i18n, zmax
pnpm install
pnpm dev # or: pnpm build
The first run builds crates/zmax (Helix-fork workspace — a few minutes) and downloads the stryke
release; both are cached afterward.
The script surface is the family's, so any app is driven with the same muscle memory:
| Script | Does |
|---|---|
dev / tauri:dev · build / tauri:build | run / bundle the app |
tauri:build:ci | tauri build --ci --no-sign |
clean · bust · rebuild · nuke | purge artifacts · rotate the asset ?v= · bust+clean+build · that plus the WebView caches |
ship-check | the pre-ship gate: submodules checked out and matching their recorded pointer, every app script referenced by index.html, no NUL byte in a frontend asset, JS + Rust suites, i18n audit |
deploy | build, clear the WebView caches, launch |
test · test:js · test:rust | everything · the JS suites · cargo test |
doc · doc:open · doc:sync | cargo doc for the host, opened, or synced into docs/api |
i18n:sort · i18n:sort:check · i18n:audit | sort the catalogs · check without writing · read-only completeness audit |
i18n:seed · i18n:seed:check | re-derive this app's English seed from its call sites · fail if it is stale |
build:hooks-editor | rebuild the vendored Monaco hooks-editor bundle |
Two of those are worth knowing about before they surprise you. ship-check flags a detached
submodule worktree that differs from the recorded pointer, because that reproduces stale sources at
build time while git status looks clean. And the catalogs i18n:sort writes live in the shared
zpwr-i18n submodule, not here — the resulting diff is committed there.
The English seed
Every translatable string in this app is written twice already: once as a key and once as the
English literal the call site falls back to — T("zmax.file.save", "Save"). That makes the English
catalog derivable, and deriving it is the only way it stays true; a hand-maintained copy drifts
from the call sites the moment somebody edits a label. i18n:seed extracts every zmax.* key
into frontend/i18n-seed/en.json, and frontend/i18n-seed.js merges it underneath whatever
locale is loaded, so a shipped translation always wins and the seed only fills what no catalog
answers. Registering it the obvious way, as an __i18nExtraBases entry, merges the other way round
and would override real translations with English — which nothing on an English screen would reveal.
It is English-only on purpose. Generating 26 more locales from an English string is not translation,
and a fabricated catalog is worse than a missing one: the runtime reads any present value as a
successful lookup, so machine-filled French would render as French with nothing to distinguish it
from the real thing. i18n:seed:check fails if a second file appears in that directory, if the seed
is stale, or if one key is used with two different English strings — which is how three genuine bugs
surfaced (zmax.macros.letter_msg was simultaneously the Record, Replay and Target register prompt).
Translations still belong in the shared zpwr-i18n catalogs. The seed is the extract to hand a
translator, not a place to translate.
Releases
Pushing a v* tag runs .github/workflows/release.yml, which builds the macOS app on Apple-silicon
(aarch64) and Intel (x86_64) runners and attaches the per-arch .dmg + zipped .app to the
GitHub release. The bundled zmax (release build of the submodule) and stryke (latest release) sidecars
are staged automatically by beforeBuildCommand, so each .app is self-contained.
git tag "v$(node -p "require('./package.json').version")" && git push --tags
Links
- Core editor —
zmax - App store — https://menketechnologies.github.io/app-store/
License
Free / OSS — MPL-2.0 (zmax / Helix lineage). See LICENSE.