GhostMD is a native macOS note-taking app written in Rust using GPUI (GPU-accelerated UI framework). Notes are plain .md files in ~/Documents/ghostmd/.
Cargo workspace with two crates (crates/ghostmd, crates/ghostmd-core). The workspace root Cargo.toml patches gpui-component to the vendored crates/gpui-component/. There is also a separate Swift iOS companion app at ghostmd-ios/ (see below) — not part of the Cargo workspace.
-
crates/ghostmd-core/— Pure business logic, zero UI dependencies. All data structures and algorithms live here.buffer.rs— Rope-based text buffer (ropey 2.0.0-beta.1) with branching undo tree (undo 0.44)note.rs— Note CRUD and auto-savediary.rs— Date-based diary path generation (diary/YYYY/MM-monthname/DD/)tree.rs— File tree model with recursive scansearch.rs— Fuzzy file search (nucleo-matcher) + full-text content search (grep-searcher)path_utils.rs— Collision-safe path generation (unique_path()appends-2,-3, ...)
-
crates/ghostmd/— Native GPUI application shell. State machines are tested independently of GPUI rendering.app_view/— Main GPUI view (refactored into submodules):mod.rs—GhostAppViewstruct, constructor,Renderimpl with action handlersworkspace.rs— Workspace CRUD, pane focus, split management, tab switchingfile_ops.rs— File open/create/move/trash, location picker,update_editor_paths()helper, update mechanismrendering.rs— All render methods: tab bar, split nodes, overlays (file finder, agentic search, location picker, command palette), context menuoverlays.rs— Open/close methods for all overlayspalette_dispatch.rs— Command palette command list and dispatch, rename modeai_commands.rs— AI rename tab/file, suggest folder, agentic search, search matchessession.rs— Session persistence (save/restore workspaces to JSON)split_node.rs—SplitNodebinary tree with directional navigationfs_watcher.rs— File system watcher for external changes (notify crate)nav_history.rs— Back/forward navigation stack (cursor + path + workspace/pane id). Max 100 entries, dedupes when cursor moves <50 bytes, truncates forward branch on new push.navigating_historyflag prevents recursive pushes during navigation. Not persisted across sessions.file_undo.rs— In-memory undo/redo for file ops (Delete,DeleteBatch,Rename,Move,Create). Backs up entire directory trees recursively as(path, bytes)pairs. Max 50 entries.reverse_op()morphsCreateintoDelete. Used by trash and file-tree rename/move.
editor_view.rs— GPUI view wrappingInputStatefor editing a single note. Tracks path, dirty flag, auto-save timingfile_tree_view.rs— GPUI view for the sidebar file tree (rendersFileTreePanel)app.rs— Legacy root state machine.#![allow(dead_code)]— fully superseded byGhostAppView's directrootandsidebar_visiblefieldseditor.rs— Legacy editor state machine.#[cfg(test)]onlyfile_tree.rs— File tree sidebar state machine with keyboard navigationsearch.rs— File finder state machine (wired to GPUI viaapp_view/rendering.rs)tabs.rs— Legacy tab manager.#[cfg(test)]onlysplits.rs— Legacy flat split pane layout.#[cfg(test)]onlypalette.rs— Command palette state machine (filtering, selection)ai.rs— AI manager for suggestion storage/retrieval.#[cfg(test)]onlytheme.rs— Multi-theme support (GhostTheme,ResolvedThemepre-converted HSLA cache) withrgb_to_hslaconverter. ~23 themes; default isRosePine. Each theme defines 13 color slots (bg, fg, selection, cursor, line_number, sidebar_bg, tab_active/inactive, accent, error, border, pane_title_bg/fg).keybindings.rs— GPUI action definitions and keyboard shortcut registrationassets.rs— Asset loading (fonts)main.rs— Application entry point, window creation
cargo build # full build
cargo test # all tests
cargo test -p ghostmd-core # core logic only
cargo test -p ghostmd # UI state machine tests
cargo bench -p ghostmd-core # criterion benchmarks (buffer, tree, search)
cargo clippy --tests # must pass with zero warningsRequires Rust 1.75+ and Xcode with Metal Toolchain on macOS.
- Version is defined in
crates/ghostmd/Cargo.toml— this is the single source of truth. - CI auto-creates a git tag (
vX.Y.Z) when the version changes on main (.github/workflows/auto-tag.yml). - The release workflow (
.github/workflows/release.yml) triggers onv*tags and builds macOS binaries. - To release: bump the version in
Cargo.tomland push to main. CI handles the rest. - Known CI caveat: Tags created by
GITHUB_TOKEN(e.g. via the auto-tag workflow) do NOT trigger other workflows. If the release workflow doesn't fire after auto-tag, delete the remote tag and re-push it locally:git push origin :refs/tags/vX.Y.Z && git tag vX.Y.Z && git push origin vX.Y.Z.
- ropey 2.0.0-beta.1 uses byte indices, not char indices. All buffer operations work in bytes.
- undo 0.44 uses
Actiontrait (notEdit). Methods:apply,undo,mergereturningMerged. - GPUI dependencies require pinning
core-foundation = "=0.10.0"andcore-text = "=21.0.0"to avoid conflicts. - Diary paths use
MM-monthnamewith lowercase month names:diary/2026/03-march/15/HHMMSS-slug.md(zero-padded DD). - String truncation must use
chars().take(n)not byte slicing&s[..n]— byte slicing panics on multi-byte UTF-8. - Dead code policy: No crate-level
#![allow(dead_code)]. Each module/item that is tested but not yet wired to GPUI gets its own#[allow(dead_code)]or module-level#![allow(dead_code)]. When wiring new features, remove the corresponding allows. - Modules with
#:ai.rs,app.rs,editor.rs,splits.rs,search.rs(the state machine; theFileFinderis wired separately). All other dead code is suppressed per-item. - Vendored gpui-component: The workspace root
Cargo.tomlhas[patch.crates-io]pointing tocrates/gpui-component/. Local patches to gpui-component (e.g. input behavior fixes) are applied there. - gpui-component features: The
tree-sitter-languagesfeature MUST be enabled on thegpui-componentdependency for syntax highlighting to work. Without it,code_editor("markdown")silently falls back to a plain JSON grammar. - Testing with custom workspace: Set
GHOSTMD_ROOT=/tmp/ghostmd-test cargo runto use a test directory instead of~/Documents/ghostmd/. Useful for testing without touching personal notes. - Same-file multi-pane sync: Each pane has its own independent
EditorView/InputState. Edits in one pane only appear in another pane showing the same file after auto-save triggers the file watcher reload — there is no live in-memory sync yet. EditorViewreload gotcha:skip_next_changesuppresses theChangeevent fired during external file reloads — otherwise reload would mark the buffer dirty and trigger auto-save loops.ContentSearchis regex, not literal:grep-regexinterprets the query as a regex pattern. Callers must escape special chars (search.rsincrates/ghostmd/has a regex-escape helper for literal matching).ContentSearchis uncached — each call walks the entire tree.FuzzySearchcaches file paths and must be refreshed viarefresh_cache().FuzzySearch::refresh_cache()caches files and folders (root excluded) so cmd-P can surface folder names too. Haystack is the relative path (slash-joined), not just the leaf — so"13"matches13/april/notes.md.nucleo'smatch_paths()config still weights leaf-segment hits highest.refresh_dir_cache()is the folder-only variant (used for the "move to folder" picker).unique_path()caps at 100 — if 99 collision-suffixed candidates exist it returns the original path unmodified. Don't rely on it for unbounded collision avoidance.tree.rspinsdiary/first — after every scan the diary dir is moved to index 0. Collapsed state is preserved across rescans..ghostmd/,.gitignore,.DS_Storeare filtered out.diary::slugify()is ASCII-only — non-ASCII alphanum is stripped, which can reduce a CJK or accent-heavy title tountitled. Diary timestamps useLocal::now(), not UTC. Full diary path:diary/YYYY/MM-monthname/DD/HHMMSS-slug.md(zero-padded DD, lowercase month).- Two distinct history stacks:
nav_history(workspace-level back/forward across panes, cmd-[/ cmd-]) is separate fromPane.path_history(per-pane fallback used when the open file is deleted/moved). Don't conflate them. OverlayKindvariants:Palette,FileFinder,AgenticSearch,NoteSwitcher(plus location picker and context menu, which are not part of the enum). Always exactly one or zero overlays open.
The GPUI app uses a multi-workspace model:
GhostAppView— Root view. Holdsroot: PathBuf,sidebar_visible,Vec<Workspace>,active_overlay: Option<OverlayKind>,theme: ResolvedTheme, file watcher.Workspace— Containsid: usize(stable, monotonically increasing),split_root: SplitNode,panes: HashMap<usize, Pane>,focused_pane,title.SplitNode— Binary tree of splits.Leaf(pane_id)orSplit { direction, left, right }. Methods:leaves(),split_leaf(),remove_leaf(),find_left/right/up/down().Pane— Holdsactive_path: Option<PathBuf>andeditor: Option<Entity<EditorView>>.
Key patterns:
FileTreeViewis a focusable, keyboard-navigable "pane-like" target but is NOT a real pane inSplitNode. It has its ownFocusHandleand akey_context("FileTree")so context-scoped bindings (arrow keys, enter, cmd-enter, cmd-z/cmd-shift-z, esc) only fire when the tree is focused. Keyboard cursor uses the samelast_clicked/selected_paths/anchor_pathfields as mouse selection —set_keyboard_selection()keeps them in sync.nav_move_left/rightare direction-aware: on dirs they expand/collapse; on files they jump to parent / first child.alt-cmd-leftfallthrough: when the focused pane has no left neighbor and the sidebar is visible, focus the tree instead of no-op.alt-cmd-balways focuses the tree (and shows the sidebar if hidden).esc/alt-cmd-rightwhile the tree is focused return focus to the active pane editor.- Hide-sidebar focus rescue:
ToggleSidebarwhen hiding refocuses the editor pane so arrow keys don't go to an invisible tree. - Finder match highlights:
FileTreeViewcarries amatch_paths: HashSet<PathBuf>(and a precomputedmatch_ancestorsset) thatset_match_paths()updates.GhostAppView::sync_finder_match_highlights()pushes the current fuzzy-finder result set into the tree after everyset_query(), andclear_finder_match_highlights()resets it on dismiss. Matched rows render with the accent color; ancestor dirs get a blended tint so collapsed parents of a match still light up. - Workspace ID vs index: Always use
workspace.id(stable) for async callbacks, never positional index which shifts on add/remove.ai_loading: HashSet<usize>stores workspace IDs. - Empty workspace guard: Always check
self.workspaces.is_empty()before callingself.active_ws()/self.active_ws_mut()— they index directly and will panic on empty vec. - Editor path updates: Use
self.update_editor_paths(old, new, cx)when renaming/moving files or directories. It handles both exact matches and child paths for directory moves. - Borrow checker: The render method clones the active workspace to avoid borrow conflicts with
cx.listener(). Extract data fromself.workspaces[idx]before calling methods that take&mut self. - Overlays:
active_overlay: Option<OverlayKind>enum ensures only one overlay at a time. Useself.overlay_is(OverlayKind::Palette)to check.dismiss_overlays()closes the current overlay viamatch. - ResolvedTheme: Use
self.theme.fg,self.theme.accent, etc. instead of callingrgb_to_hsla()per render. Rebuilt automatically on theme switch. - Collision avoidance: Use
ghostmd_core::path_utils::unique_path()for safe file/folder creation — appends-2,-3, etc.
Separate Swift / SwiftUI app, not part of the Cargo workspace. Targets iOS 17+. Generated from project.yml via XcodeGen (the .xcodeproj/ is gitignored and regenerated). It shares notes with the macOS app via the filesystem only — it reads/writes the same .md files in Documents/ghostmd/ (or the iCloud ubiquity container if available). No IPC with the Rust app. UI hides .ghostmd/ and non-markdown files. Most logic lives in GhostMD/NoteStore.swift; models are intentionally thin.
scripts/—bundle-macos.sh(build the macOS.appfrom the cargo binary),install.sh(cross-platform installer that fetches the latest GitHub release).docs/—index.html, deployed to GitHub Pages by.github/workflows/pages.yml.assets/— App icons (AppIcon.icns,icon.png) and JetBrains Mono font files (embedded viarust_embedinassets.rs)..github/workflows/—ci.yml(test on macOS + Linux),release.yml(build macOS binaries onv*tags),auto-tag.yml(tags on version bump),pages.yml(docs site).
| Shortcut | Action |
|---|---|
| cmd-n | New note (shows location picker if folder selected) |
| cmd-option-n | New daily note (carries over pending - [ ] items from last diary note) |
| cmd-shift-n | New OS window |
| cmd-t | New workspace tab |
| cmd-w | Close pane; last pane closes workspace |
| cmd-shift-t | Restore last closed workspace |
| ctrl-tab / ctrl-shift-tab | Next / previous workspace |
| cmd-1 through cmd-9 | Switch to workspace N |
| cmd-d | Split right (vertical) |
| cmd-shift-d | Split down (horizontal) |
| alt-cmd-arrows | Focus pane in direction |
| cmd-s | Save |
| cmd-b | Toggle sidebar |
| cmd-p | File finder (matches files and folders; folder names show with a trailing slash and reveal in the tree) |
| cmd-enter (in file finder) | Open selected file in a new split (folder result falls back to "reveal in tree") |
| alt-cmd-b | Focus the sidebar file tree (auto-shows it if hidden) |
| alt-cmd-left (from leftmost pane) | Focus the sidebar file tree (otherwise: focus pane to the left) |
| up / down (file tree focused) | Move tree cursor |
| left / right (file tree focused) | Collapse / expand dir (or jump to parent / first child) |
| enter (file tree focused) | Open file (or toggle directory expansion) |
| cmd-enter (file tree focused) | Open file in a new split |
| esc / alt-cmd-right (file tree focused) | Return focus to the editor pane |
| cmd-shift-f | Agentic search (Claude-powered) |
| cmd-shift-p | Command palette |
| cmd-shift-a | Open note switcher (search open notes) |
| cmd-f | Find in file |
| cmd-[ / cmd-] | Navigate back / forward (nav history) |
| cmd-z / cmd-shift-z (file tree focused) | Undo / redo file ops (delete, rename, move, create) |
| cmd-backspace | Move to trash |
| cmd-q | Quit |
- Don't create unnecessary markdown files
- Do not edit markdown files without asking for permission
- ALWAYS COMMIT YOUR CHANGES, and do it with a succinct one-liner and no attribution (don't add "Co-Authored-By" type of shit)
- Always combine
git addandgit commitin a single shell command (e.g.git add file.ts && git commit -m "msg") — never split them into separate tool calls, as other agents working on the repo can race you and either commit your staged files or have their files included in your commit - NEVER amend commits that have already been pushed
- NEVER amend commits at all - other agents might be working concurrently and amending can cause conflicts
- NEVER use
git restoreorgit checkoutto discard changes - other agents may be working concurrently and their changes could be lost - NEVER use
git commit -a- always stage specific files to avoid committing adjacent changes from other agents - After every committed change, update
CHANGELOG.mdunder the[Unreleased]section with a concise description of what changed. When bumping a version, move unreleased entries under the new version heading with the date. - Always run
cargo check(orcargo test -p ghostmd-coreif GPUI Metal shaders fail locally) after making changes to catch compilation errors before committing. CI runs on Linux and will reject broken builds.