diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c2158509..3dcca1f656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* worktrees popup: list worktrees and switch between them ([Shift+W]) +* worktrees popup: create a new worktree with a new branch ([c] from the worktrees list) +* worktrees popup: remove a worktree ([Shift+D], refused if it is dirty or locked) and lock/unlock it ([l]) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting @@ -17,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * support rewording non-HEAD commits when `commit.gpgsign` is enabled (gpg format only) [[@guerinoni](https://github.com/guerinoni)] ([#2959](https://github.com/gitui-org/gitui/pull/2959)) ### Fixes +* first keystroke after switching to a submodule or worktree was dropped * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) * when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748)) * index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953)) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..a8e28a2ada --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# gitui — working notes for Claude + +gitui is a Rust TUI git client. Cargo **workspace**: root crate `gitui` (the `src/` TUI binary) + +members `asyncgit` (git logic over `git2`/`gix`), `filetreelist`, `git2-hooks`, `git2-testing`, +`scopetime`. MSRV **1.88** (`Cargo.toml` `rust-version`, `.clippy.toml` `msrv`, CI matrix — keep +all three in sync if ever bumped). + +## Feedback loop (run these — the reviewer here does not read Rust, so tooling IS the review) + +- **Fast inner loop while editing:** `cargo check -p asyncgit` (backend) / `cargo check` (whole + workspace). Never iterate with `--release` — LTO + `opt-level="z"` makes it very slow. +- **Format before every check:** `cargo fmt`. Formatting is unusual (`rustfmt.toml`: + `max_width = 70`, `hard_tabs = true`) — hand-written code almost never matches, and + `cargo fmt -- --check` is a CI gate. +- **Full gate before declaring done / committing:** `make check` + = `fmt` (`cargo fmt -- --check`) + `clippy` (`cargo clippy --workspace --all-features`) + + `test` (`cargo nextest run --workspace`) + `sort` (`tombi format --check`) + `deny` + (`cargo deny check`). CI calls the same `make` targets, so green locally ≈ green CI. +- **Subset fallback** if a tool is missing: `cargo fmt -- --check` + + `cargo clippy --workspace --all-features` + `cargo test --workspace` (plain `cargo test` works; + it just also runs doctests that nextest skips). +- **Run a single crate's tests:** `cargo test -p asyncgit `. + +Required tooling: `cargo-nextest`, `cargo-deny`, `tombi` (installed via mise), plus `python`, +`gpgsm`/`gpg`, `openssl`, `perl`, a C compiler (vendored OpenSSL + bundled libgit2 build from +source on first compile — the first build is slow, later ones are cached). + +## Hard rules clippy enforces (these fail the build, not just warn) + +Crate roots carry `#![deny(clippy::all, perf, nursery, pedantic, cargo)]` plus bans below. +- **No `unwrap` / `expect` / `panic!` in production code** — use `?`, `ok_or(...)`, + `unwrap_or_default()`, `map_or(...)`. (`unwrap`/`expect` are fine in `#[cfg(test)]` code.) +- `asyncgit` has `#![forbid(missing_docs)]` — **every `pub` item needs a `///` doc comment**. +- `#![forbid(unsafe_code)]` in the binary crate. + +## Other gotchas + +- **UI output is snapshot-tested with `insta`** (`src/snapshots/*.snap`). If you change rendered + output, tests fail with a diff — review and accept via `cargo insta review` / `cargo insta + accept`, then commit the updated `.snap` files. (Install once: `cargo install cargo-insta`.) +- **CHANGELOG.md** must gain an entry under `## Unreleased` (`Added`/`Changed`/`Fixes`) or the CI + `log-test` job fails. Format: Keep-a-Changelog, with issue/PR links. +- Only the `stable` and `1.88` CI rows gate (nightly is `continue-on-error`). `cargo +nightly + udeps` runs in CI but not in `make check`, so **don't add unused dependencies**. +- Production code never shells out to the `git` binary; use `git2`/`gix`. The only + `Command::new("git")` calls live in test helpers (`debug_cmd_print` in `asyncgit/src/sync/mod.rs`). + +## Where things live (patterns to copy) + +- Sync git ops: `asyncgit/src/sync/.rs`, re-exported from `asyncgit/src/sync/mod.rs`. Each + op: `pub fn f(repo_path: &RepoPath, ...) -> Result { scope_time!("f"); let repo = + repo(repo_path)?; ... }`. Test helpers (`repo_init`, `debug_cmd_print`) in `sync/mod.rs::tests`. +- Popups (Branches/Submodules/etc.): `src/popups/.rs`, registered in `src/popups/mod.rs` and + the `setup_popups!` macro in `src/app.rs`; opened via an `InternalEvent` in `src/queue.rs`. +- Components implement `Component` + `DrawableComponent` (`src/components/mod.rs`). Non-commit list + views use `VerticalScroll` + `ui::scrolllist::draw_list` (see `src/popups/submodules.rs`). +- Keybindings: `src/keys/key_list.rs` (`KeysList` struct + `Default`). Command/help strings: + `src/strings.rs`. diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..bf45eecf1b 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -34,6 +34,7 @@ mod submodules; mod tags; mod tree; pub mod utils; +mod worktree; pub use blame::{blame_file, BlameHunk, FileBlame}; pub use branch::{ @@ -108,6 +109,10 @@ pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, stage_add_all, stage_add_file, stage_addremoved, Head, }; +pub use worktree::{ + create_worktree, get_worktrees, remove_worktree, + toggle_worktree_lock, WorktreeInfo, +}; pub use git2::ResetType; diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs new file mode 100644 index 0000000000..bb7a633801 --- /dev/null +++ b/asyncgit/src/sync/worktree.rs @@ -0,0 +1,514 @@ +//! read-only listing of git worktrees + +use std::path::{Path, PathBuf}; + +use git2::{Repository, WorktreeLockStatus, WorktreePruneOptions}; +use scopetime::scope_time; + +use super::status::{get_status, StatusType}; +use super::{is_workdir_clean, repo, RepoPath}; +use crate::error::{Error, Result}; + +/// name reported for the primary working tree +const MAIN_WORKTREE_NAME: &str = "(main)"; + +/// Information about one git worktree (the primary tree or a linked one). +/// +/// The four flag fields are independent (e.g. the primary tree can be +/// current or not, a linked tree can be locked or not, etc.), so they +/// don't collapse into a smaller enum; see `status_tree.rs` for the +/// same precedent in this codebase. +#[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] +pub struct WorktreeInfo { + /// Linked-worktree name; "(main)" for the primary working tree. + pub name: String, + /// Absolute path to the worktree's working directory. + pub path: PathBuf, + /// Short name of the checked-out branch (HEAD), or None if + /// detached/unborn. + pub branch: Option, + /// Whether the worktree's working directory is present/valid. + pub is_valid: bool, + /// Whether the worktree is locked. + pub is_locked: bool, + /// Whether this is the worktree gitui is currently operating in. + pub is_current: bool, + /// Whether this is the primary ("(main)") working tree, which + /// cannot be removed or locked. + pub is_main: bool, +} + +/// Lists the worktrees of the repo at `repo_path`, returning the +/// primary working tree first followed by any linked worktrees. +pub fn get_worktrees( + repo_path: &RepoPath, +) -> Result> { + scope_time!("get_worktrees"); + + let repo = repo(repo_path)?; + + // working dir gitui is currently operating in, used to flag the + // matching entry as `is_current`. + let current = repo.workdir(); + + let mut worktrees = Vec::new(); + + if let Some(main) = main_worktree_info(&repo, current) { + worktrees.push(main); + } + + // `iter()` yields `Result, Error>`; the two + // flattens drop unreadable/non-utf8 names, leaving `&str`. + for name in repo.worktrees()?.iter().flatten().flatten() { + worktrees.push(linked_worktree_info(&repo, name, current)?); + } + + Ok(worktrees) +} + +/// synthesizes the entry for the primary working tree, which is not +/// part of `Repository::worktrees`. returns `None` for bare repos +/// (no working tree) or when the primary workdir cannot be located. +fn main_worktree_info( + repo: &Repository, + current: Option<&Path>, +) -> Option { + if repo.is_bare() { + return None; + } + + // primary working dir and the repo handle used to read its branch. + let (main_workdir, branch) = if repo.is_worktree() { + // gitui is operating inside a linked worktree; the primary + // tree sits next to the shared common git dir. + let workdir = + repo.commondir().parent().map(Path::to_path_buf)?; + + let branch = Repository::open(&workdir) + .ok() + .and_then(|r| head_branch(&r)); + + (workdir, branch) + } else { + let workdir = repo.workdir()?; + + (workdir.to_path_buf(), head_branch(repo)) + }; + + Some(WorktreeInfo { + is_current: same_workdir(&main_workdir, current), + name: MAIN_WORKTREE_NAME.to_string(), + path: main_workdir, + branch, + is_valid: true, + is_locked: false, + is_main: true, + }) +} + +/// builds the entry for a single linked worktree. +fn linked_worktree_info( + repo: &Repository, + name: &str, + current: Option<&Path>, +) -> Result { + let wt = repo.find_worktree(name)?; + + let branch = Repository::open_from_worktree(&wt) + .ok() + .and_then(|r| head_branch(&r)); + + let is_locked = wt.is_locked().is_ok_and(|status| { + matches!(status, WorktreeLockStatus::Locked(_)) + }); + + Ok(WorktreeInfo { + is_current: same_workdir(wt.path(), current), + name: name.to_string(), + path: wt.path().to_path_buf(), + branch, + is_valid: wt.validate().is_ok(), + is_locked, + is_main: false, + }) +} + +/// Creates a new linked worktree at `worktree_path`, checking out a +/// new branch named after the final path component. +/// +/// `worktree_path` may be absolute or relative to the repository's +/// working directory. Returns the absolute path of the created +/// worktree. +pub fn create_worktree( + repo_path: &RepoPath, + worktree_path: &str, +) -> Result { + scope_time!("create_worktree"); + + let repo = repo(repo_path)?; + + let requested = Path::new(worktree_path); + + let target = if requested.is_absolute() { + requested.to_path_buf() + } else { + repo.workdir().ok_or(Error::NoWorkDir)?.join(requested) + }; + + let name = target + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + Error::Generic( + "invalid worktree path: no final component" + .to_string(), + ) + })? + .to_string(); + + // libgit2 only creates the leaf directory, not missing parents + // (unlike `git worktree add`), so create the parent chain first + // to support nested paths such as `worktrees/foo`. + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + + let worktree = repo.worktree(&name, &target, None)?; + + Ok(worktree.path().to_path_buf()) +} + +/// Removes the linked worktree named `name`: deletes its working +/// directory and prunes its administrative files. +/// +/// Refuses (returns an error, mirroring `git worktree remove` without +/// `--force`) when the worktree is the current one, is locked, or has +/// uncommitted/untracked changes — since removal deletes the working +/// directory irrecoverably. +pub fn remove_worktree( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("remove_worktree"); + + let repo = repo(repo_path)?; + let worktree = repo.find_worktree(name)?; + + // never remove the worktree gitui is operating in. + if same_workdir(worktree.path(), repo.workdir()) { + return Err(Error::Generic( + "cannot remove the current worktree".to_string(), + )); + } + + // a locked worktree is deliberately protected; require unlock. + if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_)) + { + return Err(Error::Generic( + "worktree is locked; unlock it before removing" + .to_string(), + )); + } + + // refuse to delete a worktree with uncommitted or untracked + // changes (libgit2 does not refuse this itself). Only checkable + // when the working dir still exists. + if worktree.validate().is_ok() { + let wt_path: RepoPath = worktree.path().to_path_buf().into(); + // `is_workdir_clean` only compares the index to the working + // dir, so it misses staged-but-uncommitted changes; also + // reject those (HEAD vs index) to match `git worktree remove`. + let has_staged = + !get_status(&wt_path, StatusType::Stage, None)? + .is_empty(); + if has_staged || !is_workdir_clean(&wt_path, None)? { + return Err(Error::Generic( + "worktree has uncommitted changes; commit or discard them first" + .to_string(), + )); + } + } + + // valid(true): also prune worktrees whose dir still exists; + // working_tree(true): delete the working directory too. + let mut opts = WorktreePruneOptions::new(); + opts.valid(true).working_tree(true); + worktree.prune(Some(&mut opts))?; + + Ok(()) +} + +/// Toggles the lock state of the linked worktree named `name`: locks +/// it (with no reason) when unlocked, unlocks it when locked. +pub fn toggle_worktree_lock( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("toggle_worktree_lock"); + + let repo = repo(repo_path)?; + let worktree = repo.find_worktree(name)?; + + if matches!(worktree.is_locked()?, WorktreeLockStatus::Locked(_)) + { + worktree.unlock()?; + } else { + worktree.lock(None)?; + } + + Ok(()) +} + +/// short name of the branch a repo's HEAD points at, or `None` when +/// HEAD is unborn or detached. +fn head_branch(repo: &Repository) -> Option { + // a detached HEAD points at a commit, not a branch, and its + // shorthand is "HEAD" rather than a branch name. + if repo.head_detached().unwrap_or(false) { + return None; + } + + repo.head() + .ok() + .as_ref() + .and_then(|head| head.shorthand().ok().map(String::from)) +} + +/// whether `path` and `current` refer to the same working directory, +/// comparing canonicalized paths and falling back to raw equality when +/// canonicalization fails. +fn same_workdir(path: &Path, current: Option<&Path>) -> bool { + current.is_some_and(|current| { + let canon = |p: &Path| std::fs::canonicalize(p).ok(); + match (canon(path), canon(current)) { + (Some(a), Some(b)) => a == b, + _ => path == current, + } + }) +} + +#[cfg(test)] +mod tests { + use super::{ + create_worktree, get_worktrees, remove_worktree, + toggle_worktree_lock, WorktreeInfo, + }; + use crate::sync::{tests::repo_init, RepoPath}; + use pretty_assertions::assert_eq; + + fn find<'a>( + list: &'a [WorktreeInfo], + name: &str, + ) -> &'a WorktreeInfo { + list.iter().find(|w| w.name == name).unwrap() + } + + #[test] + fn test_lists_primary_and_linked() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // linked worktree kept outside the main workdir to avoid + // nesting; `wt_dir` must stay alive for the whole test. + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("wt1"); + repo.worktree("wt1", &wt_path, None).unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 2); + + let linked = find(&list, "wt1"); + assert!(linked.path.is_absolute()); + assert!(linked.path.ends_with("wt1")); + // git names the branch after the worktree by default. + assert!(linked.branch.is_some()); + assert!(!linked.is_current); + + let primary = find(&list, "(main)"); + assert!(primary.is_current); + } + + #[test] + fn test_primary_only() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(list[0].is_current); + assert!(list[0].is_valid); + assert!(!list[0].is_locked); + } + + #[test] + fn test_detached_head_has_no_branch() { + let (_td, repo) = repo_init().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let oid = repo.head().unwrap().target().unwrap(); + repo.set_head_detached(oid).unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(list[0].branch.is_none()); + } + + #[test] + fn test_create_worktree_new_branch() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // keep the linked worktree outside the main workdir; wt_dir + // must stay alive for the whole test. + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("feature-x"); + + let created = + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + assert!(created.ends_with("feature-x")); + + let list = get_worktrees(&repo_path).unwrap(); + let wt = find(&list, "feature-x"); + assert!(wt.path.is_absolute()); + // libgit2 names the new branch after the worktree. + assert_eq!(wt.branch.as_deref(), Some("feature-x")); + assert!(!wt.is_current); + } + + #[test] + fn test_create_worktree_creates_missing_parents() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // nested path whose parent dirs do not exist yet; libgit2 + // would fail to mkdir the leaf without this being handled. + let wt_dir = tempfile::TempDir::new().unwrap(); + let nested = wt_dir.path().join("a").join("b").join("wt"); + + let created = + create_worktree(&repo_path, nested.to_str().unwrap()) + .unwrap(); + + assert!(created.ends_with("wt")); + let list = get_worktrees(&repo_path).unwrap(); + let wt = find(&list, "wt"); + assert_eq!(wt.branch.as_deref(), Some("wt")); + } + + #[test] + fn test_create_worktree_rejects_pathless_name() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + // a path ending in ".." has no usable final component + let res = create_worktree(&repo_path, ".."); + assert!(res.is_err()); + } + + #[test] + fn test_remove_worktree() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("gone"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + + remove_worktree(&repo_path, "gone").unwrap(); + + let list = get_worktrees(&repo_path).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "(main)"); + assert!(!wt_path.exists()); + } + + #[test] + fn test_remove_worktree_refuses_dirty() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("dirty"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + // introduce an untracked file inside the worktree + std::fs::write(wt_path.join("scratch.txt"), b"wip").unwrap(); + + assert!(remove_worktree(&repo_path, "dirty").is_err()); + // still present because removal was refused + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + } + + #[test] + fn test_remove_worktree_refuses_staged() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("staged"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + // stage a new file inside the worktree: it matches the index, + // so only the HEAD-vs-index (staged) check can catch it — the + // working-dir check alone would see this as clean. + std::fs::write(wt_path.join("new.txt"), b"data").unwrap(); + let wt_repo = git2::Repository::open(&wt_path).unwrap(); + let mut index = wt_repo.index().unwrap(); + index.add_path(std::path::Path::new("new.txt")).unwrap(); + index.write().unwrap(); + + assert!(remove_worktree(&repo_path, "staged").is_err()); + assert_eq!(get_worktrees(&repo_path).unwrap().len(), 2); + } + + #[test] + fn test_toggle_worktree_lock() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: RepoPath = root.to_str().unwrap().into(); + + let wt_dir = tempfile::TempDir::new().unwrap(); + let wt_path = wt_dir.path().join("lockme"); + create_worktree(&repo_path, wt_path.to_str().unwrap()) + .unwrap(); + + let locked = |p: &RepoPath| { + get_worktrees(p) + .unwrap() + .into_iter() + .find(|w| w.name == "lockme") + .unwrap() + .is_locked + }; + + assert!(!locked(&repo_path)); + toggle_worktree_lock(&repo_path, "lockme").unwrap(); + assert!(locked(&repo_path)); + toggle_worktree_lock(&repo_path, "lockme").unwrap(); + assert!(!locked(&repo_path)); + } +} diff --git a/src/app.rs b/src/app.rs index ddb157af3f..f00226d5d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,13 +14,14 @@ use crate::{ AppOption, BlameFilePopup, BranchListPopup, CheckoutOptionPopup, CommitPopup, CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, CreateRemotePopup, - ExternalEditorPopup, FetchPopup, FileRevlogPopup, - FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup, - LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, - PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, - RenameRemotePopup, ResetPopup, RevisionFilesPopup, - StashMsgPopup, SubmodulesListPopup, TagCommitPopup, - TagListPopup, UpdateRemoteUrlPopup, + CreateWorktreePopup, ExternalEditorPopup, FetchPopup, + FileRevlogPopup, FuzzyFindPopup, GotoLinePopup, HelpPopup, + InspectCommitPopup, LogSearchPopupPopup, MsgPopup, + OptionsPopup, PullPopup, PushPopup, PushTagsPopup, + RemoteListPopup, RenameBranchPopup, RenameRemotePopup, + ResetPopup, RevisionFilesPopup, StashMsgPopup, + SubmodulesListPopup, TagCommitPopup, TagListPopup, + UpdateRemoteUrlPopup, WorktreesPopup, }, queue::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, @@ -89,6 +90,7 @@ pub struct App { fetch_popup: FetchPopup, tag_commit_popup: TagCommitPopup, create_branch_popup: CreateBranchPopup, + create_worktree_popup: CreateWorktreePopup, create_remote_popup: CreateRemotePopup, rename_remote_popup: RenameRemotePopup, update_remote_url_popup: UpdateRemoteUrlPopup, @@ -97,6 +99,7 @@ pub struct App { select_branch_popup: BranchListPopup, options_popup: OptionsPopup, submodule_popup: SubmodulesListPopup, + worktree_popup: WorktreesPopup, tags_popup: TagListPopup, reset_popup: ResetPopup, checkout_option_popup: CheckoutOptionPopup, @@ -212,6 +215,7 @@ impl App { fetch_popup: FetchPopup::new(&env), tag_commit_popup: TagCommitPopup::new(&env), create_branch_popup: CreateBranchPopup::new(&env), + create_worktree_popup: CreateWorktreePopup::new(&env), create_remote_popup: CreateRemotePopup::new(&env), rename_remote_popup: RenameRemotePopup::new(&env), update_remote_url_popup: UpdateRemoteUrlPopup::new(&env), @@ -221,6 +225,7 @@ impl App { tags_popup: TagListPopup::new(&env), options_popup: OptionsPopup::new(&env), submodule_popup: SubmodulesListPopup::new(&env), + worktree_popup: WorktreesPopup::new(&env), log_search_popup: LogSearchPopupPopup::new(&env), fuzzy_find_popup: FuzzyFindPopup::new(&env), do_quit: QuitState::None, @@ -360,6 +365,12 @@ impl App { ) { self.options_popup.show()?; NeedsUpdate::ALL + } else if key_match( + k, + self.key_config.keys.view_worktrees, + ) { + self.worktree_popup.open()?; + NeedsUpdate::ALL } else { NeedsUpdate::empty() }; @@ -410,6 +421,7 @@ impl App { self.stashing_tab.update()?; self.stashlist_tab.update()?; self.reset_popup.update()?; + self.worktree_popup.update_worktrees()?; self.update_commands(); @@ -515,6 +527,7 @@ impl App { reset_popup, checkout_option_popup, create_branch_popup, + create_worktree_popup, create_remote_popup, rename_remote_popup, update_remote_url_popup, @@ -523,6 +536,7 @@ impl App { select_branch_popup, revision_files_popup, submodule_popup, + worktree_popup, tags_popup, options_popup, help_popup, @@ -552,10 +566,12 @@ impl App { rename_remote_popup, update_remote_url_popup, submodule_popup, + worktree_popup, tags_popup, reset_popup, checkout_option_popup, create_branch_popup, + create_worktree_popup, rename_branch_popup, revision_files_popup, fuzzy_find_popup, @@ -787,6 +803,9 @@ impl App { InternalEvent::CreateBranch => { self.create_branch_popup.open()?; } + InternalEvent::CreateWorktree => { + self.create_worktree_popup.open()?; + } InternalEvent::RenameBranch(branch_ref, cur_name) => { self.rename_branch_popup .open(branch_ref, cur_name)?; @@ -990,6 +1009,17 @@ impl App { self.select_branch_popup.update_branches()?; } + Action::DeleteWorktree(name) => { + if let Err(e) = + sync::remove_worktree(&self.repo.borrow(), &name) + { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + self.worktree_popup.update_worktrees()?; + flags.insert(NeedsUpdate::ALL); + } Action::DeleteRemoteBranch(branch_ref) => { self.delete_remote_branch(&branch_ref)?; } @@ -1143,6 +1173,14 @@ impl App { ) .order(order::NAV), ); + res.push( + CommandInfo::new( + strings::commands::view_worktrees(&self.key_config), + true, + !self.any_popup_visible(), + ) + .order(order::NAV), + ); res.push( CommandInfo::new( diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..2b4b92be38 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -37,12 +37,11 @@ impl Gitui { theme: Theme, key_config: &KeyConfig, updater: Updater, + input: &Input, ) -> Result { let (tx_git, rx_git) = unbounded(); let (tx_app, rx_app) = unbounded(); - let input = Input::new(); - let (rx_ticker, rx_watcher) = match updater { Updater::NotifyWatcher => { let repo_watcher = RepoWatcher::new( @@ -212,7 +211,7 @@ mod tests { use ratatui::{backend::TestBackend, Terminal}; use crate::{ - args::CliArgs, gitui::Gitui, keys::KeyConfig, + args::CliArgs, gitui::Gitui, input::Input, keys::KeyConfig, ui::style::Theme, AsyncNotification, Updater, }; @@ -248,10 +247,16 @@ mod tests { let theme = Theme::init(&PathBuf::new()); let key_config = KeyConfig::default(); + let input = Input::new(); - let mut gitui = - Gitui::new(cliargs, theme, &key_config, Updater::Ticker) - .unwrap(); + let mut gitui = Gitui::new( + cliargs, + theme, + &key_config, + Updater::Ticker, + &input, + ) + .unwrap(); let mut terminal = Terminal::new(TestBackend::new(90, 12)).unwrap(); diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..a50cc53b57 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -118,6 +118,8 @@ pub struct KeysList { pub stage_unstage_item: GituiKeyEvent, pub tag_annotate: GituiKeyEvent, pub view_submodules: GituiKeyEvent, + pub view_worktrees: GituiKeyEvent, + pub lock_worktree: GituiKeyEvent, pub view_remotes: GituiKeyEvent, pub update_remote_name: GituiKeyEvent, pub update_remote_url: GituiKeyEvent, @@ -216,6 +218,8 @@ impl Default for KeysList { stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), + view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT), + lock_worktree: GituiKeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()), view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE), update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE), diff --git a/src/main.rs b/src/main.rs index fd662950a2..ee50b6e077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,7 @@ use crossterm::{ ExecutableCommand, }; use gitui::Gitui; -use input::InputEvent; +use input::{Input, InputEvent}; use keys::KeyConfig; use ratatui::backend::CrosstermBackend; use scopeguard::defer; @@ -184,6 +184,12 @@ fn main() -> Result<()> { let mut terminal = start_terminal(io::stdout(), &cliargs.repo_path)?; + // the input reader is created once and shared across app restarts + // (e.g. switching to a submodule or worktree). recreating it per + // app would let the outgoing reader consume and drop the first + // keystroke after a switch; a shared reader buffers it instead. + let input = Input::new(); + let updater = if cliargs.notify_watcher { Updater::NotifyWatcher } else { @@ -199,6 +205,7 @@ fn main() -> Result<()> { theme.clone(), &key_config, updater, + &input, &mut terminal, )?; @@ -226,9 +233,11 @@ fn run_app( theme: Theme, key_config: &KeyConfig, updater: Updater, + input: &Input, terminal: &mut Terminal, ) -> Result { - let mut gitui = Gitui::new(cliargs, theme, key_config, updater)?; + let mut gitui = + Gitui::new(cliargs, theme, key_config, updater, input)?; log::trace!("app start: {} ms", app_start.elapsed().as_millis()); diff --git a/src/popups/confirm.rs b/src/popups/confirm.rs index 9910a321f3..c33d0e2fea 100644 --- a/src/popups/confirm.rs +++ b/src/popups/confirm.rs @@ -9,7 +9,7 @@ use crate::{ strings, ui, }; use anyhow::Result; -use crossterm::event::Event; +use crossterm::event::{Event, KeyCode}; use ratatui::{layout::Rect, text::Text, widgets::Clear, Frame}; use std::borrow::Cow; use ui::style::SharedTheme; @@ -74,6 +74,14 @@ impl Component for ConfirmPopup { self.hide(); } else if key_match(e, self.key_config.keys.enter) { self.confirm(); + } else if self.is_worktree_removal() { + // worktree removal additionally accepts explicit + // y/n, as spelled out in its message copy. + match e.code { + KeyCode::Char('y') => self.confirm(), + KeyCode::Char('n') => self.hide(), + _ => {} + } } return Ok(EventState::Consumed); @@ -125,6 +133,10 @@ impl ConfirmPopup { self.hide(); } + const fn is_worktree_removal(&self) -> bool { + matches!(self.target, Some(Action::DeleteWorktree(_))) + } + fn get_text(&self) -> (String, String) { if let Some(ref a) = self.target { return match a { @@ -172,6 +184,10 @@ impl ConfirmPopup { strings::confirm_title_delete_remote(&self.key_config), strings::confirm_msg_delete_remote(&self.key_config,remote_name), ), + Action::DeleteWorktree(name) => ( + strings::confirm_title_delete_worktree(&self.key_config), + strings::confirm_msg_delete_worktree(&self.key_config, name), + ), Action::DeleteTag(tag_name) => ( strings::confirm_title_delete_tag( &self.key_config, diff --git a/src/popups/create_worktree.rs b/src/popups/create_worktree.rs new file mode 100644 index 0000000000..3fd1f21340 --- /dev/null +++ b/src/popups/create_worktree.rs @@ -0,0 +1,179 @@ +use crate::components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, InputType, TextInputComponent, +}; +use crate::{ + app::Environment, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use asyncgit::sync::{self, RepoPathRef}; +use crossterm::event::Event; +use easy_cast::Cast; +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; + +pub struct CreateWorktreePopup { + repo: RepoPathRef, + input: TextInputComponent, + queue: Queue, + key_config: SharedKeyConfig, + theme: SharedTheme, +} + +impl DrawableComponent for CreateWorktreePopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + self.draw_warnings(f); + } + + Ok(()) + } +} + +impl Component for CreateWorktreePopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::create_worktree_confirm_msg( + &self.key_config, + ), + true, + true, + )); + } + + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.create_worktree(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.show()?; + + Ok(()) + } +} + +impl CreateWorktreePopup { + /// + pub fn new(env: &Environment) -> Self { + Self { + queue: env.queue.clone(), + input: TextInputComponent::new( + env, + &strings::create_worktree_popup_title( + &env.key_config, + ), + &strings::create_worktree_popup_msg(&env.key_config), + true, + ) + .with_input_type(InputType::Singleline), + theme: env.theme.clone(), + key_config: env.key_config.clone(), + repo: env.repo.clone(), + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + + Ok(()) + } + + /// + pub fn create_worktree(&mut self) { + let path = self.input.get_text().to_string(); + + if path.trim().is_empty() { + self.hide(); + return; + } + + let res = sync::create_worktree(&self.repo.borrow(), &path); + + self.input.clear(); + self.hide(); + + match res { + Ok(_) => { + self.queue + .push(InternalEvent::Update(NeedsUpdate::ALL)); + } + Err(e) => { + log::error!("create worktree: {e}"); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("create worktree error:\n{e}"), + )); + } + } + } + + fn draw_warnings(&self, f: &mut Frame) { + let current_text = self.input.get_text(); + + let derived_name = std::path::Path::new(current_text) + .file_name() + .and_then(|s| s.to_str()); + + if let Some(derived_name) = derived_name { + let valid = sync::validate_branch_name(derived_name) + .unwrap_or_default(); + + if !valid { + let msg = strings::branch_name_invalid(); + let msg_length: u16 = msg.len().cast(); + let w = Paragraph::new(msg) + .style(self.theme.text_danger()); + + let rect = { + let mut rect = self.input.get_area(); + rect.y += rect.height.saturating_sub(1); + rect.height = 1; + let offset = + rect.width.saturating_sub(msg_length + 1); + rect.width = + rect.width.saturating_sub(offset + 1); + rect.x += offset; + + rect + }; + + f.render_widget(w, rect); + } + } + } +} diff --git a/src/popups/mod.rs b/src/popups/mod.rs index ebb9e1482c..8e0b83c8a8 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -6,6 +6,7 @@ mod compare_commits; mod confirm; mod create_branch; mod create_remote; +mod create_worktree; mod externaleditor; mod fetch; mod file_revlog; @@ -29,6 +30,7 @@ mod submodules; mod tag_commit; mod taglist; mod update_remote_url; +mod worktrees; pub use blame_file::{BlameFileOpen, BlameFilePopup}; pub use branchlist::BranchListPopup; @@ -38,6 +40,7 @@ pub use compare_commits::CompareCommitsPopup; pub use confirm::ConfirmPopup; pub use create_branch::CreateBranchPopup; pub use create_remote::CreateRemotePopup; +pub use create_worktree::CreateWorktreePopup; pub use externaleditor::ExternalEditorPopup; pub use fetch::FetchPopup; pub use file_revlog::{FileRevOpen, FileRevlogPopup}; @@ -61,6 +64,7 @@ pub use submodules::SubmodulesListPopup; pub use tag_commit::TagCommitPopup; pub use taglist::TagListPopup; pub use update_remote_url::UpdateRemoteUrlPopup; +pub use worktrees::WorktreesPopup; use crate::ui::style::Theme; use ratatui::{ diff --git a/src/popups/worktrees.rs b/src/popups/worktrees.rs new file mode 100644 index 0000000000..35db1d5fd7 --- /dev/null +++ b/src/popups/worktrees.rs @@ -0,0 +1,419 @@ +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, ScrollType, VerticalScroll, + }, + keys::{key_match, SharedKeyConfig}, + queue::{Action, InternalEvent, Queue}, + strings, + ui::{self, Size}, +}; +use anyhow::Result; +use asyncgit::sync::{ + get_worktrees, toggle_worktree_lock, RepoPathRef, WorktreeInfo, +}; +use crossterm::event::Event; +use ratatui::{ + layout::{Alignment, Margin, Rect}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; +use std::cell::Cell; +use ui::style::SharedTheme; +use unicode_truncate::UnicodeTruncateStr; +use unicode_width::UnicodeWidthStr; + +/// +pub struct WorktreesPopup { + repo: RepoPathRef, + queue: Queue, + worktrees: Vec, + visible: bool, + current_height: Cell, + selection: u16, + scroll: VerticalScroll, + theme: SharedTheme, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for WorktreesPopup { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + if self.is_visible() { + const PERCENT_SIZE: Size = Size::new(80, 80); + const MIN_SIZE: Size = Size::new(60, 20); + + let area = ui::centered_rect( + PERCENT_SIZE.width, + PERCENT_SIZE.height, + rect, + ); + let area = ui::rect_inside(MIN_SIZE, rect.into(), area); + let area = area.intersection(rect); + + f.render_widget(Clear, area); + + f.render_widget( + Block::default() + .title(strings::POPUP_TITLE_WORKTREES) + .border_type(ratatui::widgets::BorderType::Thick) + .borders(Borders::ALL), + area, + ); + + let area = area.inner(Margin { + vertical: 1, + horizontal: 1, + }); + + self.draw_list(f, area)?; + } + + Ok(()) + } +} + +impl Component for WorktreesPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.visible || force_all { + if !force_all { + out.clear(); + } + + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::open_worktree(&self.key_config), + self.can_switch_worktree(), + true, + )); + + out.push(CommandInfo::new( + strings::commands::create_worktree_confirm_msg( + &self.key_config, + ), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::remove_worktree(&self.key_config), + self.can_remove_worktree(), + true, + )); + + out.push(CommandInfo::new( + strings::commands::lock_worktree(&self.key_config), + self.can_lock_worktree(), + true, + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if !self.visible { + return Ok(EventState::NotConsumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match(e, self.key_config.keys.move_down) { + return self + .move_selection(ScrollType::Up) + .map(Into::into); + } else if key_match(e, self.key_config.keys.move_up) { + return self + .move_selection(ScrollType::Down) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_down) { + return self + .move_selection(ScrollType::PageDown) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_up) { + return self + .move_selection(ScrollType::PageUp) + .map(Into::into); + } else if key_match(e, self.key_config.keys.home) { + return self + .move_selection(ScrollType::Home) + .map(Into::into); + } else if key_match(e, self.key_config.keys.end) { + return self + .move_selection(ScrollType::End) + .map(Into::into); + } else if key_match(e, self.key_config.keys.enter) { + if let Some(worktree) = self.selected_entry() { + if !worktree.is_current { + self.queue.push(InternalEvent::OpenRepo { + path: worktree.path.clone(), + }); + } + } + self.hide(); + } else if key_match(e, self.key_config.keys.create_branch) + { + self.queue.push(InternalEvent::CreateWorktree); + self.hide(); + } else if key_match(e, self.key_config.keys.delete_branch) + { + if let Some(worktree) = self.selected_entry() { + if !worktree.is_main && !worktree.is_current { + self.queue.push( + InternalEvent::ConfirmAction( + Action::DeleteWorktree( + worktree.name.clone(), + ), + ), + ); + } + } + } else if key_match(e, self.key_config.keys.lock_worktree) + { + let name = self + .selected_entry() + .filter(|w| !w.is_main) + .map(|w| w.name.clone()); + + if let Some(name) = name { + if let Err(err) = toggle_worktree_lock( + &self.repo.borrow(), + &name, + ) { + self.queue.push(InternalEvent::ShowErrorMsg( + err.to_string(), + )); + } + self.update_worktrees()?; + } + } else if key_match( + e, + self.key_config.keys.cmd_bar_toggle, + ) { + //do not consume if its the more key + return Ok(EventState::NotConsumed); + } + } + + Ok(EventState::Consumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + + Ok(()) + } +} + +impl WorktreesPopup { + pub fn new(env: &Environment) -> Self { + Self { + worktrees: Vec::new(), + scroll: VerticalScroll::new(), + queue: env.queue.clone(), + selection: 0, + visible: false, + theme: env.theme.clone(), + key_config: env.key_config.clone(), + current_height: Cell::new(0), + repo: env.repo.clone(), + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + self.update_worktrees()?; + + Ok(()) + } + + /// + pub fn update_worktrees(&mut self) -> Result<()> { + if self.is_visible() { + self.worktrees = get_worktrees(&self.repo.borrow())?; + self.set_selection(self.selection)?; + } + Ok(()) + } + + fn selected_entry(&self) -> Option<&WorktreeInfo> { + self.worktrees.get(self.selection as usize) + } + + fn can_switch_worktree(&self) -> bool { + self.selected_entry().is_some_and(|w| !w.is_current) + } + + fn can_remove_worktree(&self) -> bool { + self.selected_entry() + .is_some_and(|w| !w.is_main && !w.is_current) + } + + fn can_lock_worktree(&self) -> bool { + self.selected_entry().is_some_and(|w| !w.is_main) + } + + //TODO: dedup this almost identical with BranchListComponent + fn move_selection(&mut self, scroll: ScrollType) -> Result { + let new_selection = match scroll { + ScrollType::Up => self.selection.saturating_add(1), + ScrollType::Down => self.selection.saturating_sub(1), + ScrollType::PageDown => self + .selection + .saturating_add(self.current_height.get()), + ScrollType::PageUp => self + .selection + .saturating_sub(self.current_height.get()), + ScrollType::Home => 0, + ScrollType::End => { + let count: u16 = self.worktrees.len().try_into()?; + count.saturating_sub(1) + } + }; + + self.set_selection(new_selection)?; + + Ok(true) + } + + fn set_selection(&mut self, selection: u16) -> Result<()> { + let num_entries: u16 = self.worktrees.len().try_into()?; + let num_entries = num_entries.saturating_sub(1); + + let selection = if selection > num_entries { + num_entries + } else { + selection + }; + + self.selection = selection; + + Ok(()) + } + + fn get_text( + &self, + theme: &SharedTheme, + width_available: u16, + height: usize, + ) -> Text<'_> { + const THREE_DOTS: &str = "..."; + const NAME_WIDTH: usize = 16; + const BRANCH_WIDTH: usize = 20; + + let mut txt = Vec::with_capacity(self.worktrees.len()); + + for (i, worktree) in self + .worktrees + .iter() + .skip(self.scroll.get_top()) + .take(height) + .enumerate() + { + let selected = (self.selection as usize + - self.scroll.get_top()) + == i; + + let marker = if worktree.is_current { "*" } else { " " }; + + let branch = worktree + .branch + .clone() + .unwrap_or_else(|| "(detached)".to_string()); + + let mut suffix = String::new(); + if worktree.is_locked { + suffix.push_str(" 🔒"); + } + if !worktree.is_valid { + suffix.push_str(" (invalid)"); + } + + let prefix = format!( + "{marker} {name:name_w$} {branch:branch_w$} ", + name = worktree.name, + name_w = NAME_WIDTH, + branch_w = BRANCH_WIDTH, + ); + + let used = UnicodeWidthStr::width(prefix.as_str()) + .saturating_add(UnicodeWidthStr::width( + suffix.as_str(), + )); + + let path_width = + (width_available as usize).saturating_sub(used); + + let mut path = + worktree.path.to_string_lossy().to_string(); + + if UnicodeWidthStr::width(path.as_str()) > path_width { + let (trunc, _) = path.unicode_truncate( + path_width.saturating_sub(THREE_DOTS.len()), + ); + path = format!("{trunc}{THREE_DOTS}"); + } + + txt.push(Line::from(vec![Span::styled( + format!("{prefix}{path}{suffix}"), + theme.text(true, selected), + )])); + } + + Text::from(txt) + } + + fn draw_list(&self, f: &mut Frame, r: Rect) -> Result<()> { + let height_in_lines = r.height as usize; + self.current_height.set(height_in_lines.try_into()?); + + self.scroll.update( + self.selection as usize, + self.worktrees.len(), + height_in_lines, + ); + + f.render_widget( + Paragraph::new(self.get_text( + &self.theme, + r.width, + height_in_lines, + )) + .alignment(Alignment::Left), + r, + ); + + let mut r = r; + r.height += 2; + r.y = r.y.saturating_sub(1); + + self.scroll.draw(f, r, &self.theme); + + Ok(()) + } +} diff --git a/src/queue.rs b/src/queue.rs index 5cdfe3cef0..18099318d5 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -52,6 +52,7 @@ pub enum Action { DeleteTag(String), DeleteRemoteTag(String, String), DeleteRemote(String), + DeleteWorktree(String), ForcePush(String, bool), PullMerge { incoming: usize, rebase: bool }, AbortMerge, @@ -113,6 +114,8 @@ pub enum InternalEvent { /// CreateBranch, /// + CreateWorktree, + /// RenameRemote(String), /// UpdateRemoteUrl(String, String), diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..3ff5c4dcce 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -30,6 +30,7 @@ pub static PUSH_TAGS_STATES_PUSHING: &str = "pushing"; pub static PUSH_TAGS_STATES_DONE: &str = "done"; pub static POPUP_TITLE_SUBMODULES: &str = "Submodules"; +pub static POPUP_TITLE_WORKTREES: &str = "Worktrees"; pub static POPUP_TITLE_REMOTES: &str = "Remotes"; pub static POPUP_SUBTITLE_REMOTES: &str = "Details"; pub static POPUP_TITLE_FUZZY_FIND: &str = "Fuzzy Finder"; @@ -248,6 +249,19 @@ pub fn confirm_msg_delete_branch( ) -> String { format!("Confirm deleting branch: '{branch_ref}' ?") } +pub fn confirm_title_delete_worktree( + _key_config: &SharedKeyConfig, +) -> String { + "Remove Worktree".to_string() +} +pub fn confirm_msg_delete_worktree( + _key_config: &SharedKeyConfig, + name: &str, +) -> String { + format!( + "Really remove worktree `{name}`? Its working directory will be deleted.\n\ny = delete n / Esc = cancel" + ) +} pub fn confirm_title_delete_remote_branch( _key_config: &SharedKeyConfig, ) -> String { @@ -352,6 +366,17 @@ pub fn create_branch_popup_msg( ) -> String { "type branch name".to_string() } +pub fn create_worktree_popup_title( + _key_config: &SharedKeyConfig, +) -> String { + "Create Worktree".to_string() +} +pub fn create_worktree_popup_msg( + _key_config: &SharedKeyConfig, +) -> String { + "path (absolute, or relative to repo root — e.g. ../feature-x)" + .to_string() +} pub fn rename_remote_popup_title( _key_config: &SharedKeyConfig, ) -> String { @@ -933,6 +958,32 @@ pub mod commands { ) } + pub fn view_worktrees( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Worktrees [{}]", + key_config.get_hint(key_config.keys.view_worktrees), + ), + "open worktree view", + CMD_GROUP_GENERAL, + ) + } + + pub fn open_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Switch [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "switch to selected worktree", + CMD_GROUP_GENERAL, + ) + } + pub fn view_remotes(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( @@ -1685,6 +1736,45 @@ pub mod commands { ) .hide_help() } + + pub fn create_worktree_confirm_msg( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Create Worktree [{}]", + key_config.get_hint(key_config.keys.create_branch), + ), + "create a new worktree", + CMD_GROUP_GENERAL, + ) + } + + pub fn remove_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Remove [{}]", + key_config.get_hint(key_config.keys.delete_branch), + ), + "remove the selected worktree", + CMD_GROUP_GENERAL, + ) + } + + pub fn lock_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Lock/Unlock [{}]", + key_config.get_hint(key_config.keys.lock_worktree), + ), + "lock or unlock the selected worktree", + CMD_GROUP_GENERAL, + ) + } pub fn open_branch_create_popup( key_config: &SharedKeyConfig, ) -> CommandText {