Architecture
A map of how Launchpad is put together — stack, layout, and the handful of patterns that show up across the codebase.
Tech stack
- Tauri v2 — the native shell. Gives us a Swift / Objective-C free macOS window with a system WebView inside.
- Rust — PTY management (
portable-pty), filesystem ops, git viagit2(libgit2 bindings) plus shelled-out systemgitfor network operations. Filesystem watching vianotify(FSEvents on macOS). - Vanilla JS — all frontend logic, direct DOM manipulation.
- xterm.js 6 — the terminal emulator, with WebGL renderer and Unicode 11 width tables.
- CodeMirror 6 — the editor: syntax highlighting, search, folding, a git change gutter, blame, and an opt-in language-server client (
@codemirror/lsp-client). - Vite — frontend bundler.
Project layout
src/ # Frontend (JS / CSS / HTML)
main.js # App entry, tab mgmt, split workspace, panes, shortcuts
projects.js # Active project state + project Tauri command wrappers
projectpicker.js # Project picker UI (welcome / recents)
filebrowser.js # File tree, context menu, DnD, git colors, CRUD, reveal
editor.js # CodeMirror factory — creates independent editor instances
changegutter.js # Git change gutter (diff-vs-HEAD markers, hunk nav/revert)
blamegutter.js # Opt-in inline blame gutter
symbols.js # Symbol outline (syntax tree + LSP documentSymbol)
lspclient.js # Language-server client over a Tauri-IPC transport
git.js # Git status polling, file status colors, pending-op snapshot
gitpanel.js # Full git panel UI (incl. amend, cherry-pick, rebase ctx menu)
diffrender.js # Renders structured diffs for the diff tab + commit detail
conflictmarkers.js # Conflict-marker parser + CodeMirror inline editor
quickopen.js # Cmd+P fuzzy file finder
settings.js # Persistent settings store (~/.launchpad/config.json)
settingspanel.js # Settings form UI
styles.css # All styles, organized by section
src-tauri/ # Rust backend — lib.rs is a thin shell; commands live in
src/lib.rs # per-concern modules re-exported into the handler:
src/pty.rs # PTY spawn / reader / backpressure
src/fs.rs # Filesystem ops, search, format_file
src/git.rs # All git commands (libgit2 + system git)
src/lsp.rs # Language-server process host + JSON-RPC framing
src/{projects,project_env,windows,watcher,settings}.rs
src/tests.rs # Unit + #[ignore]d e2e tests
Cargo.toml # Rust deps · tauri.conf.json — app config
index.html # HTML shell — picker-root + workspace-root siblings
specs/ # Active design specs (project model and editor)
Projects model
A project is just a root directory stored in
~/.launchpad/projects.json. The workspace is gated behind
the picker; enterWorkspace(project) initializes the
terminal, file browser, and git panel only after a project is chosen.
The active project is held in projects.js
as module state (activeProject). It's the single source of
truth for:
- Terminal spawn cwd
- File browser root
- Git panel path
- ⌘P search root
- Filesystem watcher
All three PTY spawn sites (createTab, splitPane,
createTabInRight) pass
getActiveProject()?.path directly — no inheritance from
the previous tab's cwd, no defaultDirectory setting, no
file-browser path.
Window routing
Single-window by default, multi-window when asked. Clicking a project
in the picker takes over the current window — the picker
view swaps for the workspace in-place. If the project is already open
in another window, focus_project_window focuses that
window and the current one stays on the picker.
enterWorkspace(project) calls
register_project_window(path, current_window_label); the
"← Projects" teardown calls
unregister_project_window(path) before reloading. A
project_windows: HashMap<canonical_path, label> in
AppState is the routing table, cleaned lazily when
get_webview_window returns None.
Tab system & split workspace
Unified tab bar. Terminal, editor, and settings tabs
share one tab bar with the diff (compare refs), rebase (interactive
rebase composer), and merge (3-pane conflict) tabs. Each tab has a
type field ("terminal", "editor",
"settings", "diff", "rebase",
"merge") and tab-type guards protect terminal-specific
code (fitAllPanes, PTY writes, split panes).
Split workspace. ⌘\ toggles a vertical
split. The right group tracks its own tab IDs in
rightGroupTabIds. Each group has its own tab bar. Tabs can
be dragged between groups or moved with ⌘⇧M. Closing the last
tab in the right group auto-collapses the workspace.
In-tab pane split. ⌘D splits a single terminal tab into two vertical panes (50/50, draggable divider). Each pane gets its own PTY. Independent from the workspace split.
Tab drag-to-move. When the workspace is split, a MutationObserver-based drag system moves tabs between left and right groups. Group markers on tab objects track which group they belong to.
PTY lifecycle
Each terminal tab or pane spawns its own PTY via portable-pty.
The reader thread is not started during
spawn_pty — instead, the frontend calls
start_pty_reader after registering the pane in
paneMap. This prevents a race where early output would be
dropped before the frontend had anywhere to route it.
Backpressure
pause_pty_reader and resume_pty_reader let
the frontend signal when xterm.js's write queue exceeds its high-water
mark. The reader thread sleeps until cleared; data stays buffered in
the OS PTY buffer. No queue explosion, no memory bloat.
cat /very/large/file, peg the
CPU, and make the terminal feel stuck. Flow control fixes that.
File browser
Root-locked. setRoot() guards against
any path outside projectRoot. Nav-up ↑ caps at
the project root (no-op when already there). Go-home ⌂
jumps back to the root.
No terminal disruption. The file browser holds no
concept of the terminal's cwd. Browsing subfolders never writes
anything to a PTY — CLI agents (Claude, Aider, etc.) are safe from
accidental cd.
Off-DOM tree building. The file tree is built in a detached DOM fragment and swapped in a single operation to prevent flicker when expanding folders.
Live filesystem watcher. Uses the notify
crate (FSEvents on macOS) with a 300ms debounce to watch the project
root recursively. Started once in enterWorkspace().
Emits fs-changed Tauri events that trigger
refreshFileBrowser() and git status updates. Frontend
throttles to 500ms.
Git backend
libgit2 for local, system git for network. Local
operations (stage, unstage, stash, branch) use the git2
crate — fast, in-process, no fork overhead. Network operations (push,
pull, fetch, merge) shell out to system git via
std::process::Command so they respect the user's SSH
keys, credential helpers, and .gitconfig. Network ops
are cancellable via cancel_git_op, which kills the
spawned process by PID.
Dual status entries. get_git_status
emits separate entries for staged (index_new,
index_modified, index_deleted) and unstaged
(new, modified, deleted)
changes. A file can appear in both lists.
Panel re-rendering. The git panel rebuilds its
innerHTML on every refresh but uses a snapshot comparison
(JSON.stringify) to skip redundant re-renders during the
3-second poll. Module-level state
(expandedCommitOid) persists across re-renders.
Pending Operation banner.
get_pending_op_state is polled alongside
get_git_status (one round-trip per cycle). It detects
merge / cherry-pick / rebase pause via .git/MERGE_HEAD,
CHERRY_PICK_HEAD, and rebase-merge|rebase-apply
directories. The banner renders the right Continue / Skip / Abort
buttons per kind and is the single source of truth for "what state
is git in?"
Interactive rebase. Driver-script approach. A
tempfile::Builder-created temp dir (TOCTOU-safe; chmod
0o700) holds seq_editor.sh +
commit_editor.sh + state.json (chmod
0o600) keyed by LAUNCHPAD_REBASE_STATE_DIR.
The scripts read JSON and emit the desired todo / commit message,
decoupling the user's $EDITOR from the rebase. A
backup tag (rebase-backup-<branch>-<timestamp>)
survives success; abort drops it. RebaseStateInfo
on AppState centralizes cleanup so Continue / Skip /
Abort can release it idempotently. revparse_single
resolves rev expressions (HEAD^, ~2) to
concrete OIDs server-side.
Conflict UX. The
conflictExtension in conflictmarkers.js
parses <<<<<<< /
||||||| / ======= /
>>>>>>> blocks (strict — col 0,
exactly 7 chars, complete sequence; bails on nested), draws an
inline action bar with Accept Ours / Theirs / Both, and
tints the ours / base / theirs ranges. Save when
parseConflictBlocks(content).length === 0 auto-stages
via git_stage_file. The 3-pane merge tab uses
get_conflict_versions (libgit2 index stages 1 / 2 / 3
+ working tree) and renders three CodeMirror 6 views
with EditorState.readOnly on the side panes. Scroll
listener pinned on the tab + detached in doCloseTab
before view.destroy() to avoid view-retention leaks.
Tauri commands (Rust → JS IPC)
All commands live in src-tauri/src/lib.rs. A partial index:
PTY
spawn_pty(cwd?, rows?, cols?)— spawn a new PTY, returns{ tab_id }. Does not start reading output yet.start_pty_reader(tab_id)— start the output reader thread (call after registering the pane)write_to_pty,resize_pty,pause_pty_reader,resume_pty_reader,close_pty
Filesystem
read_directory,search_files,read_file_previewwrite_file,create_file,create_directory,delete_path,rename_pathwatch_directory/unwatch_directory— emitsfs-changedeventspick_directory,reveal_in_finder,get_home_dir
Git
get_git_status,list_branches,list_remote_branches,get_commits,get_remote_urlcheckout_branch,create_branch,git_delete_branchgit_stage_file,git_unstage_file,git_stage_all,git_unstage_all,git_discard_filegit_commit,git_push,git_pull,git_fetch,git_merge_branch,cancel_git_opgit_stash_save/pop/list/apply/dropget_file_diff,get_commit_details,get_diff_between_refs,git_resolve_ours,git_resolve_theirsgit_amend_commit,git_head_on_remote,git_cherry_pick/_continue/_abort,get_pending_op_stateget_rebase_candidate_commits,git_rebase_interactive_apply,git_rebase_continue/_skip/_abortget_conflict_versions— libgit2 index stages 1 / 2 / 3 + working tree, powers the 3-pane merge tabget_file_diff_vs_head,git_blame_file— back the editor change gutter and blame margin
Language server
lsp_start(project_path, language)— spawn (or reuse) a language server, returns a server id; idempotentlsp_send(server_id, message)— frame a JSON-RPC message and write it to the server's stdinlsp_stop(server_id)— kill the server and drop its slot. Emitslsp-message/lsp-exitevents to the frontend.
Projects & windows
load_projects,add_project,remove_project,rename_project,touch_projectfocus_project_window,register_project_window,unregister_project_windowopen_new_window(path?)— creates a new Tauri window (with path: URL?folder=<path>; without: boots into picker)
Contributing
PRs welcome. A few conventions that keep things consistent:
- Vanilla JS, direct DOM manipulation. Keep it that way.
- Project-scoped features rule. When wiring a new feature that cares about "the current directory", use
getActiveProject().path— notgetCurrentPath()from the file browser.getCurrentPath()tracks sub-folder navigation; only the file browser itself should read it. - Prefer small. If 30 lines handle it, don't reach for a library or an abstraction.
Read CLAUDE.md in the repo for a more exhaustive architecture rundown, and specs/project-model-spec.md for the projects design doc.
Done exploring? Get started →