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, with syntax highlighting, search, autocompletion, bracket matching, code folding.
- 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
editor.js # CodeMirror factory — creates independent editor instances
git.js # Git status polling, file status colors
gitpanel.js # Full git panel UI
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
src/lib.rs # All Tauri commands (PTY, fs, git, settings, projects)
Cargo.toml # Rust deps
tauri.conf.json # App config
index.html # HTML shell — picker-root + workspace-root siblings
specs/ # Design specs (project model, agent model, etc.)
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 tabs, editor tabs, and the
settings tab share one tab bar. Each tab has a type field
("terminal", "editor", or "settings")
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.
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,git_resolve_ours,git_resolve_theirs
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 →