diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 095dc8f0f99..4ffcb4e344d 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::backend::Signature; use jj_lib::object_id::ObjectId; use jj_lib::repo::Repo; @@ -42,7 +42,7 @@ pub(crate) struct CommitArgs { #[arg(long = "message", short, value_name = "MESSAGE")] message_paragraphs: Vec, /// Put these paths in the first commit - #[arg(add = ArgValueCandidates::new(complete::modified_files))] + #[arg(add = ArgValueCompleter::new(complete::modified_files))] paths: Vec, /// Reset the author to the configured user /// diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index 313c92ec136..ac9a4286c65 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use itertools::Itertools; use jj_lib::copies::CopyRecords; use jj_lib::repo::Repo; @@ -57,7 +58,7 @@ pub(crate) struct DiffArgs { #[arg(long, conflicts_with = "revision", add = ArgValueCandidates::new(complete::all_revisions))] to: Option, /// Restrict the diff to these paths - #[arg(add = ArgValueCandidates::new(complete::modified_revision_or_range_files))] + #[arg(add = ArgValueCompleter::new(complete::modified_revision_or_range_files))] paths: Vec, #[command(flatten)] format: DiffFormatArgs, diff --git a/cli/src/commands/file/annotate.rs b/cli/src/commands/file/annotate.rs index 9c30965ae17..3d6bfcec028 100644 --- a/cli/src/commands/file/annotate.rs +++ b/cli/src/commands/file/annotate.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::annotate::get_annotation_for_file; use jj_lib::annotate::FileAnnotation; use jj_lib::commit::Commit; @@ -37,7 +38,7 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub(crate) struct FileAnnotateArgs { /// the file to annotate - #[arg(add = ArgValueCandidates::new(complete::all_revision_files))] + #[arg(add = ArgValueCompleter::new(complete::all_revision_files))] path: String, /// an optional revision to start at #[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))] diff --git a/cli/src/commands/file/chmod.rs b/cli/src/commands/file/chmod.rs index 204b7f3aa8d..774838d95e4 100644 --- a/cli/src/commands/file/chmod.rs +++ b/cli/src/commands/file/chmod.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::backend::TreeValue; use jj_lib::merged_tree::MergedTreeBuilder; use jj_lib::object_id::ObjectId; @@ -52,7 +53,7 @@ pub(crate) struct FileChmodArgs { )] revision: RevisionArg, /// Paths to change the executable bit for - #[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))] + #[arg(required = true, add = ArgValueCompleter::new(complete::all_revision_files))] paths: Vec, } diff --git a/cli/src/commands/file/show.rs b/cli/src/commands/file/show.rs index 81e6e547149..7ed5db86bf6 100644 --- a/cli/src/commands/file/show.rs +++ b/cli/src/commands/file/show.rs @@ -16,6 +16,7 @@ use std::io; use std::io::Write; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::backend::BackendResult; use jj_lib::conflicts::materialize_merge_result; use jj_lib::conflicts::materialize_tree_value; @@ -51,7 +52,7 @@ pub(crate) struct FileShowArgs { )] revision: RevisionArg, /// Paths to print - #[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))] + #[arg(required = true, add = ArgValueCompleter::new(complete::all_revision_files))] paths: Vec, } diff --git a/cli/src/commands/file/untrack.rs b/cli/src/commands/file/untrack.rs index 55e3a862ff2..b46d2097c5d 100644 --- a/cli/src/commands/file/untrack.rs +++ b/cli/src/commands/file/untrack.rs @@ -14,7 +14,7 @@ use std::io::Write; -use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use itertools::Itertools; use jj_lib::merge::Merge; use jj_lib::merged_tree::MergedTreeBuilder; @@ -35,7 +35,7 @@ pub(crate) struct FileUntrackArgs { /// /// The paths could be ignored via a .gitignore or .git/info/exclude (in /// colocated repos). - #[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))] + #[arg(required = true, add = ArgValueCompleter::new(complete::all_revision_files))] paths: Vec, } diff --git a/cli/src/commands/interdiff.rs b/cli/src/commands/interdiff.rs index 7a5a3c69671..3b93f0274f3 100644 --- a/cli/src/commands/interdiff.rs +++ b/cli/src/commands/interdiff.rs @@ -16,6 +16,7 @@ use std::slice; use clap::ArgGroup; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use tracing::instrument; use crate::cli_util::CommandHelper; @@ -42,7 +43,7 @@ pub(crate) struct InterdiffArgs { #[arg(long, add = ArgValueCandidates::new(complete::all_revisions))] to: Option, /// Restrict the diff to these paths - #[arg(add = ArgValueCandidates::new(complete::modified_range_files))] + #[arg(add = ArgValueCompleter::new(complete::modified_range_files))] paths: Vec, #[command(flatten)] format: DiffFormatArgs, diff --git a/cli/src/commands/resolve.rs b/cli/src/commands/resolve.rs index ec9204f0522..d66cbd72150 100644 --- a/cli/src/commands/resolve.rs +++ b/cli/src/commands/resolve.rs @@ -15,6 +15,7 @@ use std::io::Write; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use itertools::Itertools; use jj_lib::object_id::ObjectId; use tracing::instrument; @@ -62,7 +63,7 @@ pub(crate) struct ResolveArgs { /// will attempt to resolve the first conflict we can find. You can use /// the `--list` argument to find paths to use here. // TODO: Find the conflict we can resolve even if it's not the first one. - #[arg(add = ArgValueCandidates::new(complete::revision_conflicted_files))] + #[arg(add = ArgValueCompleter::new(complete::revision_conflicted_files))] paths: Vec, } diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs index 5c88f979238..52989af873f 100644 --- a/cli/src/commands/restore.rs +++ b/cli/src/commands/restore.rs @@ -15,6 +15,7 @@ use std::io::Write; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::object_id::ObjectId; use jj_lib::rewrite::restore_tree; use tracing::instrument; @@ -45,7 +46,7 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub(crate) struct RestoreArgs { /// Restore only these paths (instead of all paths) - #[arg(add = ArgValueCandidates::new(complete::modified_range_files))] + #[arg(add = ArgValueCompleter::new(complete::modified_range_files))] paths: Vec, /// Revision to restore from (source) #[arg(long, add = ArgValueCandidates::new(complete::all_revisions))] diff --git a/cli/src/commands/split.rs b/cli/src/commands/split.rs index e422a82916c..20c5c9fbe21 100644 --- a/cli/src/commands/split.rs +++ b/cli/src/commands/split.rs @@ -14,6 +14,7 @@ use std::io::Write; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::object_id::ObjectId; use jj_lib::repo::Repo; use tracing::instrument; @@ -66,7 +67,7 @@ pub(crate) struct SplitArgs { #[arg(long, short, alias = "siblings")] parallel: bool, /// Put these paths in the first commit - #[arg(add = ArgValueCandidates::new(complete::modified_revision_files))] + #[arg(add = ArgValueCompleter::new(complete::modified_revision_files))] paths: Vec, } diff --git a/cli/src/commands/squash.rs b/cli/src/commands/squash.rs index 32ee83e7ef8..dda9a42ada4 100644 --- a/cli/src/commands/squash.rs +++ b/cli/src/commands/squash.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use itertools::Itertools as _; use jj_lib::commit::Commit; use jj_lib::commit::CommitIteratorExt; @@ -92,7 +93,7 @@ pub(crate) struct SquashArgs { /// Move only changes to these paths (instead of all paths) #[arg( conflicts_with_all = ["interactive", "tool"], - add = ArgValueCandidates::new(complete::squash_revision_files), + add = ArgValueCompleter::new(complete::squash_revision_files), )] paths: Vec, /// The source revision will not be abandoned diff --git a/cli/src/complete.rs b/cli/src/complete.rs index 8ccded46cd4..57351128db2 100644 --- a/cli/src/complete.rs +++ b/cli/src/complete.rs @@ -396,7 +396,17 @@ mod parse { } } -fn all_files_from_rev(rev: String) -> Vec { +fn dir_prefix_from<'a>(path: &'a str, current: &str) -> Option<&'a str> { + let remainder = path.strip_prefix(current)?; + remainder + .split_once('/') + .map(|(next, _)| path.split_at(current.len() + next.len() + 1).0) +} + +fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; with_jj(|mut jj, _| { let output = jj .arg("file") @@ -407,11 +417,30 @@ fn all_files_from_rev(rev: String) -> Vec { .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); - Ok(stdout.lines().map(CompletionCandidate::new).collect()) + Ok(stdout + .lines() + .filter_map(|path| { + if !path.starts_with(current) { + return None; + } + if let Some(dir_path) = dir_prefix_from(path, current) { + return Some(CompletionCandidate::new(dir_path)); + } + + Some(CompletionCandidate::new(path)) + }) + .dedup() // directories may occur multiple times + .collect()) }) } -fn modified_files_from_rev(rev: (String, Option)) -> Vec { +fn modified_files_from_rev( + rev: (String, Option), + current: &std::ffi::OsStr, +) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; with_jj(|mut jj, _| { let cmd = jj.arg("diff").arg("--summary"); match rev { @@ -423,10 +452,18 @@ fn modified_files_from_rev(rev: (String, Option)) -> Vec "Modified".into(), "D" => "Deleted".into(), @@ -435,13 +472,17 @@ fn modified_files_from_rev(rev: (String, Option)) -> Vec "Copied".into(), _ => format!("unknown mode: '{mode}'"), }; - CompletionCandidate::new(path).help(Some(help.into())) + Some(CompletionCandidate::new(path).help(Some(help.into()))) }) + .dedup() // directories may occur multiple times .collect()) }) } -fn conflicted_files_from_rev(rev: &str) -> Vec { +fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; with_jj(|mut jj, _| { let output = jj .arg("resolve") @@ -454,46 +495,57 @@ fn conflicted_files_from_rev(rev: &str) -> Vec { Ok(stdout .lines() - .filter_map(|line| line.split_whitespace().next()) - .map(CompletionCandidate::new) + .filter_map(|line| { + let path = line.split_whitespace().next()?; + + if !path.starts_with(current) { + return None; + } + if let Some(dir_path) = dir_prefix_from(path, current) { + return Some(CompletionCandidate::new(dir_path)); + } + + Some(CompletionCandidate::new(path)) + }) + .dedup() // directories may occur multiple times .collect()) }) } -pub fn modified_files() -> Vec { - modified_files_from_rev(("@".into(), None)) +pub fn modified_files(current: &std::ffi::OsStr) -> Vec { + modified_files_from_rev(("@".into(), None), current) } -pub fn all_revision_files() -> Vec { - all_files_from_rev(parse::revision_or_wc()) +pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec { + all_files_from_rev(parse::revision_or_wc(), current) } -pub fn modified_revision_files() -> Vec { - modified_files_from_rev((parse::revision_or_wc(), None)) +pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec { + modified_files_from_rev((parse::revision_or_wc(), None), current) } -pub fn modified_range_files() -> Vec { +pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec { match parse::range() { - Some((from, to)) => modified_files_from_rev((from, Some(to))), - None => modified_files_from_rev(("@".into(), None)), + Some((from, to)) => modified_files_from_rev((from, Some(to)), current), + None => modified_files_from_rev(("@".into(), None), current), } } -pub fn modified_revision_or_range_files() -> Vec { +pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec { if let Some(rev) = parse::revision() { - return modified_files_from_rev((rev, None)); + return modified_files_from_rev((rev, None), current); } - modified_range_files() + modified_range_files(current) } -pub fn revision_conflicted_files() -> Vec { - conflicted_files_from_rev(&parse::revision_or_wc()) +pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec { + conflicted_files_from_rev(&parse::revision_or_wc(), current) } /// Specific function for completing file paths for `jj squash` -pub fn squash_revision_files() -> Vec { +pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec { let rev = parse::parse_squash_revision().unwrap_or_else(|| "@".into()); - modified_files_from_rev((rev, None)) + modified_files_from_rev((rev, None), current) } /// Shell out to jj during dynamic completion generation diff --git a/cli/tests/test_completion.rs b/cli/tests/test_completion.rs index 87c3f65c7b9..bab6a64641c 100644 --- a/cli/tests/test_completion.rs +++ b/cli/tests/test_completion.rs @@ -416,6 +416,9 @@ fn create_commit( test_env.jj_cmd_ok(repo_path, &args); } for (name, content) in files { + if let Some((dir, _)) = name.rsplit_once('/') { + std::fs::create_dir_all(repo_path.join(dir)).unwrap(); + } match content { Some(content) => std::fs::write(repo_path.join(name), content).unwrap(), None => std::fs::remove_file(repo_path.join(name)).unwrap(), @@ -454,6 +457,9 @@ fn test_files() { ("f_renamed", Some("renamed\n")), ("f_deleted", None), ("f_added", Some("added\n")), + ("f_dir/dir_file_1", Some("foo\n")), + ("f_dir/dir_file_2", Some("foo\n")), + ("f_dir/dir_file_3", Some("foo\n")), ], ); @@ -466,6 +472,9 @@ fn test_files() { &[ ("f_modified", Some("modified_again\n")), ("f_added_2", Some("added_2\n")), + ("f_dir/dir_file_1", Some("bar\n")), + ("f_dir/dir_file_2", Some("bar\n")), + ("f_dir/dir_file_3", Some("bar\n")), ], ); test_env.jj_cmd_ok(&repo_path, &["rebase", "-r=@", "-d=first"]); @@ -484,19 +493,25 @@ fn test_files() { let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "--summary"]); insta::assert_snapshot!(stdout, @r" - @ yostqsxw test.user@example.com 2001-02-03 08:05:16 working copy 2da9afab + @ yostqsxw test.user@example.com 2001-02-03 08:05:16 working copy 689516a0 │ working copy │ A f_added_2 │ M f_modified - ○ zsuskuln test.user@example.com 2001-02-03 08:05:11 second 12ffc2f7 + ○ zsuskuln test.user@example.com 2001-02-03 08:05:11 second 77a99380 │ second │ A f_added │ D f_deleted + │ A f_dir/dir_file_1 + │ A f_dir/dir_file_2 + │ A f_dir/dir_file_3 │ M f_modified │ A f_renamed - │ × royxmykx test.user@example.com 2001-02-03 08:05:14 conflicted 14453858 conflict + │ × royxmykx test.user@example.com 2001-02-03 08:05:14 conflicted 23eb154d conflict ├─╯ conflicted │ A f_added_2 + │ A f_dir/dir_file_1 + │ A f_dir/dir_file_2 + │ A f_dir/dir_file_3 │ M f_modified ○ rlvkpnrz test.user@example.com 2001-02-03 08:05:09 first 2a2f433c │ first @@ -515,6 +530,7 @@ fn test_files() { insta::assert_snapshot!(stdout, @r" f_added f_added_2 + f_dir/ f_modified f_not_yet_renamed f_renamed @@ -527,25 +543,35 @@ fn test_files() { ); insta::assert_snapshot!(stdout, @r" f_added + f_dir/ f_modified f_not_yet_renamed f_renamed f_unchanged "); - let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "diff", "-r", "@-", "f_"]); insta::assert_snapshot!(stdout, @r" f_added Added f_deleted Deleted + f_dir/ f_modified Modified f_renamed Added "); + + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "diff", "-r", "@-", "f_dir/"]); + insta::assert_snapshot!(stdout, @r" + f_dir/dir_file_1 Added + f_dir/dir_file_2 Added + f_dir/dir_file_3 Added + "); + let stdout = test_env.jj_cmd_success( &repo_path, &["--", "jj", "diff", "--from", "root()", "--to", "@-", "f_"], ); insta::assert_snapshot!(stdout, @r" f_added Added + f_dir/ f_modified Added f_not_yet_renamed Added f_renamed Added @@ -558,6 +584,7 @@ fn test_files() { ); insta::assert_snapshot!(stdout, @r" f_added Added + f_dir/ f_modified Added f_not_yet_renamed Added f_renamed Added @@ -575,5 +602,8 @@ fn test_files() { let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "resolve", "-r=conflicted", "f_"]); - insta::assert_snapshot!(stdout, @"f_modified"); + insta::assert_snapshot!(stdout, @r" + f_dir/ + f_modified + "); }