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 Dec 1, 2024
1 parent 0ca6f00 commit a8c35db
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 49 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
117 changes: 88 additions & 29 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,14 +448,33 @@ 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[current.len()..]
.split_once(std::path::MAIN_SEPARATOR)
.map(|(next, _)| path.split_at(current.len() + next.len() + 1).0)
}

fn current_prefix_to_fileset(current: &str) -> String {
let cur_esc = glob::Pattern::escape(current);
let dir_pat = format!("{cur_esc}*/**");
let path_pat = format!("{cur_esc}*");
format!("glob:{dir_pat:?} | glob:{path_pat:?}")
}

fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let Some(current) = current.to_str() else {
return Vec::new();
};
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(current_prefix_to_fileset(current))
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(user_error)?;
Expand All @@ -465,16 +484,30 @@ 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> {
cmd.arg("diff").arg("--summary");
let Some(current) = current.to_str() else {
return Ok(Vec::new());
};
cmd.arg("diff")
.arg("--summary")
.arg("--config-toml")
.arg("ui.allow-filesets = true")
.arg(current_prefix_to_fileset(current));
match rev {
(rev, None) => cmd.arg("--revision").arg(rev),
(from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to),
Expand All @@ -488,6 +521,11 @@ fn modified_files_from_rev_with_jj_cmd(
let (mode, path) = line
.split_once(' ')
.expect("diff --summary should contain a space between mode and path");

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

let help = match mode {
"M" => "Modified".into(),
"D" => "Deleted".into(),
Expand All @@ -498,83 +536,104 @@ fn modified_files_from_rev_with_jj_cmd(
};
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()
.arg("resolve")
.arg("--list")
.arg("--revision")
.arg(rev)
.arg("--config-toml")
.arg("ui.allow-filesets = true")
.arg(current_prefix_to_fileset(current))
.output()
.map_err(user_error)?;
let stdout = String::from_utf8_lossy(&output.stdout);

Ok(stdout
.lines()
.filter_map(|line| line.split_whitespace().next())
.map(CompletionCandidate::new)
.map(|line| {
let path = line
.split_whitespace()
.next()
.expect("resolve --list should contain whitespace after 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())
})
}

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 a8c35db

Please sign in to comment.