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 81eb10d
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 51 deletions.
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
4 changes: 2 additions & 2 deletions 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 @@ -92,8 +93,7 @@ pub(crate) struct SquashArgs {
/// Move only changes to these paths (instead of all paths)
#[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
109 changes: 79 additions & 30 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,16 @@ 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();
};
with_jj(|jj, _| {
let mut child = jj
.build()
Expand All @@ -465,15 +474,28 @@ fn all_files_from_rev(rev: String) -> Vec<CompletionCandidate> {
.lines()
.take(1_000)
.map_while(Result::ok)
.map(CompletionCandidate::new)
.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_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 +506,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 +526,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 +556,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 81eb10d

Please sign in to comment.