diff --git a/src/core/mod.rs b/src/core/mod.rs index bd65e704..073b26a7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -26,3 +26,4 @@ pub mod ssh_profile; pub mod threads; pub mod update; pub mod window_state; +pub mod worktree; diff --git a/src/core/worktree.rs b/src/core/worktree.rs new file mode 100644 index 00000000..d88796d7 --- /dev/null +++ b/src/core/worktree.rs @@ -0,0 +1,528 @@ +//! Git-worktree support for the tab context menu's "New Worktree Tab": derive +//! the repo from a pane's cwd, propose an unused two-word name (editable in the +//! sheet, see `ui::worktree_prompt`), and run `git worktree add -b` under the +//! repository's own `.tty7/worktrees/` (kept out of `git status` by an +//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an +//! isolated checkout on its own branch, physically next to the code it forks. +//! Blocking (spawns `git`); callers run it on the background executor, except +//! [`is_inside_repo`], which is a pure filesystem probe cheap enough for +//! menu-open time. + +use std::path::{Path, PathBuf}; + +/// Word pools for generated branch names (`quiet-otter`). Short, lowercase, +/// branch-safe; two pools of 24 give 576 combinations before the numeric +/// fallback in [`defaults`] kicks in. +const ADJECTIVES: [&str; 24] = [ + "quiet", "amber", "bold", "calm", "cedar", "coral", "dusky", "early", "fable", "gold", "hazel", + "ivory", "jade", "keen", "lunar", "mossy", "noble", "ochre", "pale", "rapid", "sunny", "tidal", + "vivid", "wild", +]; +const NOUNS: [&str; 24] = [ + "otter", "heron", "lynx", "wren", "fox", "elk", "crane", "finch", "gecko", "ibis", "koala", + "llama", "marten", "newt", "osprey", "puffin", "quail", "raven", "seal", "tern", "urchin", + "vole", "walrus", "yak", +]; + +/// A freshly created worktree: where it lives and the branch checked out in it. +#[derive(Debug)] +pub struct NewWorktree { + pub path: PathBuf, + pub branch: String, +} + +/// What to create, as confirmed (or edited) in the sheet: the checkout's +/// directory name under the managed root, the new branch's name, and the +/// commit-ish it starts from. +#[derive(Debug, Clone)] +pub struct WorktreeRequest { + pub name: String, + pub branch: String, + pub base: String, +} + +/// Pre-filled values for the sheet: an unused two-word candidate (offered as +/// both directory name and branch), the branch currently checked out (the +/// natural start point; `"HEAD"` when detached), and the directory the new +/// checkout would land in, for the live path preview. +#[derive(Debug, Clone)] +pub struct WorktreeDefaults { + pub name: String, + pub base: String, + pub dir: PathBuf, +} + +/// Whether `cwd` sits inside a git repository — an upward scan for `.git` +/// (a directory in a primary checkout, a file in a linked worktree or +/// submodule). No subprocess: the tab context menu calls this while opening. +pub fn is_inside_repo(cwd: &Path) -> bool { + cwd.ancestors().any(|d| d.join(".git").exists()) +} + +/// Run `git -C `, returning trimmed stdout on success and trimmed +/// stderr as the error otherwise. +fn git(dir: &Path, args: &[&str]) -> Result { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| format!("failed to run git: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +/// Whether `name` already exists as a local branch in the repo at `repo_root`. +/// A failed probe (`--verify --quiet` exits non-zero) means it's free. +fn branch_exists(repo_root: &Path, name: &str) -> bool { + git( + repo_root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{name}"), + ], + ) + .is_ok() +} + +/// A tiny xorshift over a time+pid seed — enough randomness to spread branch +/// names without pulling in a `rand` dependency. +fn seed() -> u64 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + nanos ^ ((std::process::id() as u64) << 32) | 1 +} + +fn next(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +/// One `adjective-noun` candidate from the pools. +fn candidate(state: &mut u64) -> String { + let a = ADJECTIVES[(next(state) % ADJECTIVES.len() as u64) as usize]; + let n = NOUNS[(next(state) % NOUNS.len() as u64) as usize]; + format!("{a}-{n}") +} + +/// A tty7-managed worktree a closing tab sat in, resolved for the +/// close-time cleanup offer: where it is, its branch, the repository it +/// belongs to, and whether it holds uncommitted changes. +#[derive(Debug, Clone)] +pub struct ManagedWorktree { + pub path: PathBuf, + pub branch: String, + pub main_root: PathBuf, + pub dirty: bool, +} + +/// Resolve `cwd` to the tty7-managed worktree containing it, or `None` when it +/// sits anywhere else. Only checkouts under the main repository's +/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered +/// for removal. Blocking (spawns `git`). +pub fn managed(cwd: &Path) -> Option { + // Canonicalize before the component test: git reports resolved physical + // paths (`/private/var/…` on macOS), while `cwd` may arrive through + // symlinks — a textual comparison would then never match. The `.tty7/ + // worktrees` ancestor check is a cheap pure-filesystem pre-filter, so the + // common case (every ordinary tab close) never spawns git. + let cwd = std::fs::canonicalize(cwd).ok()?; + if !cwd + .ancestors() + .any(|a| a.ends_with(Path::new(".tty7").join("worktrees"))) + { + return None; + } + let path = PathBuf::from(git(&cwd, &["rev-parse", "--show-toplevel"]).ok()?); + let main_root = git( + &path, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(PathBuf::from)? + .parent()? + .to_path_buf(); + // The checkout must really sit in *this* repository's managed directory — + // both paths come from git, so they compare on equal (physical) footing. + if !path.starts_with(main_root.join(".tty7").join("worktrees")) { + return None; + } + let branch = git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let dirty = !git(&path, &["status", "--porcelain"]).ok()?.is_empty(); + Some(ManagedWorktree { + path, + branch, + main_root, + dirty, + }) +} + +/// Whether any of `cwds` still lives inside the worktree at `path` — removing +/// the checkout then would pull the directory out from under a live shell (new +/// tabs inherit the current cwd, so two tabs sharing one worktree is common). +/// Both sides are canonicalized before the ancestor test — cwds may arrive +/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix +/// that git-reported paths lack, so comparing raw would never match. A +/// vanished path never counts as occupying. +pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool { + let Ok(path) = std::fs::canonicalize(path) else { + return false; + }; + cwds.iter() + .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(&path))) +} + +/// Remove a managed worktree (`git worktree remove`, `--force` to discard +/// uncommitted changes), then best-effort delete its branch with `-d` — so a +/// branch carrying unmerged commits survives the cleanup. +pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> { + let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; + let mut args = vec!["worktree", "remove"]; + if force { + args.push("--force"); + } + args.push(path); + git(&wt.main_root, &args)?; + let _ = git(&wt.main_root, &["branch", "-d", &wt.branch]); + Ok(()) +} + +/// Locate the repository containing `cwd` and the directory its managed +/// worktrees live in: `(repo_root, /.tty7/worktrees)`. Anchored on +/// the *main* repository even when `cwd` is itself inside a linked worktree +/// (a worktree tab spawning another worktree), so checkouts never nest. The +/// common git-dir is `
/.git`, whose parent is the main root. +fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> { + let repo_root = git(cwd, &["rev-parse", "--show-toplevel"]) + .map_err(|_| "not inside a git repository".to_string())?; + let repo_root = PathBuf::from(repo_root); + let main_root = git( + cwd, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(PathBuf::from) + .and_then(|d| d.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| repo_root.clone()); + let dir = main_root.join(".tty7").join("worktrees"); + Ok((repo_root, dir)) +} + +/// Compute the sheet's pre-filled values: a generated `adjective-noun` name +/// (retried until both the branch and the directory are unused, with a +/// numeric-suffix fallback so a saturated pool still terminates) and the +/// currently checked-out branch as the start point. +pub fn defaults(cwd: &Path) -> Result { + let (repo_root, dir) = repo_dir(cwd)?; + + let mut state = seed(); + let mut name = candidate(&mut state); + for attempt in 0..64 { + // Both the ref and the directory must be free — a stale directory from a + // hand-removed worktree would make `git worktree add` fail either way. + if !branch_exists(&repo_root, &name) && !dir.join(&name).exists() { + break; + } + name = if attempt < 32 { + candidate(&mut state) + } else { + format!("{}-{}", candidate(&mut state), next(&mut state) % 1000) + }; + } + + // Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD. + let base = git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) + .unwrap_or_else(|_| "HEAD".to_string()); + Ok(WorktreeDefaults { name, base, dir }) +} + +/// Create the requested worktree for the repository containing `cwd`, at +/// `/.tty7/worktrees/`, on new branch `branch` starting from +/// `base`. Branch and base validity is git's to judge; the directory name only +/// has to stay a single path component so it can't escape the managed root. +pub fn create(cwd: &Path, req: &WorktreeRequest) -> Result { + if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) + { + return Err(format!("invalid worktree name \"{}\"", req.name)); + } + let (repo_root, dir) = repo_dir(cwd)?; + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + // A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included, + // the ignore file itself too) out of the repository's `git status`, without + // ever editing the repo's own .gitignore. Best-effort: a failed write only + // costs status noise, never the worktree. + let ignore = dir + .parent() + .expect(".tty7/worktrees has a parent") + .join(".gitignore"); + if !ignore.exists() { + let _ = std::fs::write(&ignore, "*\n"); + } + + let path = dir.join(&req.name); + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + git( + &repo_root, + &[ + "worktree", + "add", + "-b", + &req.branch, + path.to_str().ok_or("worktree path is not valid UTF-8")?, + &req.base, + ], + )?; + Ok(NewWorktree { + path, + branch: req.branch.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh scratch dir under the system temp location, unique per test — + /// the same std-only pattern the config tests use (no tempfile dep). + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Strip Windows' `\\?\` verbatim prefix so `std::fs::canonicalize` output + /// compares equal to the plain absolute paths git reports; a no-op on Unix. + fn plain(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()) + } + + fn sh(dir: &Path, args: &[&str]) { + assert!( + std::process::Command::new(args[0]) + .args(&args[1..]) + .current_dir(dir) + .output() + .unwrap() + .status + .success(), + "command failed: {args:?}" + ); + } + + /// A throwaway repo with one commit, so `worktree add` has a HEAD to branch + /// from. + fn temp_repo(name: &str) -> PathBuf { + let dir = scratch(name); + sh(&dir, &["git", "init", "-q"]); + sh(&dir, &["git", "config", "user.email", "t@t"]); + sh(&dir, &["git", "config", "user.name", "t"]); + std::fs::write(dir.join("a.txt"), "a").unwrap(); + sh(&dir, &["git", "add", "."]); + sh(&dir, &["git", "commit", "-q", "-m", "init"]); + dir + } + + /// The simplest sensible request: directory and branch share `name`, + /// starting from HEAD — what the sheet submits when nothing is edited. + fn req(name: &str) -> WorktreeRequest { + WorktreeRequest { + name: name.into(), + branch: name.into(), + base: "HEAD".into(), + } + } + + #[test] + fn candidate_is_two_pool_words() { + let mut state = seed(); + let name = candidate(&mut state); + let (a, n) = name.split_once('-').unwrap(); + assert!(ADJECTIVES.contains(&a)); + assert!(NOUNS.contains(&n)); + } + + #[test] + fn is_inside_repo_scans_upward_for_dot_git() { + let repo = temp_repo("probe"); + let sub = repo.join("deep/nested"); + std::fs::create_dir_all(&sub).unwrap(); + assert!(is_inside_repo(&sub)); + let plain = scratch("probe-plain"); + assert!(!is_inside_repo(&plain)); + let _ = std::fs::remove_dir_all(&repo); + let _ = std::fs::remove_dir_all(&plain); + } + + #[test] + fn defaults_proposes_fresh_name_current_branch_and_target_dir() { + let repo = temp_repo("dflt"); + let d = defaults(&repo).unwrap(); + assert!(!branch_exists(&repo, &d.name)); + let head = git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(d.base, head); + // The target dir is the repo's own `.tty7/worktrees` (git reports the + // canonical root: /var → /private/var on macOS). + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees")); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_makes_worktree_on_new_branch_inside_the_repo() { + let repo = temp_repo("repo"); + let wt = create(&repo, &req("quiet-otter")).unwrap(); + assert!(wt.path.join("a.txt").exists()); + assert!(branch_exists(&repo, &wt.branch)); + // The worktree lands under `/.tty7/worktrees/`… + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); + // …on the new branch… + let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, wt.branch); + // …and the auto-written `.tty7/.gitignore` keeps the main repo's + // status clean despite the checkout living inside it. + assert_eq!( + std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(), + "*\n" + ); + assert_eq!(git(&repo, &["status", "--porcelain"]).unwrap(), ""); + // A second request colliding on the directory is refused up front. + assert!( + create(&repo, &req("quiet-otter")) + .unwrap_err() + .contains("already exists") + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_honors_custom_branch_and_base() { + let repo = temp_repo("base"); + // A `stable` branch one commit behind the default branch's HEAD. + sh(&repo, &["git", "branch", "stable"]); + std::fs::write(repo.join("b.txt"), "b").unwrap(); + sh(&repo, &["git", "add", "."]); + sh(&repo, &["git", "commit", "-q", "-m", "second"]); + let wt = create( + &repo, + &WorktreeRequest { + name: "my-dir".into(), + branch: "feat/my-branch".into(), + base: "stable".into(), + }, + ) + .unwrap(); + // Directory and branch names diverge as requested… + assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); + let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, "feat/my-branch"); + // …and the checkout starts from `stable` (no b.txt yet). + assert!(wt.path.join("a.txt").exists()); + assert!(!wt.path.join("b.txt").exists()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_rejects_escaping_names() { + let repo = temp_repo("names"); + for bad in ["", ".", "..", "a/b", "a\\b"] { + let mut r = req("x"); + r.name = bad.into(); + assert!( + create(&repo, &r) + .unwrap_err() + .contains("invalid worktree name"), + "{bad:?} should be rejected" + ); + } + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_from_a_linked_worktree_lands_in_the_main_repo() { + let repo = temp_repo("nest"); + let first = create(&repo, &req("first-wt")).unwrap(); + // Spawn the second worktree from *inside* the first: it must land in + // the main repo's `.tty7/worktrees`, not nest inside the first checkout. + let second = create(&first.path, &req("second-wt")).unwrap(); + assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn managed_resolves_managed_checkouts_and_remove_cleans_up() { + let repo = temp_repo("mg"); + let wt = create(&repo, &req("mg-wt")).unwrap(); + // The repo root itself is never "managed"… + assert!(managed(&repo).is_none()); + // …nor is a linked worktree the user made outside `.tty7/worktrees`. + let own = scratch("mg-own"); + let _ = std::fs::remove_dir_all(&own); + sh( + &repo, + &[ + "git", + "worktree", + "add", + "-b", + "own-branch", + own.to_str().unwrap(), + ], + ); + assert!(managed(&own).is_none()); + // Any path inside the managed checkout resolves to it, initially clean. + let sub = wt.path.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + let m = managed(&sub).unwrap(); + assert_eq!(m.branch, wt.branch); + assert_eq!(m.path, wt.path); + assert!(!m.dirty); + // Uncommitted changes flip `dirty` and block a plain remove; --force + // discards them. The branch (no unique commits) is deleted with it. + std::fs::write(wt.path.join("b.txt"), "b").unwrap(); + let m = managed(&wt.path).unwrap(); + assert!(m.dirty); + assert!(remove(&m, false).is_err()); + remove(&m, true).unwrap(); + assert!(!wt.path.exists()); + assert!(!branch_exists(&repo, &wt.branch)); + let _ = std::fs::remove_dir_all(&repo); + let _ = std::fs::remove_dir_all(&own); + } + + #[test] + fn occupied_detects_live_cwds_inside_the_worktree() { + let repo = temp_repo("occ"); + let wt = create(&repo, &req("occ-wt")).unwrap(); + let inside = wt.path.join("deep"); + std::fs::create_dir_all(&inside).unwrap(); + assert!(occupied(&wt.path, &[repo.clone(), inside])); + // Cwds elsewhere in the repo don't count… + assert!(!occupied(&wt.path, &[repo.clone()])); + // …and neither does a cwd that no longer exists. + assert!(!occupied(&wt.path, &[wt.path.join("gone")])); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_outside_a_repo_errors() { + let plain = scratch("plain"); + let err = create(&plain, &req("x")).unwrap_err(); + assert_eq!(err, "not inside a git repository"); + assert_eq!(defaults(&plain).unwrap_err(), "not inside a git repository"); + let _ = std::fs::remove_dir_all(&plain); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 9e686d93..4c2ab6b3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -282,6 +282,9 @@ pub struct Tty7App { pub(crate) closed: Vec, /// `Some` while a tab label is being renamed inline; `None` otherwise. pub(crate) renaming: Option, + /// `Some` while the "New Worktree Tab" sheet is open (see + /// `ui::worktree_prompt`); `None` otherwise. + pub(crate) worktree_prompt: Option, /// When `Some`, the active tab renders only this one leaf full-window /// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change. maximized: Option>, @@ -529,6 +532,7 @@ impl Tty7App { palette_sub: None, closed: Vec::new(), renaming: None, + worktree_prompt: None, maximized: None, mod_hint_badges: false, mod_hint_gen: 0, @@ -1922,6 +1926,10 @@ impl Tty7App { // A rename in progress stores a fixed tab index; removing a tab shifts // indices and would let the pending edit commit onto the wrong tab. Drop it. self.renaming = None; + // Capture the tab's cwd *before* its panes are killed (the daemon can't + // report it afterwards): if it sat in a tty7-managed worktree, the + // cleanup offer below needs it. + let worktree_cwd = self.tab_cwd(index, window, cx); // Snapshot the tab (layout + each pane's current cwd + name) onto the // recently-closed stack so Cmd+Shift+T can bring it back. let snapshot = tab_to_session(&self.tabs[index], cx); @@ -1950,6 +1958,187 @@ impl Tty7App { self.focus_active(window, cx); self.save_session(cx); cx.notify(); + // The tab is gone; if it lived in a tty7-managed worktree, offer to + // clean the checkout up rather than letting them pile up silently. + self.offer_worktree_cleanup(worktree_cwd, cx); + } + + /// After closing a tab that sat in a tty7-managed worktree (see + /// [`crate::core::worktree::managed`]), offer to remove the checkout: a + /// clean worktree gets a plain keep/remove prompt; one with uncommitted + /// changes defaults to keeping and makes discarding explicit. Removal also + /// deletes the branch when it carries no unmerged commits (`branch -d`). + /// No offer while any surviving pane still has its cwd inside the checkout + /// (new tabs inherit the current cwd, so shared worktrees are common) — + /// removal would yank the directory out from under a live shell. + /// Detection, the dirty probe, and removal all run off the UI thread. + fn offer_worktree_cleanup(&mut self, cwd: Option, cx: &mut Context) { + let Some(cwd) = cwd else { return }; + // Every leaf of every surviving tab, not just focused panes — a shell + // tucked away in a split occupies the worktree all the same. + let open_cwds: Vec = self + .tabs + .iter() + .flat_map(|tab| tab.pane.leaves()) + .filter_map(|leaf| leaf.read(cx).cwd()) + .collect(); + cx.spawn(async move |this, cx| { + let Some(wt) = cx + .background_spawn(async move { + crate::core::worktree::managed(&cwd) + .filter(|wt| !crate::core::worktree::occupied(&wt.path, &open_cwds)) + }) + .await + else { + return; + }; + let detail = if wt.dirty { + format!( + "The closed tab's worktree at {} has uncommitted changes.", + wt.path.display() + ) + } else { + format!( + "The closed tab's worktree at {} is clean.", + wt.path.display() + ) + }; + let title = format!("Remove worktree \"{}\"?", wt.branch); + let level = if wt.dirty { + PromptLevel::Warning + } else { + PromptLevel::Info + }; + let remove_label = if wt.dirty { + "Discard Changes & Remove" + } else { + "Remove Worktree" + }; + let Ok(answer) = this.update_in(cx, |_, window, cx| { + window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx) + }) else { + return; + }; + if !matches!(answer.await, Ok(1)) { + return; + } + let force = wt.dirty; + let branch = wt.branch.clone(); + let result = cx + .background_spawn(async move { crate::core::worktree::remove(&wt, force) }) + .await; + let _ = this.update_in(cx, |_, window, cx| match result { + Ok(()) => window.push_notification(format!("Removed worktree \"{branch}\""), cx), + Err(e) => window.push_notification(format!("Worktree removal failed: {e}"), cx), + }); + }) + .detach(); + } + + /// Close every tab except `index` ("Close Other Tabs"). Iterates from the + /// end so removals never shift an index still to visit. Tabs holding a live + /// warn-on-close SSH session are skipped outright — the per-tab confirm + /// sheet is keyed by index, which a bulk close would immediately + /// invalidate — so they simply survive the sweep. + pub(crate) fn close_other_tabs( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if index >= self.tabs.len() { + return; + } + for i in (0..self.tabs.len()).rev() { + if i == index || self.tab_has_warn_ssh(i, cx) { + continue; + } + self.close_tab(i, window, cx); + } + } + + /// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs + /// Below" in the sidebar). Same end-first iteration and warn-SSH skip as + /// [`close_other_tabs`](Self::close_other_tabs). + pub(crate) fn close_tabs_right_of( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + for i in ((index + 1)..self.tabs.len()).rev() { + if self.tab_has_warn_ssh(i, cx) { + continue; + } + self.close_tab(i, window, cx); + } + } + + /// The cwd of the tab's label-driving terminal (focused leaf, else first) — + /// what the tab context menu's "Copy Working Directory" copies and "New + /// Worktree Tab" derives the repo from. + pub(crate) fn tab_cwd( + &self, + index: usize, + window: &Window, + cx: &App, + ) -> Option { + self.tabs + .get(index)? + .pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).cwd()) + } + + /// "New Worktree Tab": probe the repository containing the tab's cwd for + /// defaults (a fresh generated name, the current branch as start point) on + /// the background executor, then open the confirmation sheet + /// (`ui::worktree_prompt`) where name/branch/base can be edited before + /// anything is created. Failure to probe lands as a notification. + pub(crate) fn new_worktree_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(cwd) = self.tab_cwd(index, window, cx) else { + window.push_notification("This tab has no working directory yet", cx); + return; + }; + cx.spawn(async move |this, cx| { + let probe_cwd = cwd.clone(); + let result = cx + .background_spawn(async move { crate::core::worktree::defaults(&probe_cwd) }) + .await; + let _ = this.update_in(cx, |this, window, cx| match result { + Ok(defaults) => this.open_worktree_prompt(cwd, defaults, window, cx), + Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx), + }); + }) + .detach(); + } + + /// Open the tab for a just-created worktree: a default-shell terminal in + /// the worktree directory, with the tab pre-named after its branch so a + /// strip of parallel worktrees stays tellable-apart. Mirrors + /// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point). + pub(crate) fn open_worktree_tab( + &mut self, + wt: crate::core::worktree::NewWorktree, + window: &mut Window, + cx: &mut Context, + ) { + let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx); + self.remember_active_pane(window, cx); + self.maximized = None; + let insert_at = self.new_tab_insert_at(cx); + let mut tab = Tab::new(Pane::leaf(view)); + tab.name = Some(wt.branch); + self.tabs.insert(insert_at, tab); + self.active = insert_at; + self.focus_active(window, cx); + self.save_session(cx); + cx.notify(); } /// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop). @@ -3449,6 +3638,10 @@ impl Render for Tty7App { .when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| { this.child(el) }) + // "New Worktree Tab" confirmation sheet (from the tab context menu). + .when_some(self.render_worktree_prompt_overlay(cx), |this, el| { + this.child(el) + }) // Working-tree diff overlay (clicked from a sidebar git line) — // last child, so it paints over every pane-contextual element // above. It covers only the body: the sidebar stays interactive. @@ -3642,6 +3835,10 @@ impl Render for Tty7App { .when_some(settings_overlay, |this, overlay| this.child(overlay)) // Command palette overlay, layered above everything when open. .when_some(self.palette.clone(), |this, palette| this.child(palette)) + // Toast layer for `window.push_notification` (worktree/SSH errors). + // gpui-component's Root only *stores* the list; the root view must + // render the layer — without this child every toast was invisible. + .children(gpui_component::Root::render_notification_layer(window, cx)) } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 72856df4..ec8f20b0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,3 +23,4 @@ pub mod ssh_prompt; pub mod tab_sidebar; pub mod tab_strip; pub mod theme; +pub mod worktree_prompt; diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 319d616c..f8ebb1ce 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -16,6 +16,7 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use std::cell::Cell; use std::rc::Rc; @@ -323,7 +324,13 @@ impl Tty7App { .into_any_element() }); - list = list.child(row); + // Per-tab right-click menu, shared with the strip's chips; + // `below_wording` flips the trailing close to "Close Tabs Below" + // to match the vertical layout. + let menu_app = cx.entity().downgrade(); + list = list.child(row.context_menu(move |menu, window, cx| { + Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx) + })); } // Top control bar: a right-aligned "+" new-tab button (the same shell diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index c8ff4a35..7f29370b 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -5,12 +5,12 @@ //! orchestration rather than chrome rendering. use gpui::{ - App, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, prelude::*, - px, + App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; -use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex}; use crate::core::actions::{OpenSettings, TogglePalette}; @@ -377,6 +377,128 @@ impl Tty7App { }) } + /// Build the per-tab right-click menu, shared by the strip's chips and the + /// sidebar's rows (which passes `below_wording` so the trailing close reads + /// "Close Tabs Below" in the vertical list). Live state — tab count, the + /// tab's cwd — is read at open time through the weak `app` handle, so the + /// render loop never pays a per-frame cwd syscall and the enablement can't + /// go stale between render and click. + pub(crate) fn tab_context_menu( + menu: PopupMenu, + index: usize, + below_wording: bool, + app: &gpui::WeakEntity, + window: &Window, + cx: &App, + ) -> PopupMenu { + let Some(entity) = app.upgrade() else { + return menu; + }; + let this = entity.read(cx); + let tab_count = this.tabs.len(); + let cwd = this.tab_cwd(index, window, cx); + let has_cwd = cwd.is_some(); + let mut menu = menu.min_w(px(200.)); + + // Rename — the same inline edit a label double-click starts, given a + // discoverable entry point. + menu = menu.item(PopupMenuItem::new("Rename Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.start_rename(index, window, cx)); + } + })); + + // Worktree: an isolated checkout of this tab's repo on a fresh branch, + // opened as a new tab — parallel-agent fuel. Only offered when the + // tab's cwd actually sits in a git repository (a filesystem-only + // probe, cheap enough at open time); outside one the entry would be + // pure noise. + let in_repo = cwd + .as_deref() + .is_some_and(crate::core::worktree::is_inside_repo); + if in_repo { + menu = menu + .separator() + .item(PopupMenuItem::new("New Worktree Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.new_worktree_tab(index, window, cx)); + } + })); + } + + // Splits act on the right-clicked tab: activate it first (a no-op when + // it already is), then split its focused pane — one code path with the + // keyboard actions. + menu = menu + .separator() + .item(PopupMenuItem::new("Split Right").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.activate(index, window, cx); + this.split(Axis::Horizontal, window, cx); + }); + } + })) + .item(PopupMenuItem::new("Split Down").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.activate(index, window, cx); + this.split(Axis::Vertical, window, cx); + }); + } + })); + + menu = menu.separator().item( + PopupMenuItem::new("Copy Working Directory") + .disabled(!has_cwd) + .on_click(move |_, _window, cx| { + if let Some(cwd) = cwd.as_ref() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + cwd.display().to_string(), + )); + } + }), + ); + + menu.separator() + .item(PopupMenuItem::new("Close Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.close_tab(index, window, cx)); + } + })) + .item( + PopupMenuItem::new("Close Other Tabs") + .disabled(tab_count <= 1) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.close_other_tabs(index, window, cx)); + } + }), + ) + .item( + PopupMenuItem::new(if below_wording { + "Close Tabs Below" + } else { + "Close Tabs to the Right" + }) + .disabled(index + 1 >= tab_count) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.close_tabs_right_of(index, window, cx)); + } + }), + ) + } + /// The horizontal tab strip rendered into the title bar. `show_chips` draws /// the per-tab chip row; passing `false` (the vertical-sidebar mode, where /// the sidebar owns the tab list) keeps only the "+" and "⋯" chrome so the @@ -639,7 +761,12 @@ impl Tty7App { .into_any_element() }); - chips = chips.child(chip); + // Per-tab right-click menu (rename / worktree / split / copy cwd / + // close group) — the same builder the sidebar rows use. + let menu_app = cx.entity().downgrade(); + chips = chips.child(chip.context_menu(move |menu, window, cx| { + Self::tab_context_menu(menu, i, false, &menu_app, window, cx) + })); } // "+" new-tab button — click opens the shell picker. The default shell diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs new file mode 100644 index 00000000..f9f3f5b0 --- /dev/null +++ b/src/ui/worktree_prompt.rs @@ -0,0 +1,233 @@ +//! The "New Worktree Tab" sheet: confirms (or edits) the generated worktree +//! name, the new branch, and the branch it starts from before anything touches +//! git. Opened from the tab context menu (`tab_strip::tab_context_menu`); the +//! defaults are probed off the UI thread in `Tty7App::new_worktree_tab`. + +use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _, h_flex, v_flex, +}; + +use crate::core::worktree::{WorktreeDefaults, WorktreeRequest}; +use crate::ui::app::Tty7App; + +/// State for the open sheet. Held on [`Tty7App`] so it survives re-renders and +/// tab switches; there is at most one, app-wide. +pub(crate) struct WorktreePrompt { + /// The directory the repo was derived from (the right-clicked tab's cwd) — + /// what the eventual `git worktree add` resolves the repository through. + cwd: std::path::PathBuf, + /// Where the checkout will land (`/`), for the live path + /// preview under the name field. + dir: std::path::PathBuf, + name: Entity, + branch: Entity, + base: Entity, + /// True while `git worktree add` runs, so a second Enter can't double-create. + busy: bool, + _subs: Vec, +} + +impl Tty7App { + /// Open the sheet with probed defaults: the generated candidate fills both + /// the name and the branch (edit either independently), the current branch + /// fills the start point. Focus lands on the name field; Enter anywhere + /// submits, Esc cancels. + pub(crate) fn open_worktree_prompt( + &mut self, + cwd: std::path::PathBuf, + defaults: WorktreeDefaults, + window: &mut Window, + cx: &mut Context, + ) { + let name = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name.clone())); + let branch = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name)); + let base = cx.new(|cx| InputState::new(window, cx).default_value(defaults.base)); + name.update(cx, |state, cx| state.focus(window, cx)); + let subs = [&name, &branch, &base] + .into_iter() + .map(|input| { + cx.subscribe_in(input, window, |this, _, ev: &InputEvent, window, cx| { + match ev { + InputEvent::PressEnter { .. } => this.submit_worktree_prompt(window, cx), + // Keep the path preview tracking the name field. + InputEvent::Change => cx.notify(), + _ => {} + } + }) + }) + .collect(); + self.worktree_prompt = Some(WorktreePrompt { + cwd, + dir: defaults.dir, + name, + branch, + base, + busy: false, + _subs: subs, + }); + cx.notify(); + } + + fn cancel_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) { + if self.worktree_prompt.take().is_some() { + self.focus_active(window, cx); + cx.notify(); + } + } + + /// Validate the fields and run the creation off the UI thread. Blanking + /// one of name/branch falls back to the other (one name is enough); a + /// blank start point means the repo's HEAD. On failure the sheet stays up + /// with the values intact, so a typo'd branch is a fix away. + fn submit_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let Some(p) = self.worktree_prompt.as_ref() else { + return; + }; + if p.busy { + return; + } + let name = p.name.read(cx).value().trim().to_string(); + let branch = p.branch.read(cx).value().trim().to_string(); + let base = p.base.read(cx).value().trim().to_string(); + let (name, branch) = match (name.is_empty(), branch.is_empty()) { + (true, true) => { + window.push_notification("The worktree needs a name", cx); + return; + } + (true, false) => (branch.clone(), branch), + (false, true) => (name.clone(), name), + (false, false) => (name, branch), + }; + let req = WorktreeRequest { + name, + branch, + base: if base.is_empty() { + "HEAD".to_string() + } else { + base + }, + }; + let p = self.worktree_prompt.as_mut().expect("checked above"); + p.busy = true; + let cwd = p.cwd.clone(); + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { crate::core::worktree::create(&cwd, &req) }) + .await; + let _ = this.update_in(cx, |this, window, cx| match result { + Ok(wt) => { + this.worktree_prompt = None; + this.open_worktree_tab(wt, window, cx); + } + Err(e) => { + if let Some(p) = this.worktree_prompt.as_mut() { + p.busy = false; + } + window.push_notification(format!("New worktree failed: {e}"), cx); + cx.notify(); + } + }); + }) + .detach(); + } + + /// The sheet itself, floated near the top of the terminal area like the + /// SSH auth sheet. `None` while no prompt is open. + pub(crate) fn render_worktree_prompt_overlay( + &self, + cx: &mut Context, + ) -> Option { + let p = self.worktree_prompt.as_ref()?; + let muted = cx.theme().muted_foreground; + let field = |label: &'static str, input: &Entity| { + v_flex() + .gap_1() + .child(div().text_xs().text_color(muted).child(label)) + .child(Input::new(input).small()) + }; + // Live preview of where the checkout will land, following the name field. + let name_now = p.name.read(cx).value().trim().to_string(); + let preview = p + .dir + .join(if name_now.is_empty() { + "…" + } else { + &name_now + }) + .display() + .to_string(); + + let card = v_flex() + .occlude() + .w(px(420.)) + .gap_3() + .p_4() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .shadow_lg() + // Esc cancels from anywhere in the sheet (Enter submits via the + // inputs' PressEnter events). + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + if ev.keystroke.key == "escape" { + this.cancel_worktree_prompt(window, cx); + } + })) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("New Worktree Tab"), + ) + .child(field("Worktree Name", &p.name)) + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(muted) + .child(preview), + ) + .child(field("New Branch", &p.branch)) + .child(field("Start From", &p.base)) + .child( + h_flex() + .gap_2() + .child( + Button::new("worktree-create") + .label(if p.busy { "Creating…" } else { "Create" }) + .small() + .primary() + .disabled(p.busy) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_worktree_prompt(window, cx) + })), + ) + .child( + Button::new("worktree-cancel") + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_worktree_prompt(window, cx) + })), + ), + ); + + Some( + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_start() + .pt(px(48.)) + .child(card) + .into_any_element(), + ) + } +}