A lightweight, memory-frugal text editor for macOS with Markdown preview and simple syntax highlighting for Swift, Python and Markdown.
The motivation: editing a couple of kB of text should not cost a gigabyte of RAM. FEdit deliberately avoids heavyweight machinery — native AppKit text views instead of a web-based editor, and a native Markdown renderer instead of an embedded browser. In practice it settles at roughly 80–190 MB with small files open, most of which is framework memory an audit found to be irreducible rather than leaked; the point is the order of magnitude, not a race to the bottom.
- Three-column window — folder sidebar, editor, and a Markdown preview column that appears only while a Markdown file is open. Draggable, persisted splits (1/3 · 1/3 · 1/3 by default), each column topped by a fixed header strip (folder name(s), open file name, "Preview"). Windows are independent: each has its own folders, filter, open file and cursor.
- Folder sidebar — open multiple top-level folders (each its own section,
~-abbreviated header, Remove/Refresh menu); expandable tree view, or a flat filtered list driven by a boolean query language (.py OR .swift,ANDbinds tighter thanOR, space = union,^/$anchor a term to the start/end of the path). Scanning runs off the main thread — the window is usable while a big root fills in behind aScanning…placeholder — and each root's tree is capped at ~50,000 entries, filled breadth-first, with a notice in that section when the cap is hit. Sidebar roots and the open file are watched (FSEvents / vnode) so external adds, removes and edits are reflected automatically. Files whose working-tree content differs fromHEADshow a read-only "(changed)" badge when the root is a git repo. - Editor — line numbers, soft wrapping, lightweight regex-based syntax highlighting for Swift, Python and Markdown; opens any UTF-8 (Latin-1 fallback) text file; font size zoom (Cmd-+ / Cmd-− / Cmd-0), app-wide and persisted.
- Find in the editor text — Cmd+F opens a find bar over the editor (never the preview): literal matching with a visible Case sensitive checkbox (off by default), every match highlighted with the current one distinguished, Return / Cmd+G to step and wrap (Cmd+Shift+G steps backwards, wrapping at the start), a
3 of 17count, Esc to close. No regex and no replace, by design. In a narrow editor column the bar wraps to two rows — query field and checkbox above, count and Done below — rather than spilling out over the split dividers. - Markdown preview — rendered natively (no WKWebView), with approximate scroll sync: the preview follows the first line visible in the editor. GFM pipe tables render as real aligned grids (
NSTextTable), with a bold header row and:---:alignment honored; ragged rows degrade gracefully rather than being rejected or truncated. - Save flow — explicit save (Cmd+S), plus unconditional, always-on debounced autosave (no toggle) on typing pause, file switch, window close and quit. A clean buffer is reloaded when the file changes underneath you; a dirty one is kept and the window subtitle says "changed on disk" until the next save wins. The only surviving dialog is a minimal "Close Without Saving / Cancel" escape when a close/quit flush fails.
- File creation — File → New… (Cmd+N) creates a file via a filename sheet in the current folder.
- Session restore — reopens folders, last file, cursor position, splits and windows.
- Light appearance only, deliberately — no dark mode, no themes.
| Shortcut | Action |
|---|---|
| Cmd+N | New… — create a file in the open file's folder (or the first root) |
| Cmd+O | Open Folder… — opens a new window and prompts for its sole root |
| Cmd+Shift+O | Add Folder to Window… — add root(s) to the focused window |
| Cmd+S | Save (autosave is always on regardless) |
| Cmd+F | Find — open the find bar over the editor text (Esc closes it) |
| Cmd+G | Find Next — step to the next match, wrapping at the end |
| Cmd+Shift+G | Find Previous — step to the previous match, wrapping at the start |
| Cmd-+ / Cmd-− / Cmd-0 | Increase / decrease / reset the editor font size |
v1 feature-complete: everything in the original plan has shipped (see DONE.md); TODO.md carries the open queue of work filed since — currently empty. SPEC.md is the maintained implementation contract — kept in sync with each shipped change, and the place to look for exact behavior. It also absorbed the original one-page pitch, which used to live in Specification.md.
- macOS 26 or later
- Xcode 26 (to build)
Open FEdit.xcodeproj in Xcode and Run. No third-party dependencies.
A handful of pure-logic modules (filter query, filter row caching, markdown renderer, git status parsing, file tree scanning, per-root scan scheduling, watcher skip gating, session snapshots, line counting, command-line path mapping, --wait marker claiming, find match enumeration and stepping) also have standalone swiftc-run regression harnesses — eleven of them under scripts/*/main.swift, used in place of an XCTest target; the fedit shim has a shell one at scripts/FeditShimTests/run.sh. Two more under scripts/*/main.swift — GutterRulerTests and FindBarWidthTests — are not pure logic; see the note below.
Each harness's header comment carries the exact command that builds and runs it, e.g.
swiftc FEdit/Models/FilterQuery.swift scripts/FilterQueryTests/main.swift -o /tmp/fqtests && /tmp/fqtests
sh scripts/FeditShimTests/run.shEleven of the thirteen scripts/*/main.swift harnesses are pure logic and run
anywhere. Two are render-level and need AppKit to be able to draw — in practice
a logged-in GUI session. Neither creates a window or becomes visible:
scripts/GutterRulerTestspins the line-number gutter's drawing staying inside the editor pane, by rasterising a real TextKit stack and scroll view withcacheDisplay(in:to:).scripts/FindBarWidthTestspins the find bar fitting inside the editor column rather than painting over the split dividers, by rendering the realFindBarat a range of column widths and looking for ink outside its own frame. It also straddles the one-row/two-row switch from the production constant itself (so moving that constant without re-measuring fails), and pins the bar's height to exactly the one-row or two-row figure — a Case sensitive label that wrapped or truncated would produce neither.
scripts/install.sh builds the Release configuration (derived data goes to a fixed folder under $TMPDIR, outside the repo) and installs FEdit.app into /Applications, replacing any previous copy. Pass a directory as the single optional argument to install somewhere else. If FEdit is already running, quit and relaunch it to pick up the new build.
scripts/install.shThe installer also drops a fedit command into the first writable directory among $FEDIT_BIN_DIR, /opt/homebrew/bin, /usr/local/bin and ~/.local/bin (created if needed), with its app path pointed at wherever the bundle was installed. It never uses sudo, and a shim that cannot be placed is a warning, not a failed install. Installing to a non-default destination (e.g. a temp directory for testing) skips the shim — it would repoint your real fedit at the throwaway copy — unless FEDIT_BIN_DIR is set explicitly.
fedit notes.md # opens notes.md, with its folder as the sidebar root
fedit . # opens the current directory as a root, no file open
fedit src/a.py docs/b.md # one window each
fedit # just launches or activates FEdit
fedit --wait notes.md # opens notes.md and blocks until that window closes
fedit --help # usage, on stdoutEach path gets its own new window — an existing window, full or empty, is never disturbed (the request is delivered as the new window's own value, so it cannot land anywhere else). A file's containing folder becomes that window's sole sidebar root, so fedit ~/notes.md scans your entire home directory (the same cost as picking ~ in the Open Folder… panel). The scan runs off the main thread: the window appears immediately, showing Scanning… in the sidebar while the tree fills in behind it, and the ~50,000-entry cap keeps a home-scale root bounded (it loses only its deepest reached level's tail, and says so in its sidebar section). At most 8 paths per call.
-h/--help and -w/--wait are recognized as the first argument only — after that everything is a path, since a file really can be called --help.
| Situation | Exit code |
|---|---|
| Success | open's status (normally 0) |
| A path does not exist (nothing is opened, not even the valid paths) | 66 |
More than 8 paths, or a --wait that is not exactly one existing regular file |
64 |
FEdit.app could not be found |
69 |
--wait misconfigured: neither HOME nor FEDIT_WAIT_DIR set (no place for its marker), or a non-numeric FEDIT_WAIT_ACK_TIMEOUT |
78 |
--wait gave up: the open produced no window within 30 s, or FEdit died holding the file |
1 |
--wait interrupted by Ctrl-C, kill, or a closed terminal |
128 + signal |
Set FEDIT_APP to the bundle's path if it is not where the installer put it. If there is no bundle there, the shim falls back to asking LaunchServices for an app named FEdit — so a stale FEDIT_APP may quietly open a differently-located copy and still exit 0. The 69 row above applies only when neither resolves.
A few details worth knowing:
- Symlinks are resolved. The shim canonicalizes every argument with
realpath(the usual command-line convention), sofedit link/notes.mdshows the real folder in the sidebar, whereas opening the same folder through Cmd+O would show the link's name. - A dotfile has no sidebar row.
fedit ~/.zshrcopens the file in the editor as expected, but the sidebar skips hidden files, so nothing in the tree is highlighted. - A cold
fedit x.mdalso restores your previous session. The new window comes up next to the windows FEdit had when you last quit, not instead of them; it is then an ordinary window and comes back with the next session too.
fedit --wait <file> opens one file in its own window and does not return until that window closes, which is the contract git, crontab -e and friends expect of an editor:
git config --global core.editor "fedit --wait"git commit then opens COMMIT_EDITMSG in a FEdit window; write the message, close the window (Cmd+W), and the commit completes.
How to abort a commit. FEdit autosaves — there is no "close without saving" — so the way to abort is to delete the whole message text and then close the window. Git sees an empty message and aborts. (Simply typing nothing works the same way, since git's template lines are all comments.) Quitting FEdit while the message window is open counts as finishing: git gets whatever was autosaved.
The file opens with its containing folder as the sidebar root, so a commit-message window is rooted at the repository's .git directory. That is the same rule every other external open follows.
Details: it takes exactly one path, and that path must already exist (fedit --wait new-file.md is an error, not a create). It leaves a marker file in ~/Library/Application Support/FEdit/wait for the duration of the wait — that is how the window tells the command line it has closed — and removes it on every exit path, including Ctrl-C. If the open never produces a window, it gives up after 30 seconds with exit 1 rather than blocking forever. sudo crontab -e is not supported: the marker would land in root's spool where FEdit never looks, so it gives up after that same 30 seconds. The full marker protocol — claim, acknowledgement, release, and the garbage collection that keeps a killed shim's marker from being claimed by a later wait — is SPEC.md §15.2.
FEdit is free software, licensed under the GNU General Public License v3.0 (or, at your option, any later version).
Copyright © 2026 Felix Matschke
