Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ edits the change twice in some cases.
* Fixed parsing of `files(expr)` revset expression including parentheses.
[#7747](https://github.com/jj-vcs/jj/issues/7747)

* Shell completion now works with non‑normalized paths, fixing the previous
panic and allowing prefixes containing `.` or `..` to be completed correctly.
[#6861](https://github.com/jj-vcs/jj/issues/6861)

## [0.34.0] - 2025-10-01

### Release highlights
Expand Down
108 changes: 66 additions & 42 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use clap_complete::CompletionCandidate;
use indoc::indoc;
use itertools::Itertools as _;
use jj_lib::config::ConfigNamePathBuf;
use jj_lib::file_util::normalize_path;
use jj_lib::settings::UserSettings;
use jj_lib::workspace::DefaultWorkspaceLoaderFactory;
use jj_lib::workspace::WorkspaceLoaderFactory as _;
Expand Down Expand Up @@ -739,10 +740,57 @@ pub fn branch_name_equals_any_revision(current: &std::ffi::OsStr) -> Vec<Complet
.collect()
}

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 is_path_separator(c: char) -> bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to use std::path::is_separator instead of manually defining this function

c == '/' || c == std::path::MAIN_SEPARATOR
}

fn path_completion_candidate_from(
current_prefix: &str,
path: &str,
mode: Option<&str>,
) -> Option<CompletionCandidate> {
// The user-provided completion string might not prefix `path` unless cast in
// normal form.
let normalized_prefix_path = normalize_path(Path::new(current_prefix));
let normalized_prefix = match normalized_prefix_path.to_str() {
None => current_prefix,
Some(".") => "", // `.` cannot be normalized further, but doesn't prefix `path`.
Some(normalized_prefix) => normalized_prefix,
};

let mut remainder = path.strip_prefix(normalized_prefix)?;

// Trailing slash might have been normalized away in which case we need to strip
// the leading slash in the remainder away, or else the slash would appear
// twice.
if current_prefix.ends_with(is_path_separator) {
remainder = remainder
.strip_prefix(is_path_separator)
.unwrap_or(remainder);
}

let (completion, help) = match remainder.split_inclusive(is_path_separator).at_most_one() {
// Completed component is the final component in `path`, so we're completing the file to
// which `mode` refers.
Ok(file_completion) => (
file_completion?,
mode.map(|mode| match mode {
"modified" => "Modified".into(),
"removed" => "Deleted".into(),
"added" => "Added".into(),
"renamed" => "Renamed".into(),
"copied" => "Copied".into(),
_ => format!("unknown mode: '{mode}'").into(),
}),
),

// Omit `mode` when completing only up to the next directory.
Err(mut components) => (components.next().unwrap(), None),
};

// Completion candidate splices normalized completion onto the user-provided
// prefix.
Some(CompletionCandidate::new(format!("{current_prefix}{completion}")).help(help))
}

fn current_prefix_to_fileset(current: &str) -> String {
Expand Down Expand Up @@ -774,12 +822,7 @@ fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionC
.lines()
.take(1_000)
.map_while(Result::ok)
.map(|path| {
if let Some(dir_path) = dir_prefix_from(&path, current) {
return CompletionCandidate::new(dir_path);
}
CompletionCandidate::new(path)
})
.filter_map(|path| path_completion_candidate_from(current, &path, None))
.dedup() // directories may occur multiple times
.collect())
})
Expand Down Expand Up @@ -811,36 +854,20 @@ fn modified_files_from_rev_with_jj_cmd(
let output = cmd.output().map_err(user_error)?;
let stdout = String::from_utf8_lossy(&output.stdout);

let mut candidates = Vec::new();
let mut include_renames = false;

for (mode, path) in stdout.lines().filter_map(|line| line.split_once(' ')) {
fn path_to_candidate(current: &str, mode: &str, path: &str) -> CompletionCandidate {
if let Some(dir_path) = dir_prefix_from(path, current) {
return CompletionCandidate::new(dir_path);
let mut candidates: Vec<_> = stdout
.lines()
.filter_map(|line| line.split_once(' '))
.filter_map(|(mode, path)| {
if mode == "renamed.source" {
path_completion_candidate_from(current, path, Some("renamed"))
.inspect(|_| include_renames |= true)
} else {
path_completion_candidate_from(current, path, Some(mode))
}
})
.collect();

let help = match mode {
"modified" => "Modified".into(),
"removed" => "Deleted".into(),
"added" => "Added".into(),
"renamed" => "Renamed".into(),
"copied" => "Copied".into(),
_ => format!("unknown mode: '{mode}'"),
};
CompletionCandidate::new(path).help(Some(help.into()))
}

if mode == "renamed.source" {
if !path.starts_with(current) {
continue;
}
candidates.push(path_to_candidate(current, "renamed", path));
include_renames |= true;
} else {
candidates.push(path_to_candidate(current, mode, path));
}
}
if include_renames {
candidates.sort_unstable_by(|a, b| Path::new(a.get_value()).cmp(Path::new(b.get_value())));
}
Expand Down Expand Up @@ -874,16 +901,13 @@ fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec<Comple

Ok(stdout
.lines()
.map(|line| {
.filter_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)
path_completion_candidate_from(current, path, None)
})
.dedup() // directories may occur multiple times
.collect())
Expand Down
68 changes: 68 additions & 0 deletions cli/tests/test_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,58 @@ fn test_files() {
[EOF]
");

let output = work_dir.complete_fish(["file", "show", "./f_"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
./f_added
./f_added_2
./f_another_renamed_2
./f_copied
./f_dir/
./f_modified
./f_not_yet_copied
./f_renamed
./f_unchanged
[EOF]
");

let output = work_dir.complete_fish(["file", "show", "f_dir"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_dir/
[EOF]
");

let output = work_dir.complete_fish(["file", "show", "f_dir/"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_dir/dir_file_1
f_dir/dir_file_2
f_dir/dir_file_3
f_dir/f_renamed_3
[EOF]
");

let output = work_dir.complete_fish(["file", "show", "f_dir/../"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_dir/../f_added
f_dir/../f_added_2
f_dir/../f_another_renamed_2
f_dir/../f_copied
f_dir/../f_dir/
f_dir/../f_modified
f_dir/../f_not_yet_copied
f_dir/../f_renamed
f_dir/../f_unchanged
[EOF]
");

let output = work_dir.complete_fish(["file", "show", "f_dir/../f_dir/"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_dir/../f_dir/dir_file_1
f_dir/../f_dir/dir_file_2
f_dir/../f_dir/dir_file_3
f_dir/../f_dir/f_renamed_3
[EOF]
");

let output = work_dir.complete_fish(["file", "annotate", "-r@-", "f_"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_added
Expand Down Expand Up @@ -1429,6 +1481,22 @@ fn test_files() {
[EOF]
");

let output = work_dir.complete_fish(["diff", "-r", "@-", "f_dir/../"]);
insta::assert_snapshot!(output.normalize_backslash(), @r"
f_dir/../f_added Added
f_dir/../f_another_renamed_2 Renamed
f_dir/../f_copied Copied
f_dir/../f_deleted Deleted
f_dir/../f_dir/
f_dir/../f_modified Modified
f_dir/../f_not_yet_copied Modified
f_dir/../f_not_yet_renamed Renamed
f_dir/../f_not_yet_renamed_2 Renamed
f_dir/../f_not_yet_renamed_3 Renamed
f_dir/../f_renamed Renamed
[EOF]
");

let output = work_dir.complete_fish([
"diff",
"-r",
Expand Down