This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
2code is a Tauri 2 desktop application for managing code projects with integrated terminal sessions. It combines a React 19 frontend with a Rust backend, featuring:
- Project management with folder selection and metadata
- Profile management via git worktrees (branch-isolated workspaces)
- Persistent PTY (pseudo-terminal) sessions with scrollback restoration
- SQLite database for project/session/profile storage
- Project-level configuration (
2code.json) for setup/teardown scripts - Git diff/commit history browsing
- i18n support via Paraglide.js (English + Chinese)
# Dev server (frontend + backend hot-reload)
bun tauri dev
# Frontend-only dev
bun run dev
# Production build
bun tauri build
# Frontend-only build (runs paraglide compile → tsc → vite build)
bun run build
# Rust tests
cd src-tauri && cargo test
cd src-tauri && cargo test test_name # single test
# Regenerate TypeScript bindings from Rust commands
cargo tauri-typegen generate
# Format code
just fmt # runs 'fama'React 19 + TypeScript + Vite. Provider stack (outermost → innermost): QueryClientProvider → ThemeProvider → TooltipProvider → BrowserRouter → AppRoot, with the shadcn Toaster mounted inside TooltipProvider.
Routing (react-router v7): / → HomePage, /projects/:id/profiles/:profileId → ProjectDetailPage, /settings → SettingsPage, * → redirect to /.
Key directories (feature-based organization):
generated/— Auto-generated Tauri IPC bindings viatauri-typegen(gitignored, do not edit)features/home/— HomePagefeatures/projects/— ProjectDetailPage, project hooks (useProjects,useCreateProject,useProjectProfiles, etc.) and dialogs (Create/Delete/Rename)features/profiles/— Profile hooks (useCreateProfile,useDeleteProfile) and dialogsfeatures/terminal/— Terminal store, hooks (useCreateTerminalTab,useCloseTerminalTab,useRestoreTerminals,useTerminalTheme), themes, and components (Terminal, TerminalTabs, TerminalLayer, TerminalPreview)features/git/— GitDiffDialog, ProjectTopBar (git branch display + diff trigger), and components (ChangesFileList, CommitList, GitDiffPane, HistoryFileList)features/settings/— SettingsPage, picker components, and Zustand stores (stores/terminalSettingsStore,stores/themeStore,stores/notificationStore)features/watcher/— File system watcher hook (useFileWatcher) for live project updates via Tauri eventsfeatures/debug/— Debug panel (Cmd+Shift+D toggle), debug logger, and stores (debugStore,debugLogStore)shared/lib/— Query client config, centralized query keys, cached promise utilityshared/providers/— ThemeProvidershared/components/— Fallbacks (PageSkeleton, PageError, SidebarSkeleton), SidebarLink. ErrorBoundary is fromreact-error-boundarypackage.layout/— AppSidebar andsidebar/sub-components (ProjectMenuItem, ProfileList, ProfileItem)
State management:
- Zustand for client state (terminal tabs per project, font preferences, notification settings)
- TanStack Query for server state (projects, sessions, profiles)
- Query keys centralized in
shared/lib/queryKeys.ts— always usequeryKeys.projects.all/queryKeys.git.diff(profileId)pattern terminalSettingsStore,notificationStore, andthemeStoreuse persist middleware (localStorage). Terminal store is rebuilt from DB on startup.
UI Framework:
- shadcn/ui primitives in
src/components/ui(Base UI + Tailwind CSS v4) next-themesfor dark/light mode (wrapped in custom ThemeProvider)sonnerfor toast notifications
Rust application with Tauri 2. Entry: main.rs → lib.rs.
Layered architecture (4 layers):
- Handler (
handler/) — Tauri#[tauri::command]entry points. Extracts state (DbPool, PtySessionMap), acquires DB lock, delegates to service layer. Thin layer — no business logic. - Service (
service/) — Business logic and orchestration. Coordinates between repository and infrastructure layers (e.g., creating temp dirs, initializing git repos, running scripts). - Repository (
repo/) — Direct database access via Diesel ORM. CRUD operations and complex queries (e.g.,resolve_context_foldertries profiles table first, falls back to projects). - Infrastructure (
infra/) — Cross-cutting concerns:db.rs(SQLite setup + migrations),git.rs(git command execution),pty.rs(PTY session lifecycle),pty_log.rs(per-session output stored as{app_data_dir}/pty_logs/{session_id}.logfiles),slug.rs(CJK-aware slug generation),config.rs(project config loading + script execution),logger.rs(debug logging),watcher.rs(file system watching),shell_init.rs(ZDOTDIR-based shell init injection).
Model (model/) — Diesel models and DTOs: Queryable structs (Project, Profile, PtySessionRecord), Insertable structs (NewProject, NewProfile), AsChangeset structs (UpdateProject, UpdateProfile), and non-DB types (GitCommit, GitAuthor, WatchEvent, LogEntry).
Database: SQLite via Diesel ORM, single connection wrapped in Arc<Mutex<SqliteConnection>> (not a pool). Stored at app_data_dir()/app.db. Pragmas: WAL journal mode, foreign keys ON. Tables: projects, profiles, pty_sessions (session metadata only — output bytes live in per-session files, not the DB).
Database migrations: Diesel migrations in src-tauri/migrations/, embedded at compile time via diesel_migrations::embed_migrations!() and run on app startup in infra::db::init_db(). Schema auto-generated in src/schema.rs.
PTY output streaming: Background thread reads 4KB chunks → sends raw PTY chunks to the active output sink registered by attach_pty_output(sessionId, streamId). stream_pty_output owns a tauri::ipc::Channel<&[u8]> and sends each chunk with on_output.send(chunk.as_slice()); detach_pty_output must pass the same streamId so stale React cleanup cannot remove a newer stream for the same session. Terminal.tsx receives ArrayBuffer and writes new Uint8Array(payload) to xterm. Output produced before attach is recovered from the persisted log via get_pty_session_history (same seam the old global-event API had). Bytes are sent as-is — xterm.js decodes UTF-8 across writes, so no backend boundary splitting is needed. Session exit is still a low-volume global event (pty-exit-{id}). A separate persistence thread via mpsc channel with 32KB flush buffer appends raw bytes to the session's pty_logs/{session_id}.log file (see infra::pty_log). No byte cap — a log lives only for one session (removed on restore/close/delete) and scrollback is bounded on restore by the vt100 emulator (sanitize_history, 10k lines). Orphan logs are reaped on startup by service::pty::gc_orphan_logs.
Workspace crates: model/, repo/, service/, and infra/.
Agent status detection: Terminal.tsx detects coding-agent state from xterm screen text, OSC title, and OSC progress after live output writes. Rules live in src/features/terminal/detector/rules/, one manifest per agent. The detector publishes running|waiting|idle to terminalStore; waiting status can play the configured system sound via the generated playSystemSound Tauri command.
The project uses tauri-typegen to auto-generate typed TypeScript bindings from Rust commands. Config in tauri.conf.json under plugins.typegen (output: src/generated/).
Adding a new command:
- Define Rust command with
#[tauri::command]inhandler/*.rs - Register in
lib.rsviatauri::generate_handler![] - Run
cargo tauri-typegen generateto regenerate TypeScript bindings - Import generated function directly:
import { myCommand } from "@/generated" - Consume via TanStack Query hook in the relevant
src/features/*/hooks.tswith query invalidation on mutations
Do not create manual API wrappers in src/api/ — all IPC bindings are auto-generated.
Terminals never unmount — tab switches and route changes use CSS display: none to preserve xterm.js state. The TerminalLayer component renders as a persistent absolute-positioned overlay across all routes.
Session restoration on app start:
- Fetch all sessions from DB (including closed ones with scrollback)
- Create new PTY session with same metadata
- Pass old
session.idasrestoreFromprop - Terminal component fetches history, writes to xterm, then deletes old record
Session cleanup: mark_all_open_sessions_closed() runs both on startup (orphan cleanup) and on exit (graceful shutdown).
Git operations (get_git_diff, get_git_log, get_commit_diff) accept a contextId parameter that can be either a project ID or a profile ID. The backend resolves this polymorphically via repo::project::resolve_context_folder(): profile ID → profile's worktree path; project ID → project's folder. This lets git operations work seamlessly with both regular project folders and profile worktrees.
Profiles create isolated branch workspaces using git worktree add. Each profile gets a worktree in ~/.2code/workspace/{profile_id}. Branch names are sanitized (CJK → pinyin, special chars stripped). On creation, setup_script from 2code.json runs in the worktree. On deletion, teardown_script runs, then the worktree and branch are removed.
Projects can include a 2code.json in their root folder:
{ "setup_script": ["npm install"], "teardown_script": ["rm -rf node_modules"] }Scripts execute via sh -c in the project/worktree directory. Used automatically during profile creation/deletion.
// Direct access in mutations (outside React):
useTerminalStore.getState().addTab(...)
// Reactive subscriptions in components:
const tabs = useTerminalStore(s => s.profiles[profileId]?.tabs)Tests use in-memory SQLite with embedded migrations:
fn setup_db() -> SqliteConnection {
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory db");
diesel::sql_query("PRAGMA foreign_keys=ON;").execute(&mut conn).ok();
conn.run_pending_migrations(MIGRATIONS).expect("run migrations");
conn
}Tests are colocated with implementation in #[cfg(test)] modules.
Paraglide.js v2 with inlang message format plugin. Source messages in messages/{locale}.json. Generated code in src/paraglide/ (gitignored, do not edit).
Usage: import * as m from "@/paraglide/messages.js" → m.home()
Critical: project.inlang/settings.json must include the modules array:
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"]Without this, paraglide compiles but generates empty message files. Also requires allowJs: true in tsconfig.json.
@/ maps to src/ — configured in both vite.config.ts (resolve.alias) and tsconfig.json (paths). Keep them in sync.
- Database is single-connection (
Arc<Mutex<SqliteConnection>>), not a pool — avoid long-held locks - Terminals use CSS display for show/hide — do not refactor to conditional rendering or they lose xterm state
- PTY output sends
&[u8]over a per-session IPCChannel(not global events; frontend receivesArrayBuffer) — xterm.js handles UTF-8 decoding across chunk boundaries, so no backend boundary splitting is done - Font listing and sound playback are platform-backed: macOS uses
core-text+/System/Library/Sounds+afplay; Linux usesfontdb+ XDG sound dirs + desktop audio players; Windows usesfontdb+C:\Windows\Media+ PowerShellMedia.SoundPlayer. - UI components should use shadcn/ui primitives from
src/components/ui; do not add legacy UI-library APIs back - Directory/branch name generation uses
pinyincrate for CJK → romanized slugs — well-tested, don't simplify - macOS title bar uses overlay style with custom traffic light positioning — window chrome is defined in
tauri.conf.json - Tauri plugins:
tauri-plugin-opener,tauri-plugin-dialog,tauri-plugin-notification,tauri-plugin-store— all registered inlib.rs - Generated bindings (
src/generated/) are gitignored — runcargo tauri-typegen generateafter changing Rust commands - Diesel schema (
src-tauri/src/schema.rs) is auto-generated — do not edit manually; rundiesel print-schemaor migrations - Immer MapSet plugin — terminal store uses
Set<string>fornotifiedTabs, requiresenableMapSet()from immer before store creation. Already called at module level instore.ts; if addingSet/Mapto other immer stores, enable it there too