mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
@@ -26,3 +26,4 @@ pub mod ssh_profile;
|
||||
pub mod threads;
|
||||
pub mod update;
|
||||
pub mod window_state;
|
||||
pub mod worktree;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ pub mod ssh_prompt;
|
||||
pub mod tab_sidebar;
|
||||
pub mod tab_strip;
|
||||
pub mod theme;
|
||||
pub mod worktree_prompt;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user