Launchpad / Architecture

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 via git2 (libgit2 bindings) plus shelled-out system git for network operations. Filesystem watching via notify (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.

Why not stream directly? A naïve implementation that pipes PTY output straight into xterm.js as fast as it arrives can overwhelm the renderer on a 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_preview
  • write_file, create_file, create_directory, delete_path, rename_path
  • watch_directory / unwatch_directory — emits fs-changed events
  • pick_directory, reveal_in_finder, get_home_dir

Git

  • get_git_status, list_branches, list_remote_branches, get_commits, get_remote_url
  • checkout_branch, create_branch, git_delete_branch
  • git_stage_file, git_unstage_file, git_stage_all, git_unstage_all, git_discard_file
  • git_commit, git_push, git_pull, git_fetch, git_merge_branch, cancel_git_op
  • git_stash_save / pop / list / apply / drop
  • get_file_diff, get_commit_details, get_diff_between_refs, git_resolve_ours, git_resolve_theirs
  • git_amend_commit, git_head_on_remote, git_cherry_pick / _continue / _abort, get_pending_op_state
  • get_rebase_candidate_commits, git_rebase_interactive_apply, git_rebase_continue / _skip / _abort
  • get_conflict_versions — libgit2 index stages 1 / 2 / 3 + working tree, powers the 3-pane merge tab
  • get_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; idempotent
  • lsp_send(server_id, message) — frame a JSON-RPC message and write it to the server's stdin
  • lsp_stop(server_id) — kill the server and drop its slot. Emits lsp-message / lsp-exit events to the frontend.

Projects & windows

  • load_projects, add_project, remove_project, rename_project, touch_project
  • focus_project_window, register_project_window, unregister_project_window
  • open_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().pathnot getCurrentPath() 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 →