Skip to content

Commit

Permalink
completion: suggest file paths incrementally
Browse files Browse the repository at this point in the history
If there are multiple files in a subdirectory that are candidates for
completion, only complete the common directory prefix to reduce the number of
completion candidates shown at once.

This matches the normal shell completion of file paths.
  • Loading branch information
senekor committed Nov 29, 2024
1 parent 172fbd4 commit eb0d240
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 50 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dunce = { workspace = true }
futures = { workspace = true }
git2 = { workspace = true }
gix = { workspace = true }
glob = { workspace = true }
indexmap = { workspace = true }
indoc = { workspace = true }
itertools = { workspace = true }
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,7 +44,7 @@ pub(crate) struct CommitArgs {
/// Put these paths in the first commit
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::modified_files),
add = ArgValueCompleter::new(complete::modified_files),
)]
paths: Vec<String>,
/// Reset the author to the configured user
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,7 +60,7 @@ pub(crate) struct DiffArgs {
/// Restrict the diff to these paths
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::modified_revision_or_range_files),
add = ArgValueCompleter::new(complete::modified_revision_or_range_files),
)]
paths: Vec<String>,
#[command(flatten)]
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/interdiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,7 +45,7 @@ pub(crate) struct InterdiffArgs {
/// Restrict the diff to these paths
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::interdiff_files),
add = ArgValueCompleter::new(complete::interdiff_files),
)]
paths: Vec<String>,
#[command(flatten)]
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,7 +65,7 @@ pub(crate) struct ResolveArgs {
// TODO: Find the conflict we can resolve even if it's not the first one.
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::revision_conflicted_files),
add = ArgValueCompleter::new(complete::revision_conflicted_files),
)]
paths: Vec<String>,
}
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,7 +48,7 @@ pub(crate) struct RestoreArgs {
/// Restore only these paths (instead of all paths)
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::modified_range_files),
add = ArgValueCompleter::new(complete::modified_range_files),
)]
paths: Vec<String>,
/// Revision to restore from (source)
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,7 +69,7 @@ pub(crate) struct SplitArgs {
/// Put these paths in the first commit
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::modified_revision_files),
add = ArgValueCompleter::new(complete::modified_revision_files),
)]
paths: Vec<String>,
}
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/squash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,7 +94,7 @@ pub(crate) struct SquashArgs {
#[arg(
conflicts_with_all = ["interactive", "tool"],
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCandidates::new(complete::squash_revision_files),
add = ArgValueCompleter::new(complete::squash_revision_files),
)]
paths: Vec<String>,
/// The source revision will not be abandoned
Expand Down
110 changes: 80 additions & 30 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,14 +448,27 @@ pub fn leaf_config_keys() -> Vec<CompletionCandidate> {
config_keys_impl(true)
}

fn all_files_from_rev(rev: String) -> Vec<CompletionCandidate> {
fn dir_prefix_from<'a>(path: &'a str, current: &str) -> Option<&'a str> {
path.strip_prefix(current)?
.split_once(std::path::MAIN_SEPARATOR)
.map(|(next, _)| path.split_at(current.len() + next.len() + 1).0)
}

fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let Some(current) = current.to_str() else {
return Vec::new();
};
let cur_esc = glob::Pattern::escape(current);
with_jj(|jj, _| {
let mut child = jj
.build()
.arg("file")
.arg("list")
.arg("--revision")
.arg(rev)
.arg("--config-toml")
.arg("ui.allow-filesets = true")
.arg(format!(r#"glob:"{cur_esc}*/**" | glob:"{cur_esc}*""#))
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(user_error)?;
Expand All @@ -465,15 +478,25 @@ fn all_files_from_rev(rev: String) -> Vec<CompletionCandidate> {
.lines()
.take(1_000)
.map_while(Result::ok)
.map(CompletionCandidate::new)
.map(|path| {
if let Some(dir_path) = dir_prefix_from(&path, current) {
return CompletionCandidate::new(dir_path);
}
CompletionCandidate::new(path)
})
.dedup() // directories may occur multiple times
.collect())
})
}

fn modified_files_from_rev_with_jj_cmd(
rev: (String, Option<String>),
mut cmd: std::process::Command,
current: &std::ffi::OsStr,
) -> Result<Vec<CompletionCandidate>, CommandError> {
let Some(current) = current.to_str() else {
return Ok(Vec::new());
};
cmd.arg("diff").arg("--summary");
match rev {
(rev, None) => cmd.arg("--revision").arg(rev),
Expand All @@ -484,10 +507,18 @@ fn modified_files_from_rev_with_jj_cmd(

Ok(stdout
.lines()
.map(|line| {
.filter_map(|line| {
let (mode, path) = line
.split_once(' ')
.expect("diff --summary should contain a space between mode and path");

if !path.starts_with(current) {
return None;
}
if let Some(dir_path) = dir_prefix_from(path, current) {
return Some(CompletionCandidate::new(dir_path));
}

let help = match mode {
"M" => "Modified".into(),
"D" => "Deleted".into(),
Expand All @@ -496,16 +527,23 @@ fn modified_files_from_rev_with_jj_cmd(
"C" => "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 modified_files_from_rev(rev: (String, Option<String>)) -> Vec<CompletionCandidate> {
with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build()))
fn modified_files_from_rev(
rev: (String, Option<String>),
current: &std::ffi::OsStr,
) -> Vec<CompletionCandidate> {
with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build(), current))
}

fn conflicted_files_from_rev(rev: &str) -> Vec<CompletionCandidate> {
fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let Some(current) = current.to_str() else {
return Vec::new();
};
with_jj(|jj, _| {
let output = jj
.build()
Expand All @@ -519,62 +557,74 @@ fn conflicted_files_from_rev(rev: &str) -> Vec<CompletionCandidate> {

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<CompletionCandidate> {
modified_files_from_rev(("@".into(), None))
pub fn modified_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
modified_files_from_rev(("@".into(), None), current)
}

pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
// TODO: Use `current` once `jj file list` gains the ability to list only
// the content of the "current" directory.
let _ = current;
all_files_from_rev(parse::revision_or_wc())
all_files_from_rev(parse::revision_or_wc(), current)
}

pub fn modified_revision_files() -> Vec<CompletionCandidate> {
modified_files_from_rev((parse::revision_or_wc(), None))
pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
modified_files_from_rev((parse::revision_or_wc(), None), current)
}

pub fn modified_range_files() -> Vec<CompletionCandidate> {
pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
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<CompletionCandidate> {
pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
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<CompletionCandidate> {
conflicted_files_from_rev(&parse::revision_or_wc())
pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
conflicted_files_from_rev(&parse::revision_or_wc(), current)
}

/// Specific function for completing file paths for `jj squash`
pub fn squash_revision_files() -> Vec<CompletionCandidate> {
pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let rev = parse::squash_revision().unwrap_or_else(|| "@".into());
modified_files_from_rev((rev, None))
modified_files_from_rev((rev, None), current)
}

/// Specific function for completing file paths for `jj interdiff`
pub fn interdiff_files() -> Vec<CompletionCandidate> {
pub fn interdiff_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let Some((from, to)) = parse::range() else {
return Vec::new();
};
// Complete all modified files in "from" and "to". This will also suggest
// files that are the same in both, which is a false positive. This approach
// is more lightweight than actually doing a temporary rebase here.
with_jj(|jj, _| {
let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build())?;
res.extend(modified_files_from_rev_with_jj_cmd((to, None), jj.build())?);
let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build(), current)?;
res.extend(modified_files_from_rev_with_jj_cmd(
(to, None),
jj.build(),
current,
)?);
Ok(res)
})
}
Expand Down
Loading

0 comments on commit eb0d240

Please sign in to comment.