From 65f0297b43bdeb7dd3c1ee56f134d1fee4054180 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:17:56 -0600 Subject: [PATCH 01/12] chore: add tempfile dev-dependency for worktree tests Phase B tests need real git repos in temp dirs. tempfile is the standard Rust crate for that pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 + 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e7b95d..801e7ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "bitflags" version = "2.11.0" @@ -99,7 +105,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -232,6 +238,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -265,6 +277,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -285,6 +310,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" @@ -300,12 +331,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "indoc" version = "2.0.7" @@ -343,6 +392,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" @@ -375,6 +430,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -486,6 +547,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -504,6 +575,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ratatui" version = "0.29.0" @@ -540,7 +617,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -597,10 +674,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -619,6 +709,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -750,6 +846,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -813,6 +922,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "time", ] @@ -857,6 +967,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "vcpkg" version = "0.2.15" @@ -875,6 +991,58 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -985,6 +1153,100 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 3b13301..dcec11d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,6 @@ dirs = "6" md-5 = "0.10" regex = "1" time = { version = "0.3", features = ["formatting", "macros"] } + +[dev-dependencies] +tempfile = "3" From c6cf70a2dbd36bbe489e2c41672a65715bcf7e86 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:19:05 -0600 Subject: [PATCH 02/12] feat: add git::prune_worktrees and add_worktree_existing_branch helpers prune_worktrees clears stale admin entries when a worktree dir has been deleted manually. add_worktree_existing_branch attaches a worktree to a branch that already exists locally (unlike create_worktree which always uses -b and errors on existing branches). Both helpers are needed by the upcoming find_or_create_worktree logic which must recover from prunable worktrees without re-creating the branch ref. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/git.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/git.rs b/src/git.rs index fdd128d..d16b205 100644 --- a/src/git.rs +++ b/src/git.rs @@ -169,6 +169,41 @@ pub fn remove_worktree(repo_path: &str, worktree_path: &str) -> Result<(), GitEr Ok(()) } +pub fn prune_worktrees(repo_path: &str) -> Result<(), GitError> { + let output = run(&["worktree", "prune"], Some(repo_path)); + if !output.status.success() { + return Err(GitError(format!( + "Failed to prune worktrees in '{}': {}", + repo_path, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) +} + +/// Attach a worktree to a branch that already exists locally. +/// Unlike `create_worktree`, this does not use `-b`, so it does not require +/// the branch to be new. +pub fn add_worktree_existing_branch( + repo_path: &str, + worktree_path: &str, + branch: &str, +) -> Result<(), GitError> { + let output = run( + &["worktree", "add", worktree_path, branch], + Some(repo_path), + ); + if !output.status.success() { + return Err(GitError(format!( + "Failed to attach worktree at '{}' to branch '{}': {}", + worktree_path, + branch, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) +} + pub fn get_pr_branch(repo_path: &str, pr_number: i64) -> Result { let pr_str = pr_number.to_string(); let output = Command::new("gh") @@ -240,3 +275,61 @@ pub fn has_remote_branch(repo_path: &str, branch: &str) -> Result Date: Tue, 19 May 2026 11:20:10 -0600 Subject: [PATCH 03/12] feat: add find_or_create_worktree helper Free function resolve_or_create_worktree does the work; Manager method threads in repo metadata and worktrees_dir. Consults git worktree list as source of truth; reuses recorded paths even when outside the trellis-convention layout; recovers from prunable worktrees by reusing the existing branch ref. start_point is required so each caller (n, t, R) declares its base branch explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/manager.rs b/src/manager.rs index aeb4619..31d6ec8 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -42,6 +42,60 @@ pub fn apply_layout(session_name: &str, working_dir: &str) { let _ = tmux::select_window(session_name, 1); } +/// Find or create the on-disk worktree for (repo, branch). +/// +/// Git's `worktree list` is the source of truth. If a worktree for `branch` +/// already exists, returns its recorded path (even if outside `worktrees_dir`). +/// If git considers a worktree prunable (dir gone), prunes and reattaches the +/// worktree to the still-existing branch. Otherwise creates fresh at the +/// trellis-convention path, branching from `start_point`. +/// +/// `start_point` is only consulted when creating a NEW local branch. Callers +/// pass what they want the branch rooted at (e.g. `repo.default_branch`, +/// `session.base_branch`, or `format!("origin/{}", branch)` for PR review). +pub fn resolve_or_create_worktree( + repo_path: &str, + repo_name: &str, + branch: &str, + worktrees_dir: &str, + start_point: &str, +) -> Result { + let mut needs_prune = false; + let existing = git::list_worktrees(repo_path).map_err(|e| e.to_string())?; + for wt in &existing { + if wt.branch.as_deref() == Some(branch) { + if !Path::new(&wt.path).exists() { + needs_prune = true; + break; + } + return Ok(wt.path.clone()); + } + } + + if needs_prune { + let _ = git::prune_worktrees(repo_path); + } + + let wt_path = Path::new(worktrees_dir) + .join(repo_name) + .join(branch.replace('/', "-")) + .to_string_lossy() + .to_string(); + + // Pick between `git worktree add -b ` (new branch) + // and `git worktree add ` (existing branch). After a prune, + // the local branch ref still exists, so the existing-branch path is taken. + let branches = git::list_branches(repo_path).map_err(|e| e.to_string())?; + if branches.iter().any(|b| b == branch) { + git::add_worktree_existing_branch(repo_path, &wt_path, branch) + .map_err(|e| format!("Failed to attach worktree: {}", e))?; + } else { + git::create_worktree(repo_path, &wt_path, branch, start_point) + .map_err(|e| format!("Failed to create worktree: {}", e))?; + } + Ok(wt_path) +} + /// Detect subsystem directories in a monorepo (e.g. workers/model_train, src/sojourner). pub fn detect_subsystems(repo_path: &str) -> Vec { let root = Path::new(repo_path); @@ -102,6 +156,21 @@ impl Manager { .to_string() } + fn find_or_create_worktree( + &self, + repo: &Repo, + branch: &str, + start_point: &str, + ) -> Result { + resolve_or_create_worktree( + &repo.path, + &repo.name, + branch, + &self.worktrees_dir().to_string_lossy(), + start_point, + ) + } + // ------------------------------------------------------------------ // Internal helpers // ------------------------------------------------------------------ @@ -791,3 +860,74 @@ impl Manager { db::get_worktrees(&self.conn) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::process::Command; + use tempfile::tempdir; + + fn init_repo(dir: &Path) { + Command::new("git").args(["init", "-q"]).current_dir(dir).output().unwrap(); + Command::new("git").args(["config", "user.email", "t@t"]).current_dir(dir).output().unwrap(); + Command::new("git").args(["config", "user.name", "t"]).current_dir(dir).output().unwrap(); + Command::new("git").args(["commit", "--allow-empty", "-qm", "init"]).current_dir(dir).output().unwrap(); + Command::new("git").args(["branch", "-M", "main"]).current_dir(dir).output().unwrap(); + } + + fn make_repo(tmp: &Path) -> (String, String) { + let canonical = tmp.canonicalize().unwrap(); + let repo = canonical.join("repo"); + let worktrees = canonical.join("worktrees"); + fs::create_dir_all(&repo).unwrap(); + fs::create_dir_all(&worktrees).unwrap(); + init_repo(&repo); + (repo.to_string_lossy().to_string(), worktrees.to_string_lossy().to_string()) + } + + #[test] + fn resolve_creates_when_no_existing_worktree() { + let tmp = tempdir().unwrap(); + let (repo, wts) = make_repo(tmp.path()); + let path = resolve_or_create_worktree(&repo, "myrepo", "feat/x", &wts, "main").unwrap(); + assert!(Path::new(&path).exists()); + assert!(path.ends_with("feat-x")); + } + + #[test] + fn resolve_reuses_existing_worktree() { + let tmp = tempdir().unwrap(); + let (repo, wts) = make_repo(tmp.path()); + let first = resolve_or_create_worktree(&repo, "myrepo", "feat/x", &wts, "main").unwrap(); + let second = resolve_or_create_worktree(&repo, "myrepo", "feat/x", &wts, "main").unwrap(); + assert_eq!(first, second); + } + + #[test] + fn resolve_recovers_from_prunable_worktree() { + let tmp = tempdir().unwrap(); + let (repo, wts) = make_repo(tmp.path()); + let first = resolve_or_create_worktree(&repo, "myrepo", "feat/x", &wts, "main").unwrap(); + // Nuke the dir behind git's back to make it prunable. + fs::remove_dir_all(&first).unwrap(); + let second = resolve_or_create_worktree(&repo, "myrepo", "feat/x", &wts, "main").unwrap(); + assert!(Path::new(&second).exists()); + } + + #[test] + fn resolve_finds_worktree_at_nonconventional_path() { + let tmp = tempdir().unwrap(); + let (repo, wts) = make_repo(tmp.path()); + // User created a worktree at an unusual path outside our convention. + let unusual = tmp.path().canonicalize().unwrap().join("unusual-spot"); + Command::new("git") + .args(["worktree", "add", "-b", "feat-y", unusual.to_str().unwrap(), "main"]) + .current_dir(&repo) + .output() + .unwrap(); + // Now ask trellis to find/create for feat-y — should return the unusual path. + let found = resolve_or_create_worktree(&repo, "myrepo", "feat-y", &wts, "main").unwrap(); + assert_eq!(found, unusual.to_string_lossy().to_string()); + } +} From b6b21ee3d4989efc8dc96ad27091b25bbb7abef9 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:21:35 -0600 Subject: [PATCH 04/12] feat: add find_or_create_session helper Extracts schema-application from init_db into db::init_schema so tests can build an in-memory connection. Adds Manager::find_or_create_session which routes by sanitized session name: returns existing if the tmux session is alive, drops stale DB rows before inserting fresh. Tests use process-unique branch names and a Drop guard so the tmux sessions created during testing get cleaned up even on panic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db.rs | 55 +++++++++++++---------- src/manager.rs | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/db.rs b/src/db.rs index 4290e9b..d0a8629 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,6 +8,37 @@ pub fn init_db(db_path: &Path) -> Connection { std::fs::create_dir_all(parent).ok(); } let conn = Connection::open(db_path).expect("open database"); + init_schema(&conn); + + // Seed defaults (env-dependent, so kept out of init_schema). + conn.execute( + "INSERT OR IGNORE INTO config (key, value) VALUES (?1, ?2)", + params![ + "repos_dir", + dirs::home_dir().unwrap().join("dev").to_str().unwrap() + ], + ) + .unwrap(); + conn.execute( + "INSERT OR IGNORE INTO config (key, value) VALUES (?1, ?2)", + params![ + "worktrees_dir", + dirs::home_dir() + .unwrap() + .join("dev/worktrees") + .to_str() + .unwrap() + ], + ) + .unwrap(); + + conn +} + +/// Apply schema (tables + migrations) to an open connection. +/// Separated from `init_db` so tests can build an in-memory connection +/// with the full schema without depending on `dirs::home_dir()`. +pub fn init_schema(conn: &Connection) { conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); conn.execute_batch( @@ -43,28 +74,6 @@ pub fn init_db(db_path: &Path) -> Connection { ) .unwrap(); - // Seed defaults - conn.execute( - "INSERT OR IGNORE INTO config (key, value) VALUES (?1, ?2)", - params![ - "repos_dir", - dirs::home_dir().unwrap().join("dev").to_str().unwrap() - ], - ) - .unwrap(); - conn.execute( - "INSERT OR IGNORE INTO config (key, value) VALUES (?1, ?2)", - params![ - "worktrees_dir", - dirs::home_dir() - .unwrap() - .join("dev/worktrees") - .to_str() - .unwrap() - ], - ) - .unwrap(); - // Migration: add last_selected_at column if missing let has_col: bool = conn .prepare("PRAGMA table_info(sessions)") @@ -76,8 +85,6 @@ pub fn init_db(db_path: &Path) -> Connection { conn.execute_batch("ALTER TABLE sessions ADD COLUMN last_selected_at TEXT;") .unwrap(); } - - conn } // --- Config --- diff --git a/src/manager.rs b/src/manager.rs index 31d6ec8..1b58b71 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -171,6 +171,52 @@ impl Manager { ) } + fn find_or_create_session(&self, repo: &Repo, branch: &str, wt_path: &str) -> Result { + let session_name = tmux::sanitize_session_name(branch); + + // Existing DB row? + if let Some(existing) = self.get_session_by_name(&session_name) { + if existing.repo_id == repo.id.unwrap() && tmux::session_exists(&session_name) { + return Ok(existing); + } + // Stale row — drop it. + if let Some(id) = existing.id { + db::delete_session(&self.conn, id); + } + } + + // Create fresh: DB row, tmux session via apply_layout, optional worktree DB row. + let session = db::add_session( + &self.conn, + &Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.to_string(), + created_at: utc_now(), + last_selected_at: None, + }, + ); + apply_layout(&session_name, wt_path); + + if wt_path != repo.path && db::get_worktree_by_path(&self.conn, wt_path).is_none() { + db::add_worktree( + &self.conn, + &Worktree { + id: None, + repo_id: repo.id.unwrap(), + path: wt_path.to_string(), + branch: branch.to_string(), + session_id: session.id, + tmux_window: None, + created_at: utc_now(), + }, + ); + } + + Ok(session) + } + // ------------------------------------------------------------------ // Internal helpers // ------------------------------------------------------------------ @@ -930,4 +976,78 @@ mod tests { let found = resolve_or_create_worktree(&repo, "myrepo", "feat-y", &wts, "main").unwrap(); assert_eq!(found, unusual.to_string_lossy().to_string()); } + + // ------------------------------------------------------------------ + // find_or_create_session tests + // ------------------------------------------------------------------ + + fn make_manager() -> Manager { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::init_schema(&conn); + Manager::new(conn) + } + + /// Cleans up a tmux session at end of test (even on panic). + struct SessionGuard(String); + impl Drop for SessionGuard { + fn drop(&mut self) { + let _ = crate::tmux::kill_session(&self.0); + } + } + + /// Process-unique branch name. tmux::sanitize_session_name leaves `-` and + /// alphanumerics alone, so the sanitized version is identical. + fn unique_branch(suffix: &str) -> String { + format!("trellistest-{}-{}", std::process::id(), suffix) + } + + #[test] + fn find_or_create_session_inserts_when_missing() { + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("fresh"); + let _guard = SessionGuard(crate::tmux::sanitize_session_name(&branch)); + let wt_path = tmp.path().join("wt").to_string_lossy().to_string(); + + let session = mgr.find_or_create_session(&repo, &branch, &wt_path).unwrap(); + assert_eq!(session.base_branch, branch); + assert!(crate::db::get_session_by_name(&mgr.conn, &session.name).is_some()); + } + + #[test] + fn find_or_create_session_drops_stale_db_row_then_inserts() { + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("stale"); + let session_name = crate::tmux::sanitize_session_name(&branch); + let _guard = SessionGuard(session_name.clone()); + + // Pre-insert a "stale" DB row. The corresponding tmux session does not + // exist yet (unique name + Drop cleanup guarantees this across runs). + crate::db::add_session(&mgr.conn, &crate::models::Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.clone(), + created_at: crate::utils::utc_now(), + last_selected_at: None, + }); + assert!(crate::db::get_session_by_name(&mgr.conn, &session_name).is_some()); + assert!(!crate::tmux::session_exists(&session_name)); + + let wt_path = tmp.path().join("wt").to_string_lossy().to_string(); + let fresh = mgr.find_or_create_session(&repo, &branch, &wt_path).unwrap(); + assert_eq!(fresh.name, session_name); + let rows: Vec<_> = crate::db::get_sessions(&mgr.conn) + .into_iter() + .filter(|s| s.name == session_name) + .collect(); + assert_eq!(rows.len(), 1); + } } From 546f4deed8770a5ae7152fdaea0a818ad9ce5367 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:22:17 -0600 Subject: [PATCH 05/12] refactor: route create_session through find_or_create_worktree Adds an early by-branch lookup that routes to an existing live session for (repo, base_branch) regardless of user-typed name, matching the spec's "route to where it lives" semantic. Dead DB rows (tmux session gone) are dropped before any fresh insert. Worktree creation is delegated to find_or_create_worktree with repo.default_branch as the explicit start_point (preserving existing behavior). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 74 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 1b58b71..a9948c9 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -270,7 +270,9 @@ impl Manager { // Public API // ------------------------------------------------------------------ - /// Register repo if needed, create worktree, create DB session, create tmux session. + /// Register repo if needed, create or reuse worktree, create or reuse DB + /// session, create tmux session. If a live session for (repo, base_branch) + /// already exists, routes to it (user-typed `session_name` ignored). pub fn create_session( &self, repo_path: &str, @@ -280,54 +282,66 @@ impl Manager { ) -> Result { let repo = self.get_or_create_repo(repo_path); - let default = &repo.default_branch; - let start_dir = if base_branch == default { - repo_path.to_string() - } else { - // Fetch latest before creating worktree (non-fatal — stale state is acceptable) - let _ = git::fetch_and_pull(repo_path, default); - - let wt_path = self.worktree_path(&repo.name, base_branch); - match git::create_worktree(repo_path, &wt_path, base_branch, default) { - Ok(()) => {} - Err(e) => { - // Branch/worktree may already exist - use it if the dir is there - if !Path::new(&wt_path).exists() { - return Err(format!("Worktree creation failed: {}", e)); - } - } + // Spec: "n on branch X when X already has a session — route to the + // existing session." Consult by branch BEFORE honoring the user-typed + // name. The wizard's auto_session_name disambiguates to "feat-x-2", + // "feat-x-3", etc. when a session for the branch exists; bypass that. + let by_branch: Vec = db::get_sessions(&self.conn) + .into_iter() + .filter(|s| s.repo_id == repo.id.unwrap() && s.base_branch == base_branch) + .collect(); + for s in by_branch { + if tmux::session_exists(&s.name) { + return Ok(s); } - wt_path + // Dead tmux session — drop stale row and continue. + if let Some(id) = s.id { + db::delete_session(&self.conn, id); + } + } + + // No live session for this branch. Resolve the worktree. + let wt_path = if base_branch == repo.default_branch { + repo.path.clone() + } else { + // Fetch latest before resolving worktree (non-fatal — stale is acceptable). + let _ = git::fetch_and_pull(repo_path, &repo.default_branch); + // New branches root at the repo's default branch. + self.find_or_create_worktree(&repo, base_branch, &repo.default_branch)? + }; + + // Honor the user-typed session name (or fall back to sanitized branch). + let chosen_name = if session_name.is_empty() { + tmux::sanitize_session_name(base_branch) + } else { + tmux::sanitize_session_name(session_name) + }; + + let effective_dir = match subdirectory { + Some(sub) => Path::new(&wt_path).join(sub).to_string_lossy().to_string(), + None => wt_path.clone(), }; let session = db::add_session( &self.conn, &Session { id: None, - name: session_name.to_string(), + name: chosen_name.clone(), repo_id: repo.id.unwrap(), base_branch: base_branch.to_string(), created_at: utc_now(), last_selected_at: None, }, ); + apply_layout(&chosen_name, &effective_dir); - let effective_dir = if let Some(sub) = subdirectory { - Path::new(&start_dir).join(sub).to_string_lossy().to_string() - } else { - start_dir.clone() - }; - - apply_layout(session_name, &effective_dir); - - // Record worktree if we created one - if start_dir != repo_path { + if wt_path != repo.path && db::get_worktree_by_path(&self.conn, &wt_path).is_none() { db::add_worktree( &self.conn, &Worktree { id: None, repo_id: repo.id.unwrap(), - path: start_dir, + path: wt_path.clone(), branch: base_branch.to_string(), session_id: session.id, tmux_window: None, From bdfd3f18a61f031b14f82a6225b2ebbedf2ec1bf Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:22:54 -0600 Subject: [PATCH 06/12] refactor: route add_tab through find_or_create_worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline git::create_worktree call with the unified helper, so a tab for a branch that already has a worktree no longer errors — the new tmux window attaches to the existing worktree path. session.base_branch is passed as the explicit start_point, preserving the original semantic where new branches in a tab are rooted at the session's working branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index a9948c9..071dd97 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -492,7 +492,9 @@ impl Manager { Ok((session, wt_path)) } - /// Create a worktree + tmux window for the given session. + /// Create or reuse a worktree + tmux window for the given session. + /// If a worktree for `branch_name` already exists, attaches a new tmux + /// window to it instead of erroring. pub fn add_tab(&self, session_id: i64, branch_name: &str) -> Result { let session = self .get_session_by_id(session_id) @@ -502,23 +504,26 @@ impl Manager { .get_repo_by_id(session.repo_id) .ok_or_else(|| format!("Repo {} not found", session.repo_id))?; - let wt_path = self.worktree_path(&repo.name, branch_name); - git::create_worktree(&repo.path, &wt_path, branch_name, &session.base_branch) - .map_err(|e| format!("Failed to create worktree: {}", e))?; + // New branches in a tab root at the session's base branch (preserves existing behavior). + let wt_path = self.find_or_create_worktree(&repo, branch_name, &session.base_branch)?; let _ = tmux::new_window(&session.name, branch_name, Some(&wt_path)); - let worktree = db::add_worktree( - &self.conn, - &Worktree { - id: None, - repo_id: repo.id.unwrap(), - path: wt_path, - branch: branch_name.to_string(), - session_id: Some(session_id), - tmux_window: None, - created_at: utc_now(), - }, - ); + let worktree = if let Some(existing) = db::get_worktree_by_path(&self.conn, &wt_path) { + existing + } else { + db::add_worktree( + &self.conn, + &Worktree { + id: None, + repo_id: repo.id.unwrap(), + path: wt_path, + branch: branch_name.to_string(), + session_id: Some(session_id), + tmux_window: None, + created_at: utc_now(), + }, + ) + }; Ok(worktree) } From 92306fdeb637639d75bed01cc41771ef5f301c56 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:23:28 -0600 Subject: [PATCH 07/12] refactor: route checkout_and_review through find_or_create_* helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces the PR-review flow to PR-number resolution + the two unified helpers. Threads origin/ as the explicit start_point so the PR's actual HEAD commits are used when creating the local branch — the load-bearing behavior the helper extraction must preserve. 70 lines down to ~12; same observable semantic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 67 +++++++------------------------------------------- 1 file changed, 9 insertions(+), 58 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 071dd97..8ce55d2 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -417,8 +417,8 @@ impl Manager { }) } - /// Create a worktree + session for a PR number or branch, ready for review. - /// Returns (session, worktree_path). Reuses existing if found. + /// Create or reuse worktree + session for a PR number or branch, ready + /// for review. Returns (session, worktree_path). pub fn checkout_and_review( &self, repo_path: &str, @@ -426,69 +426,20 @@ impl Manager { ) -> Result<(Session, String), String> { let repo = self.get_or_create_repo(repo_path); - // Resolve PR number to branch name - let branch = if pr_or_branch.chars().all(|c| c.is_ascii_digit()) && !pr_or_branch.is_empty() - { + let branch = if pr_or_branch.chars().all(|c| c.is_ascii_digit()) && !pr_or_branch.is_empty() { let pr_num: i64 = pr_or_branch.parse().unwrap(); - git::get_pr_branch(repo_path, pr_num) - .map_err(|e| format!("Failed to get PR branch: {}", e))? + git::get_pr_branch(repo_path, pr_num).map_err(|e| format!("Failed to get PR branch: {}", e))? } else { pr_or_branch.to_string() }; - // Fetch the branch so it's available locally git::fetch_branch(repo_path, &branch); - // Check if we already have a session for this branch - let session_name = tmux::sanitize_session_name(&branch); - let wt_path = self.worktree_path(&repo.name, &branch); - let existing_session = self.get_session_by_name(&session_name); - - if let Some(ref s) = existing_session { - if Path::new(&wt_path).exists() { - return Ok((s.clone(), wt_path)); - } - } - - // Create worktree if it doesn't already exist on disk - if !Path::new(&wt_path).exists() { - let base = format!("origin/{}", branch); - git::create_worktree(repo_path, &wt_path, &branch, &base) - .map_err(|e| format!("Failed to create worktree: {}", e))?; - } - - if let Some(s) = existing_session { - return Ok((s, wt_path)); - } - - // Create session - let session = db::add_session( - &self.conn, - &Session { - id: None, - name: session_name.clone(), - repo_id: repo.id.unwrap(), - base_branch: branch.clone(), - created_at: utc_now(), - last_selected_at: None, - }, - ); - apply_layout(&session_name, &wt_path); - - // Record worktree - db::add_worktree( - &self.conn, - &Worktree { - id: None, - repo_id: repo.id.unwrap(), - path: wt_path.clone(), - branch: branch.clone(), - session_id: session.id, - tmux_window: None, - created_at: utc_now(), - }, - ); - + // PR review roots the new local branch at the PR's HEAD on the remote, + // not main — preserves the original `checkout_and_review` semantic. + let start_point = format!("origin/{}", branch); + let wt_path = self.find_or_create_worktree(&repo, &branch, &start_point)?; + let session = self.find_or_create_session(&repo, &branch, &wt_path)?; Ok((session, wt_path)) } From e37b5907b84cf2abb6454916f7f74f6027f743bc Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 11:24:47 -0600 Subject: [PATCH 08/12] refactor: remove unused Manager::worktree_path Path construction now lives inside resolve_or_create_worktree; all former callers (create_session, add_tab, checkout_and_review) route through find_or_create_worktree, leaving the public method without callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 8ce55d2..aa4613b 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -148,14 +148,6 @@ impl Manager { .unwrap_or_else(|| dirs::home_dir().unwrap().join("dev").join("worktrees")) } - pub fn worktree_path(&self, repo_name: &str, branch: &str) -> String { - self.worktrees_dir() - .join(repo_name) - .join(branch.replace("/", "-")) - .to_string_lossy() - .to_string() - } - fn find_or_create_worktree( &self, repo: &Repo, From eaeb2474ad08773bbd61c1d2f67c6a62cfd1a28d Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 15:37:50 -0600 Subject: [PATCH 09/12] feat: resurrect dead sessions on Enter in session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DB session row whose tmux session no longer exists (e.g. after reboot, or because scan_existing discovered worktrees on disk that were never tied to live tmux state) used to silently no-op when the user pressed Enter — switch_client errored on a nonexistent target and the .ok() at main.rs:41 discarded the failure. Add Manager::resurrect_session(id), which calls apply_layout using the worktree path recorded in the DB (falling back to the repo root for default-branch sessions). Session list's Enter handler now invokes it when the highlighted row is managed && !live, surfacing any failure through the inline status message. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 131 ++++++++++++++++++++++++++++++++++++++++ src/tui/session_list.rs | 11 +++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/manager.rs b/src/manager.rs index aa4613b..53c597a 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -163,6 +163,38 @@ impl Manager { ) } + /// If the tmux session for `session_id` is dead (DB row exists but + /// `tmux has-session` is false), recreate it using the worktree path + /// recorded in the DB (falling back to the repo root for default-branch + /// sessions). No-op if the session is already alive. + pub fn resurrect_session(&self, session_id: i64) -> Result<(), String> { + let session = self + .get_session_by_id(session_id) + .ok_or_else(|| format!("session {} not found", session_id))?; + + if tmux::session_exists(&session.name) { + return Ok(()); + } + + let wt_path = db::get_worktrees_for_session(&self.conn, session_id) + .into_iter() + .next() + .map(|wt| wt.path) + .or_else(|| self.get_repo_by_id(session.repo_id).map(|r| r.path)) + .ok_or_else(|| format!("no worktree or repo for session {}", session_id))?; + + if !Path::new(&wt_path).exists() { + return Err(format!("path '{}' no longer exists", wt_path)); + } + + apply_layout(&session.name, &wt_path); + + if !tmux::session_exists(&session.name) { + return Err(format!("failed to create tmux session '{}'", session.name)); + } + Ok(()) + } + fn find_or_create_session(&self, repo: &Repo, branch: &str, wt_path: &str) -> Result { let session_name = tmux::sanitize_session_name(branch); @@ -979,6 +1011,105 @@ mod tests { assert!(crate::db::get_session_by_name(&mgr.conn, &session.name).is_some()); } + #[test] + fn resurrect_session_creates_dead_tmux_session() { + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("resurrect"); + let session_name = crate::tmux::sanitize_session_name(&branch); + let _guard = SessionGuard(session_name.clone()); + + // Insert a DB session + worktree but never start the tmux session. + let inserted = crate::db::add_session(&mgr.conn, &crate::models::Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.clone(), + created_at: crate::utils::utc_now(), + last_selected_at: None, + }); + crate::db::add_worktree(&mgr.conn, &crate::models::Worktree { + id: None, + repo_id: repo.id.unwrap(), + path: repo_path.clone(), + branch: branch.clone(), + session_id: inserted.id, + tmux_window: None, + created_at: crate::utils::utc_now(), + }); + assert!(!crate::tmux::session_exists(&session_name)); + + mgr.resurrect_session(inserted.id.unwrap()).unwrap(); + assert!(crate::tmux::session_exists(&session_name)); + } + + #[test] + fn resurrect_session_is_noop_when_already_alive() { + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("alive"); + let session_name = crate::tmux::sanitize_session_name(&branch); + let _guard = SessionGuard(session_name.clone()); + + // Pre-create the tmux session so resurrect_session sees a live one. + crate::tmux::new_session(&session_name, &repo_path).unwrap(); + assert!(crate::tmux::session_exists(&session_name)); + + let inserted = crate::db::add_session(&mgr.conn, &crate::models::Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.clone(), + created_at: crate::utils::utc_now(), + last_selected_at: None, + }); + + mgr.resurrect_session(inserted.id.unwrap()).unwrap(); + // Still alive, never killed and recreated. + assert!(crate::tmux::session_exists(&session_name)); + } + + #[test] + fn resurrect_session_errors_when_worktree_path_missing() { + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("missing-path"); + let session_name = crate::tmux::sanitize_session_name(&branch); + let _guard = SessionGuard(session_name.clone()); + + let inserted = crate::db::add_session(&mgr.conn, &crate::models::Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.clone(), + created_at: crate::utils::utc_now(), + last_selected_at: None, + }); + // Worktree row points at a path that doesn't exist. + crate::db::add_worktree(&mgr.conn, &crate::models::Worktree { + id: None, + repo_id: repo.id.unwrap(), + path: "/var/empty/does-not-exist-trellistest".to_string(), + branch: branch.clone(), + session_id: inserted.id, + tmux_window: None, + created_at: crate::utils::utc_now(), + }); + + let result = mgr.resurrect_session(inserted.id.unwrap()); + assert!(result.is_err()); + assert!(!crate::tmux::session_exists(&session_name)); + } + #[test] fn find_or_create_session_drops_stale_db_row_then_inserts() { let tmp = tempdir().unwrap(); diff --git a/src/tui/session_list.rs b/src/tui/session_list.rs index ffc6dfa..be0306b 100644 --- a/src/tui/session_list.rs +++ b/src/tui/session_list.rs @@ -543,13 +543,22 @@ impl SessionListScreen { self.switch_to_session(&key, manager) } - fn switch_to_session(&self, row_key: &str, manager: &Manager) -> ScreenAction { + fn switch_to_session(&mut self, row_key: &str, manager: &Manager) -> ScreenAction { let session = match self.session_for_row_key(row_key) { Some(s) => s, None => return ScreenAction::None, }; if session.managed { if let Some(id) = session.id { + // Dead row: DB has the session but tmux doesn't. Recreate the + // tmux state from the worktree on disk before switching, so + // Enter is never a silent no-op. + if !session.live { + if let Err(e) = manager.resurrect_session(id) { + self.status_message = Some(format!("Could not resurrect: {}", e)); + return ScreenAction::None; + } + } manager.touch_session(id); } } From 0329b8dbcaa04ffa4498cb8183a17d07a8b6246a Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 15:58:12 -0600 Subject: [PATCH 10/12] fix: resurrect_session uses orphan worktree row by (repo_id, branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user hit this on 2026-05-19: pressing Enter on a dead session brought up a tmux session at the repo root rather than the worktree. Root cause: scan_existing inserts worktree rows with session_id=NULL and only links them to sessions via adopt_session_worktrees, which requires a live tmux session to exist at scan time. After reboot, no live sessions → no links → get_worktrees_for_session returned empty → resurrect fell back to repo.path. Now resurrect_session resolves the working directory in four stages: DB row linked by session_id, DB row matched by (repo_id, branch) even if session_id is NULL, git worktree list as the authoritative fallback, and finally repo root. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 53c597a..6738c3e 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -164,9 +164,18 @@ impl Manager { } /// If the tmux session for `session_id` is dead (DB row exists but - /// `tmux has-session` is false), recreate it using the worktree path - /// recorded in the DB (falling back to the repo root for default-branch - /// sessions). No-op if the session is already alive. + /// `tmux has-session` is false), recreate it at the right working + /// directory. No-op if the session is already alive. + /// + /// Working directory is resolved in this order: + /// 1. A DB worktree row linked to this session via `session_id` + /// 2. A DB worktree row matching `(repo_id, base_branch)` even if + /// `session_id` is `NULL` (e.g. scan_existing recorded the worktree + /// on disk but never linked it because no live tmux session existed + /// at scan time — the common case after reboot) + /// 3. Git's authoritative `worktree list` for this repo, matching by + /// branch (covers worktrees created outside trellis) + /// 4. The repo root (only correct for default-branch sessions) pub fn resurrect_session(&self, session_id: i64) -> Result<(), String> { let session = self .get_session_by_id(session_id) @@ -176,12 +185,28 @@ impl Manager { return Ok(()); } + let repo = self + .get_repo_by_id(session.repo_id) + .ok_or_else(|| format!("repo {} not found", session.repo_id))?; + let wt_path = db::get_worktrees_for_session(&self.conn, session_id) .into_iter() .next() .map(|wt| wt.path) - .or_else(|| self.get_repo_by_id(session.repo_id).map(|r| r.path)) - .ok_or_else(|| format!("no worktree or repo for session {}", session_id))?; + .or_else(|| { + db::get_worktrees(&self.conn) + .into_iter() + .find(|wt| wt.repo_id == session.repo_id && wt.branch == session.base_branch) + .map(|wt| wt.path) + }) + .or_else(|| { + git::list_worktrees(&repo.path).ok().and_then(|wts| { + wts.into_iter() + .find(|wt| wt.branch.as_deref() == Some(&session.base_branch)) + .map(|wt| wt.path) + }) + }) + .unwrap_or_else(|| repo.path.clone()); if !Path::new(&wt_path).exists() { return Err(format!("path '{}' no longer exists", wt_path)); @@ -1046,6 +1071,57 @@ mod tests { assert!(crate::tmux::session_exists(&session_name)); } + #[test] + fn resurrect_session_uses_orphan_worktree_by_branch() { + // The scenario the user hit on 2026-05-19: scan_existing recorded a + // worktree on disk but left session_id NULL. After reboot, the DB has + // a session row + an orphan worktree row matching by (repo_id, + // base_branch). resurrect_session must find that worktree and use its + // path, not fall back to the repo root. + let tmp = tempdir().unwrap(); + let (repo_path, _) = make_repo(tmp.path()); + let mgr = make_manager(); + + let repo = mgr.get_or_create_repo(&repo_path); + let branch = unique_branch("orphan"); + let session_name = crate::tmux::sanitize_session_name(&branch); + let _guard = SessionGuard(session_name.clone()); + + // A separate, real on-disk path for the worktree (not the repo root). + let worktree_dir = tmp.path().canonicalize().unwrap().join("wt-orphan"); + std::fs::create_dir_all(&worktree_dir).unwrap(); + let wt_path_str = worktree_dir.to_string_lossy().to_string(); + + let inserted = crate::db::add_session(&mgr.conn, &crate::models::Session { + id: None, + name: session_name.clone(), + repo_id: repo.id.unwrap(), + base_branch: branch.clone(), + created_at: crate::utils::utc_now(), + last_selected_at: None, + }); + crate::db::add_worktree(&mgr.conn, &crate::models::Worktree { + id: None, + repo_id: repo.id.unwrap(), + path: wt_path_str.clone(), + branch: branch.clone(), + session_id: None, // <- orphan: not linked to the session row above + tmux_window: None, + created_at: crate::utils::utc_now(), + }); + + mgr.resurrect_session(inserted.id.unwrap()).unwrap(); + assert!(crate::tmux::session_exists(&session_name)); + + // Inspect tmux to confirm the working dir is the worktree, not the repo root. + let out = std::process::Command::new("tmux") + .args(["display-message", "-p", "-t", &session_name, "#{session_path}"]) + .output() + .unwrap(); + let session_path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + assert_eq!(session_path, wt_path_str); + } + #[test] fn resurrect_session_is_noop_when_already_alive() { let tmp = tempdir().unwrap(); From e405d44c4fb8ec889413eecfb48d2b8e139ab31a Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 17:51:11 -0600 Subject: [PATCH 11/12] fix: apply_layout names the first window via -n, base-index agnostic apply_layout used to call tmux::rename_window(session, 1, "claude") to rename the session's initial window, hardcoding index 1. tmux's default base-index is 0, so this silently failed: the first window kept its default name (zsh, the user's login shell), the send_keys to a target named "claude" had no destination, claude never started in the first window, and the session ended up with [zsh, shell] instead of the intended [claude, shell]. Fix: pass `-n claude` to `tmux new-session` so the first window is named at creation time. send_keys then targets the named window directly, claude actually runs, and the focus call uses the new select_window_by_name helper instead of a hardcoded index. Threads an Option<&str> initial_window through tmux::new_session for all callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 45 ++++++++++++++++++++++++++++++++++----------- src/tmux.rs | 24 ++++++++++++++++++++++-- src/tui/history.rs | 2 +- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 6738c3e..74c21bf 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -20,26 +20,30 @@ const DEFAULT_LAYOUT: &[(&str, Option<&str>)] = &[ ]; /// Create a tmux session and set up windows according to the default layout. +/// +/// The first layout entry becomes the session's initial window — named at +/// `tmux new-session` time via `-n`, so we don't depend on tmux's +/// configurable `base-index`. Subsequent entries become additional windows. pub fn apply_layout(session_name: &str, working_dir: &str) { - // Create the session - if let Err(_) = tmux::new_session(session_name, working_dir) { + let (first_name, first_command) = DEFAULT_LAYOUT[0]; + if let Err(_) = tmux::new_session(session_name, working_dir, Some(first_name)) { return; } + if let Some(cmd) = first_command { + let target = format!("{}:{}", session_name, first_name); + tmux::send_keys(&target, &[cmd, "Enter"]); + } - for (i, &(name, command)) in DEFAULT_LAYOUT.iter().enumerate() { - if i == 0 { - let _ = tmux::rename_window(session_name, 1, name); - } else { - let _ = tmux::new_window(session_name, name, Some(working_dir)); - } + for &(name, command) in &DEFAULT_LAYOUT[1..] { + let _ = tmux::new_window(session_name, name, Some(working_dir)); if let Some(cmd) = command { let target = format!("{}:{}", session_name, name); tmux::send_keys(&target, &[cmd, "Enter"]); } } - // Focus the first window - let _ = tmux::select_window(session_name, 1); + // Focus the first window by name (base-index agnostic). + let _ = tmux::select_window_by_name(session_name, first_name); } /// Find or create the on-disk worktree for (repo, branch). @@ -1071,6 +1075,25 @@ mod tests { assert!(crate::tmux::session_exists(&session_name)); } + #[test] + fn apply_layout_creates_named_windows_independent_of_base_index() { + // The bug we're fixing: hardcoded window index 1 in rename_window + // silently failed on default tmux (base-index 0), leaving the first + // window with tmux's default name ("zsh") instead of "claude". + // The fix passes -n claude at new-session time, so the first window + // is correctly named regardless of base-index. + let tmp = tempdir().unwrap(); + let session_name = format!("trellistest-layout-{}", std::process::id()); + let _guard = SessionGuard(session_name.clone()); + + apply_layout(&session_name, &tmp.path().to_string_lossy()); + assert!(crate::tmux::session_exists(&session_name)); + + let windows = crate::tmux::list_windows(&session_name); + let names: Vec = windows.iter().map(|w| w.name.clone()).collect(); + assert_eq!(names, vec!["claude".to_string(), "shell".to_string()]); + } + #[test] fn resurrect_session_uses_orphan_worktree_by_branch() { // The scenario the user hit on 2026-05-19: scan_existing recorded a @@ -1134,7 +1157,7 @@ mod tests { let _guard = SessionGuard(session_name.clone()); // Pre-create the tmux session so resurrect_session sees a live one. - crate::tmux::new_session(&session_name, &repo_path).unwrap(); + crate::tmux::new_session(&session_name, &repo_path, None).unwrap(); assert!(crate::tmux::session_exists(&session_name)); let inserted = crate::db::add_session(&mgr.conn, &crate::models::Session { diff --git a/src/tmux.rs b/src/tmux.rs index 99fdad7..435e9a6 100644 --- a/src/tmux.rs +++ b/src/tmux.rs @@ -73,11 +73,15 @@ pub fn session_exists(name: &str) -> bool { run(&["has-session", "-t", name]).status.success() } -pub fn new_session(name: &str, start_dir: &str) -> Result<(), TmuxError> { +pub fn new_session(name: &str, start_dir: &str, initial_window: Option<&str>) -> Result<(), TmuxError> { if session_exists(name) { return Err(TmuxError(format!("Session '{}' already exists", name))); } - let output = run(&["new-session", "-d", "-s", name, "-c", start_dir]); + let mut args = vec!["new-session", "-d", "-s", name, "-c", start_dir]; + if let Some(window_name) = initial_window { + args.extend_from_slice(&["-n", window_name]); + } + let output = run(&args); if !output.status.success() { return Err(TmuxError(format!( "Failed to create session '{}': {}", @@ -146,6 +150,22 @@ pub fn select_window(session: &str, index: i64) -> Result<(), TmuxError> { Ok(()) } +/// Select a window by its name rather than index — robust to tmux's +/// configurable `base-index` (default 0, but commonly set to 1). +pub fn select_window_by_name(session: &str, name: &str) -> Result<(), TmuxError> { + let target = format!("{}:{}", session, name); + let output = run(&["select-window", "-t", &target]); + if !output.status.success() { + return Err(TmuxError(format!( + "Failed to select window '{}' in '{}': {}", + name, + session, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) +} + pub fn rename_window(session: &str, index: i64, new_name: &str) -> Result<(), TmuxError> { let target = format!("{}:{}", session, index); let output = run(&["rename-window", "-t", &target, new_name]); diff --git a/src/tui/history.rs b/src/tui/history.rs index 4e3932c..fa4217d 100644 --- a/src/tui/history.rs +++ b/src/tui/history.rs @@ -285,7 +285,7 @@ impl HistoryScreen { } else { // Create new tmux session let session_name = format!("resume-{}", &session_id[..session_id.len().min(8)]); - let _ = tmux::new_session(&session_name, &entry.project); + let _ = tmux::new_session(&session_name, &entry.project, None); tmux::send_keys(&session_name, &[&resume_cmd, "Enter"]); switch::write_switch(&SwitchAction::Session { target: session_name, From 2ff850b422ca5a09f589cf7693371b94322070f5 Mon Sep 17 00:00:00 2001 From: Brendan Whitney Date: Tue, 19 May 2026 18:05:49 -0600 Subject: [PATCH 12/12] feat: support multiple repos directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-string repos_dir config with a list-of-paths config that still lives under the same DB key (newline-separated). Existing single-path configs migrate transparently — they parse as a single-entry list. Manager::repos_dirs() returns Vec. scan_existing iterates over every configured dir to discover repos and worktrees. The new-session wizard's PickRepo step combines repos across all dirs; when two dirs contain a same-named repo, the label disambiguates with the parent dir name (e.g. "myproj (work)" vs "myproj (personal)"). Settings UI adds multi-line support for the repos_dir field: render across multiple rows, Up/Down to move between lines, Alt+Enter to insert a new line, plain Enter still saves. Refactored input_handle_key into input_handle_key_ex with an explicit multiline flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/manager.rs | 90 ++++++++++++++++++++++++++----- src/tui/new_session.rs | 40 +++++++++++--- src/tui/rename.rs | 59 +++++++++++++++++++++ src/tui/settings.rs | 117 +++++++++++++++++++++++++++++++++-------- 4 files changed, 266 insertions(+), 40 deletions(-) diff --git a/src/manager.rs b/src/manager.rs index 74c21bf..20e356c 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -140,10 +140,25 @@ impl Manager { // Properties // ------------------------------------------------------------------ - pub fn repos_dir(&self) -> PathBuf { - db::get_config(&self.conn, "repos_dir") + /// Returns the list of directories under which trellis looks for repos. + /// Stored as a single config string with newline-separated paths so the + /// value migrates seamlessly from the original single-dir setup. Defaults + /// to `[~/dev]` if nothing is configured. + pub fn repos_dirs(&self) -> Vec { + let raw = db::get_config(&self.conn, "repos_dir"); + let parsed: Vec = raw + .as_deref() + .unwrap_or("") + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) .map(PathBuf::from) - .unwrap_or_else(|| dirs::home_dir().unwrap().join("dev")) + .collect(); + if parsed.is_empty() { + vec![dirs::home_dir().unwrap().join("dev")] + } else { + parsed + } } pub fn worktrees_dir(&self) -> PathBuf { @@ -716,9 +731,14 @@ impl Manager { .unwrap_or_default() .to_string_lossy() .to_string(); + // Try this dir first, then fall back to looking up by name + // across known_repos (covers the case where the repo lives in + // a different configured repos_dir). let repo_path_candidate = home_dev.join(&repo_dir_name).to_string_lossy().to_string(); + let already_known = known_repos.contains_key(&repo_path_candidate) + || known_repos.values().any(|r| r.name == repo_dir_name); - if !known_repos.contains_key(&repo_path_candidate) { + if !already_known { let candidate_path = Path::new(&repo_path_candidate); if candidate_path.is_dir() { let default_branch = git::detect_default_branch(&repo_path_candidate) @@ -738,7 +758,17 @@ impl Manager { } } - let repo = &known_repos[&repo_path_candidate]; + // Look up the repo by exact path first, then by name. The + // path lookup covers freshly-adopted repos in this same + // scan_worktrees call; the name lookup covers repos found in + // a different configured repos_dir. + let repo = match known_repos.get(&repo_path_candidate) { + Some(r) => r, + None => match known_repos.values().find(|r| r.name == repo_dir_name) { + Some(r) => r, + None => continue, + }, + }; let branch_name = branch_dir .file_name() .unwrap_or_default() @@ -814,11 +844,10 @@ impl Manager { ); } } else { - // Find the repo for this worktree - let repos_dir = self.repos_dir(); - let repo_path_candidate = - repos_dir.join(&repo_name).to_string_lossy().to_string(); - let repo = match known_repos.get(&repo_path_candidate) { + // Find the repo for this worktree — match by name across + // all known repos rather than constructing a candidate + // path under a single repos_dir. + let repo = match known_repos.values().find(|r| r.name == repo_name) { Some(r) => r, None => continue, }; @@ -876,7 +905,7 @@ impl Manager { /// First-run adoption: scan repos and worktrees dirs, scan live tmux sessions, /// and populate the DB with discovered state. pub fn scan_existing(&self) { - let home_dev = self.repos_dir(); + let repos_dirs = self.repos_dirs(); let worktrees_root = self.worktrees_dir(); let mut known_repos: HashMap = db::get_repos(&self.conn) .into_iter() @@ -887,8 +916,12 @@ impl Manager { .map(|wt| wt.path) .collect(); - self.scan_repos(&home_dev, &mut known_repos); - self.scan_worktrees(&home_dev, &worktrees_root, &mut known_repos, &mut known_worktree_paths); + for dir in &repos_dirs { + self.scan_repos(dir, &mut known_repos); + } + for dir in &repos_dirs { + self.scan_worktrees(dir, &worktrees_root, &mut known_repos, &mut known_worktree_paths); + } self.scan_tmux_sessions(&known_repos); self.adopt_session_worktrees(&worktrees_root, &known_repos); } @@ -1040,6 +1073,37 @@ mod tests { assert!(crate::db::get_session_by_name(&mgr.conn, &session.name).is_some()); } + #[test] + fn repos_dirs_parses_multiline_config() { + let mgr = make_manager(); + crate::db::set_config(&mgr.conn, "repos_dir", "/Users/x/personal\n/Users/x/work"); + let dirs = mgr.repos_dirs(); + assert_eq!( + dirs, + vec![ + PathBuf::from("/Users/x/personal"), + PathBuf::from("/Users/x/work"), + ] + ); + } + + #[test] + fn repos_dirs_ignores_blank_lines_and_trims() { + let mgr = make_manager(); + crate::db::set_config(&mgr.conn, "repos_dir", " /a \n\n /b \n"); + let dirs = mgr.repos_dirs(); + assert_eq!(dirs, vec![PathBuf::from("/a"), PathBuf::from("/b")]); + } + + #[test] + fn repos_dirs_falls_back_to_default_when_blank() { + let mgr = make_manager(); + crate::db::set_config(&mgr.conn, "repos_dir", ""); + let dirs = mgr.repos_dirs(); + assert_eq!(dirs.len(), 1); + assert!(dirs[0].ends_with("dev")); + } + #[test] fn resurrect_session_creates_dead_tmux_session() { let tmp = tempdir().unwrap(); diff --git a/src/tui/new_session.rs b/src/tui/new_session.rs index 2fd4e15..9a52df8 100644 --- a/src/tui/new_session.rs +++ b/src/tui/new_session.rs @@ -63,12 +63,20 @@ pub struct NewSessionScreen { impl NewSessionScreen { pub fn new(manager: &Manager) -> Self { - let repos_dir = manager.repos_dir(); + let repos_dirs = manager.repos_dirs(); let worktrees_dir = manager.worktrees_dir(); - let mut dev_dirs: Vec<(String, String)> = Vec::new(); - if repos_dir.is_dir() { - let mut entries: Vec<_> = std::fs::read_dir(&repos_dir) + // Collect (name, abs_path, parent_dir_label) across every configured dir. + let mut raw: Vec<(String, String, String)> = Vec::new(); + for repos_dir in &repos_dirs { + if !repos_dir.is_dir() { + continue; + } + let parent_label = repos_dir + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let entries: Vec<_> = std::fs::read_dir(repos_dir) .into_iter() .flatten() .filter_map(|e| e.ok()) @@ -78,14 +86,34 @@ impl NewSessionScreen { && !e.file_name().to_string_lossy().starts_with('.') }) .collect(); - entries.sort_by(|a, b| a.file_name().cmp(&b.file_name())); for entry in entries { let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path().to_string_lossy().to_string(); - dev_dirs.push((name, path)); + raw.push((name, path, parent_label.clone())); } } + // Count name occurrences for collision detection. + let mut name_counts: std::collections::HashMap = std::collections::HashMap::new(); + for (name, _, _) in &raw { + *name_counts.entry(name.clone()).or_insert(0) += 1; + } + + // Build display labels: plain name unless that name collides, in + // which case annotate with the parent dir. + let mut dev_dirs: Vec<(String, String)> = raw + .into_iter() + .map(|(name, path, parent)| { + let label = if name_counts.get(&name).copied().unwrap_or(0) > 1 { + format!("{} ({})", name, parent) + } else { + name + }; + (label, path) + }) + .collect(); + dev_dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + let filtered_dirs = dev_dirs.clone(); let mut repo_list_state = ListState::default(); if !filtered_dirs.is_empty() { diff --git a/src/tui/rename.rs b/src/tui/rename.rs index 47fc612..40fd66f 100644 --- a/src/tui/rename.rs +++ b/src/tui/rename.rs @@ -14,6 +14,34 @@ use super::theme; // --------------------------------------------------------------------------- pub fn input_handle_key(input: &mut String, cursor: &mut usize, code: KeyCode, modifiers: KeyModifiers) { + input_handle_key_ex(input, cursor, code, modifiers, false); +} + +/// Like `input_handle_key`, plus optional multi-line support. When +/// `multiline` is true: Enter with any modifier inserts `\n` at the cursor, +/// and Up/Down jump the cursor to the same column on the adjacent line. +/// Plain Enter (no modifiers) is still ignored here — callers handle save. +pub fn input_handle_key_ex( + input: &mut String, + cursor: &mut usize, + code: KeyCode, + modifiers: KeyModifiers, + multiline: bool, +) { + if multiline { + match code { + KeyCode::Enter if !modifiers.is_empty() => { + input.insert(*cursor, '\n'); + *cursor += 1; + return; + } + KeyCode::Up | KeyCode::Down => { + jump_line(input, cursor, code == KeyCode::Up); + return; + } + _ => {} + } + } match code { KeyCode::Char(c) => { // Ctrl+U: clear line @@ -82,6 +110,37 @@ pub fn input_handle_key(input: &mut String, cursor: &mut usize, code: KeyCode, m } } +fn jump_line(input: &str, cursor: &mut usize, up: bool) { + // Find start of current line and column within it. + let cur = *cursor; + let line_start = input[..cur].rfind('\n').map(|i| i + 1).unwrap_or(0); + let col = cur - line_start; + + if up { + // Find start of previous line, if any. + if line_start == 0 { + return; + } + let prev_line_end = line_start - 1; // position of the `\n` + let prev_line_start = input[..prev_line_end].rfind('\n').map(|i| i + 1).unwrap_or(0); + let prev_line_len = prev_line_end - prev_line_start; + *cursor = prev_line_start + col.min(prev_line_len); + } else { + // Find end of current line (next `\n`) and start of next. + let cur_line_end = match input[cur..].find('\n') { + Some(rel) => cur + rel, + None => return, // no next line + }; + let next_line_start = cur_line_end + 1; + let next_line_end = input[next_line_start..] + .find('\n') + .map(|r| next_line_start + r) + .unwrap_or(input.len()); + let next_line_len = next_line_end - next_line_start; + *cursor = next_line_start + col.min(next_line_len); + } +} + /// Render a text input line with cursor highlighting into the given area. pub fn render_input(f: &mut Frame, area: Rect, input: &str, cursor_pos: usize) { let cursor_char_len = if cursor_pos < input.len() { diff --git a/src/tui/settings.rs b/src/tui/settings.rs index 417f492..4ef8260 100644 --- a/src/tui/settings.rs +++ b/src/tui/settings.rs @@ -8,9 +8,10 @@ use crate::manager::Manager; use super::{ScreenAction, ScreenBehavior}; use super::theme; -const CONFIG_KEYS: &[(&str, &str)] = &[ - ("repos_dir", "Repos directory"), - ("worktrees_dir", "Worktrees directory"), +const CONFIG_KEYS: &[(&str, &str, bool)] = &[ + // (key, label, multiline) + ("repos_dir", "Repos directories (one path per line)", true), + ("worktrees_dir", "Worktrees directory", false), ]; struct Field { @@ -18,16 +19,26 @@ struct Field { label: String, value: String, cursor: usize, + multiline: bool, } impl Field { - fn new(key: &str, label: &str, value: &str) -> Self { + fn new(key: &str, label: &str, value: &str, multiline: bool) -> Self { let cursor = value.len(); Self { key: key.to_string(), label: label.to_string(), value: value.to_string(), cursor, + multiline, + } + } + + fn line_count(&self) -> usize { + if self.multiline { + self.value.lines().count().max(1) + } else { + 1 } } } @@ -46,9 +57,9 @@ impl SettingsScreen { let fields = CONFIG_KEYS .iter() - .map(|(key, label)| { + .map(|(key, label, multiline)| { let value = config.get(*key).cloned().unwrap_or_default(); - Field::new(key, label, &value) + Field::new(key, label, &value, *multiline) }) .collect(); @@ -62,12 +73,34 @@ impl SettingsScreen { fn handle_field_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { let field = &mut self.fields[self.focused]; - super::rename::input_handle_key(&mut field.value, &mut field.cursor, code, modifiers); + super::rename::input_handle_key_ex( + &mut field.value, + &mut field.cursor, + code, + modifiers, + field.multiline, + ); + } + + /// Clean a field's value before saving: trim each line, drop blank lines. + /// For single-line fields this collapses to the existing trim. + fn cleaned(field: &Field) -> String { + if field.multiline { + field + .value + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n") + } else { + field.value.trim().to_string() + } } fn save(&mut self, manager: &mut Manager) { for field in &self.fields { - let value = field.value.trim(); + let value = Self::cleaned(field); if value.is_empty() { self.status = format!("{} cannot be empty.", field.label); self.status_is_error = true; @@ -76,7 +109,7 @@ impl SettingsScreen { } } for field in &self.fields { - manager.set_config(&field.key, field.value.trim()); + manager.set_config(&field.key, &Self::cleaned(field)); } self.status = "Saved.".to_string(); self.status_is_error = false; @@ -86,18 +119,24 @@ impl SettingsScreen { impl ScreenBehavior for SettingsScreen { fn render(&self, f: &mut Frame, area: Rect, _manager: &Manager) { let width = 82u16; - let field_rows = self.fields.len() as u16 * 2; - let height = (field_rows + 8).min(area.height.saturating_sub(4)); + // Each field: 1 label + N input lines + 1 gap. Plus 4 lines of chrome + // (hint + gap + status + footer). + let field_rows: u16 = self + .fields + .iter() + .map(|f| 1 + f.line_count() as u16 + 1) + .sum(); + let height = (field_rows + 4).min(area.height.saturating_sub(4)); let inner = super::rename::render_modal_box(f, area, "Settings", width, height); - // Build constraints: hint, gap, then per-field: label + input, status, footer hint + // Build constraints: hint, gap, then per-field: label + input(s) + gap, status, footer hint let mut constraints = vec![ Constraint::Length(1), // hint Constraint::Length(1), // gap ]; - for _ in &self.fields { + for field in &self.fields { constraints.push(Constraint::Length(1)); // label - constraints.push(Constraint::Length(1)); // input + constraints.push(Constraint::Length(field.line_count() as u16)); // input(s) constraints.push(Constraint::Length(1)); // gap between fields } constraints.push(Constraint::Length(1)); // status @@ -128,12 +167,37 @@ impl ScreenBehavior for SettingsScreen { f.render_widget(label, chunks[chunk_idx]); chunk_idx += 1; - // Input with cursor (only show cursor for focused field) - if is_focused { - super::rename::render_input(f, chunks[chunk_idx], &field.value, field.cursor); + // Input with cursor (only show cursor for focused field). + // Multi-line fields split the chunk into one row per line and + // route the cursor render to the line that contains it. + let input_area = chunks[chunk_idx]; + if field.multiline { + let line_count = field.line_count(); + let input_constraints: Vec<_> = (0..line_count).map(|_| Constraint::Length(1)).collect(); + let line_chunks = Layout::vertical(input_constraints).split(input_area); + // Identify cursor line + column. + let (cur_line_idx, cur_col) = line_offset(&field.value, field.cursor); + for (i, line) in field.value.split('\n').enumerate() { + if i >= line_chunks.len() { + break; + } + if is_focused && i == cur_line_idx { + super::rename::render_input(f, line_chunks[i], line, cur_col); + } else { + let style = if is_focused { + Style::default() + } else { + Style::default().fg(theme::TEXT_DIM) + }; + let p = Paragraph::new(Line::from(Span::styled(line.to_string(), style))); + f.render_widget(p, line_chunks[i]); + } + } + } else if is_focused { + super::rename::render_input(f, input_area, &field.value, field.cursor); } else { let line = Line::from(Span::styled(field.value.clone(), Style::default().fg(theme::TEXT_DIM))); - f.render_widget(Paragraph::new(line), chunks[chunk_idx]); + f.render_widget(Paragraph::new(line), input_area); } chunk_idx += 1; chunk_idx += 1; // gap @@ -155,11 +219,13 @@ impl ScreenBehavior for SettingsScreen { // Footer hint let footer = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme::ACCENT)), - Span::styled(" to save ", Style::default().fg(theme::TEXT_DIM)), + Span::styled(" save ", Style::default().fg(theme::TEXT_DIM)), + Span::styled("Alt+Enter", Style::default().fg(theme::ACCENT)), + Span::styled(" new line ", Style::default().fg(theme::TEXT_DIM)), Span::styled("Tab", Style::default().fg(theme::ACCENT)), - Span::styled(" to switch field ", Style::default().fg(theme::TEXT_DIM)), + Span::styled(" next field ", Style::default().fg(theme::TEXT_DIM)), Span::styled("Escape", Style::default().fg(theme::ACCENT)), - Span::styled(" to cancel", Style::default().fg(theme::TEXT_DIM)), + Span::styled(" cancel", Style::default().fg(theme::TEXT_DIM)), ]); let footer_para = Paragraph::new(footer).alignment(Alignment::Center); f.render_widget(footer_para, chunks[chunk_idx]); @@ -204,3 +270,12 @@ impl ScreenBehavior for SettingsScreen { true } } + +/// Given a string with `\n` separators and a byte cursor offset, return +/// (line_index, byte_offset_within_line). +fn line_offset(s: &str, cursor: usize) -> (usize, usize) { + let cursor = cursor.min(s.len()); + let line_start = s[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_idx = s[..cursor].matches('\n').count(); + (line_idx, cursor - line_start) +}