Merge pull request #96 from l0ng-ai/feat/tab-context-menu-worktree

feat(tabs): per-tab context menu with git worktree tabs
This commit is contained in:
l0ng-ai
2026-07-16 10:40:28 +08:00
committed by GitHub
7 changed files with 1099 additions and 5 deletions
+1
View File
@@ -26,3 +26,4 @@ pub mod ssh_profile;
pub mod threads;
pub mod update;
pub mod window_state;
pub mod worktree;
+528
View File
@@ -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 <dir> <args>`, returning trimmed stdout on success and trimmed
/// stderr as the error otherwise.
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
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<ManagedWorktree> {
// 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, <main-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 `<main>/.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<WorktreeDefaults, String> {
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
/// `<main-root>/.tty7/worktrees/<name>`, 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<NewWorktree, String> {
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 `<repo>/.tty7/worktrees/<name>`…
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);
}
}
+197
View File
@@ -282,6 +282,9 @@ pub struct Tty7App {
pub(crate) closed: Vec<SessionTab>,
/// `Some` while a tab label is being renamed inline; `None` otherwise.
pub(crate) renaming: Option<Renaming>,
/// `Some` while the "New Worktree Tab" sheet is open (see
/// `ui::worktree_prompt`); `None` otherwise.
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
/// When `Some`, the active tab renders only this one leaf full-window
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
maximized: Option<Entity<TerminalView>>,
@@ -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<std::path::PathBuf>, cx: &mut Context<Self>) {
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<std::path::PathBuf> = 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<Self>,
) {
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<Self>,
) {
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<std::path::PathBuf> {
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<Self>,
) {
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<Self>,
) {
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))
}
}
+1
View File
@@ -23,3 +23,4 @@ pub mod ssh_prompt;
pub mod tab_sidebar;
pub mod tab_strip;
pub mod theme;
pub mod worktree_prompt;
+8 -1
View File
@@ -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
+131 -4
View File
@@ -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<Self>,
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
+233
View File
@@ -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 (`<root>/<repo-name>`), for the live path
/// preview under the name field.
dir: std::path::PathBuf,
name: Entity<InputState>,
branch: Entity<InputState>,
base: Entity<InputState>,
/// True while `git worktree add` runs, so a second Enter can't double-create.
busy: bool,
_subs: Vec<Subscription>,
}
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<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>,
) -> Option<AnyElement> {
let p = self.worktree_prompt.as_ref()?;
let muted = cx.theme().muted_foreground;
let field = |label: &'static str, input: &Entity<InputState>| {
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(),
)
}
}