feat: show repository changes in normal file pickers

This commit is contained in:
2026-10-13 21:10:20 +02:00
parent e90d1c3945
commit 9526876b04
12 changed files with 2169 additions and 33 deletions
+396 -1
View File
@@ -1,3 +1,5 @@
use crate::diff::FileStatus;
use crate::repository::WorkingFileChange;
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::fs;
@@ -39,6 +41,8 @@ pub struct FileRow<'a> {
pub label: &'a str,
/// Original virtual-catalog input index; absent for scanned entries and directories.
pub catalog_index: Option<usize>,
pub status: Option<FileStatus>,
pub old_path: Option<&'a Path>,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
@@ -51,6 +55,9 @@ struct Node {
search_label: String,
catalog_index: Option<usize>,
is_dir: bool,
status: Option<FileStatus>,
old_path: Option<PathBuf>,
virtual_entry: bool,
parent: Option<usize>,
children: Vec<usize>,
}
@@ -92,6 +99,9 @@ pub struct FilePicker {
filtered_selection: [Option<usize>; 2],
tree_searching: bool,
warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
has_repository_changes: bool,
has_virtual_entries: bool,
}
fn label(path: &Path) -> String {
@@ -101,6 +111,28 @@ fn label(path: &Path) -> String {
.collect()
}
fn safe_repository_path(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& path.components().all(|component| {
matches!(component, std::path::Component::Normal(name) if name != ".git" && name != ".jj")
})
}
fn missing_repository_path(root: &Path, path: &Path) -> bool {
// A deleted leaf may be absent with all its parents, but never walk through symlinks.
let mut current = root.to_owned();
for component in path.components() {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return false,
Err(error) if error.kind() == io::ErrorKind::NotFound => return true,
Err(_) => return false,
}
}
false
}
impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() {
@@ -156,6 +188,9 @@ impl FilePicker {
search_label: label.to_lowercase(),
label,
catalog_index: None,
status: None,
old_path: None,
virtual_entry: false,
is_dir: entry.file_type().unwrap().is_dir(),
parent: None,
children: Vec::new(),
@@ -186,6 +221,9 @@ impl FilePicker {
search_label: label.to_lowercase(),
label,
catalog_index,
status: None,
old_path: None,
virtual_entry: false,
is_dir,
parent: None,
children: Vec::new(),
@@ -255,6 +293,7 @@ impl FilePicker {
focused: true,
error: warning.clone(),
tree_for_node: vec![0; nodes.len()],
has_virtual_entries: nodes.iter().any(|node| node.virtual_entry),
nodes,
tree: Vec::new(),
tree_roots: Vec::new(),
@@ -265,6 +304,8 @@ impl FilePicker {
filtered_selection: [None, None],
tree_searching: false,
warning,
repository_changes: Vec::new(),
has_repository_changes: false,
};
for root in roots {
let tree = picker.compact(root, None);
@@ -315,6 +356,8 @@ impl FilePicker {
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir,
catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.as_deref(),
depth: row.depth,
expanded: row.expanded,
}
@@ -721,11 +764,158 @@ impl FilePicker {
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(replacement) => self.replace_catalog(replacement),
Ok(mut replacement) => {
replacement.set_repository_changes(self.repository_changes.clone());
self.replace_catalog(replacement);
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
pub(crate) fn has_repository_changes(&self) -> bool {
self.has_repository_changes
}
pub(crate) fn set_repository_changes(&mut self, changes: Vec<WorkingFileChange>) -> bool {
if !self.has_virtual_entries && changes == self.repository_changes {
return false;
}
if self.nodes.iter().any(|node| {
node.virtual_entry
&& !node.is_dir
&& fs::symlink_metadata(self.root.join(&node.path))
.is_ok_and(|metadata| metadata.is_file())
}) {
// Reuse the scanner's ignore/symlink rules when a deleted file returns.
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(mut replacement) => {
replacement.set_repository_changes(changes);
replacement.error = self.error.clone().or(replacement.error);
self.replace_catalog(replacement);
return true;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
}
}
let changes: Vec<_> = changes
.into_iter()
.filter(|change| safe_repository_path(&change.path))
.map(|mut change| {
change.old_path = change.old_path.filter(|path| safe_repository_path(path));
change
})
.collect();
let by_path: HashMap<_, _> = changes
.iter()
.map(|change| (change.path.as_path(), change))
.collect();
let scanned: HashSet<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| (node.path.as_path(), node.is_dir))
.collect();
let mut virtual_files = HashSet::new();
let mut virtual_dirs = HashSet::new();
for change in &changes {
if change.status != FileStatus::Removed
|| scanned.contains(&(change.path.as_path(), false))
|| !missing_repository_path(&self.root, &change.path)
{
continue;
}
virtual_files.insert(change.path.clone());
for parent in change.path.ancestors().skip(1) {
if parent.as_os_str().is_empty() {
break;
}
if !scanned.contains(&(parent, true)) && !virtual_dirs.contains(parent) {
virtual_dirs.insert(parent.to_owned());
}
}
}
let topology_changed = self.nodes.iter().filter(|node| node.virtual_entry).count()
!= virtual_files.len() + virtual_dirs.len()
|| self
.nodes
.iter()
.filter(|node| node.virtual_entry)
.any(|node| {
!(if node.is_dir {
&virtual_dirs
} else {
&virtual_files
})
.contains(node.path.as_path())
});
let mut metadata_changed = false;
for node in &mut self.nodes {
let change = (!node.is_dir && node.catalog_index.is_none())
.then(|| by_path.get(node.path.as_path()))
.flatten();
let status = change.map(|change| change.status);
let old_path = change.and_then(|change| change.old_path.as_ref());
metadata_changed |= node.status != status || node.old_path.as_ref() != old_path;
node.status = status;
if node.old_path.as_ref() != old_path {
node.old_path = old_path.cloned();
}
}
if topology_changed {
let mut nodes: Vec<_> = self
.nodes
.iter()
.filter(|node| !node.virtual_entry)
.map(|node| Node {
path: node.path.clone(),
label: node.label.clone(),
search_label: node.search_label.clone(),
catalog_index: node.catalog_index,
status: node.status,
old_path: node.old_path.clone(),
virtual_entry: false,
is_dir: node.is_dir,
parent: None,
children: Vec::new(),
})
.collect();
nodes.extend(
virtual_files
.into_iter()
.map(|path| (path, false))
.chain(virtual_dirs.into_iter().map(|path| (path, true)))
.map(|(path, is_dir)| {
let label = label(&path);
let change = (!is_dir).then(|| by_path.get(path.as_path())).flatten();
Node {
status: change.map(|change| change.status),
old_path: change.and_then(|change| change.old_path.clone()),
path,
search_label: label.to_lowercase(),
label,
catalog_index: None,
virtual_entry: true,
is_dir,
parent: None,
children: Vec::new(),
}
}),
);
let mut replacement = Self::from_nodes(
self.root.clone(),
nodes,
self.presentation,
self.view,
self.warning.clone(),
);
replacement.error = self.error.clone();
self.replace_catalog(replacement);
}
self.has_repository_changes = self.nodes.iter().any(|node| node.status.is_some());
self.repository_changes = changes;
topology_changed || metadata_changed
}
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
}
@@ -835,6 +1025,7 @@ mod tests {
assert!(!rows.is_empty());
for row in rows {
assert_eq!(row.catalog_index, expected_index(&row));
assert!(row.status.is_none() && row.old_path.is_none());
}
}
}
@@ -881,9 +1072,213 @@ mod tests {
picker.set_view(view);
picker.reveal_path(Path::new("nested/file.txt"));
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
}
}
fn repository_change(
path: &str,
status: FileStatus,
old_path: Option<&str>,
) -> WorkingFileChange {
WorkingFileChange {
path: path.into(),
status,
old_path: old_path.map(PathBuf::from),
}
}
#[test]
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
let directory = Directory::new();
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
directory.put(name, "");
}
let mut picker = directory.scan();
picker.presentation = FilePresentation::Docked;
picker.set_view(FileView::Tree);
picker.reveal_path(Path::new("src/clean.rs"));
picker.query = "src".into();
picker.refilter();
picker.focused = false;
let changes = vec![
repository_change("src/modified.rs", FileStatus::Modified, None),
repository_change("added.rs", FileStatus::Added, None),
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
];
assert!(picker.set_repository_changes(changes.clone()));
assert!(!picker.set_repository_changes(changes));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert_eq!(picker.query, "src");
assert!(!picker.focused);
assert_eq!(picker.presentation, FilePresentation::Docked);
assert!(picker.has_repository_changes());
for view in [FileView::List, FileView::Tree] {
picker.set_view(view);
for query in ["", "src", "modified.rs", "deleted.rs"] {
picker.query = query.into();
picker.refilter();
let rows: Vec<_> = picker.entries().collect();
assert!(!rows.is_empty());
for row in rows {
assert!(row.catalog_index.is_none());
if row.is_dir || row.path == Path::new("src/clean.rs") {
assert!(row.status.is_none() && row.old_path.is_none());
} else {
let status = match row.path.to_str().unwrap() {
"src/modified.rs" => FileStatus::Modified,
"added.rs" => FileStatus::Added,
"renamed.rs" => FileStatus::Renamed,
"vanished/deep/deleted.rs" => FileStatus::Removed,
other => panic!("unexpected {other}"),
};
assert_eq!(row.status, Some(status));
assert_eq!(
row.old_path,
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
);
}
}
if query.is_empty() || query == "src" {
assert!(
picker
.entries()
.any(|row| row.path == Path::new("src/clean.rs"))
);
}
}
}
picker.set_view(FileView::List);
picker.clear_filter();
picker.reveal_path(Path::new("src/clean.rs"));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
assert!(!picker.has_repository_changes());
assert_eq!(picker.entries().len(), 4);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(
picker
.entries()
.all(|row| row.status.is_none() && row.old_path.is_none())
);
assert!(!picker.set_repository_changes(Vec::new()));
}
#[test]
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
let directory = Directory::new();
directory.put("keep.txt", "");
let mut picker = directory.scan();
let deleted = Path::new("lost/deep/deleted.txt");
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.set_view(FileView::Tree);
assert_eq!(
picker.entries().find(|row| row.is_dir).unwrap().label,
"lost/deep"
);
picker.reveal_path(deleted);
picker.query = "deleted".into();
picker.refilter();
picker.start_search();
picker.focused = false;
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.searching && !picker.focused);
assert_eq!(picker.query, "deleted");
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert!(!directory.0.join(deleted).exists());
assert_eq!(
picker.entries().find(|row| !row.is_dir).unwrap().status,
Some(FileStatus::Removed)
);
directory.put("lost/deep/deleted.txt", "restored");
assert!(picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]));
picker.refresh();
assert_eq!(selected_path(&picker), deleted);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
assert!(picker.set_repository_changes(Vec::new()));
assert_eq!(selected_path(&picker), deleted);
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
assert_eq!(
fs::read_to_string(directory.0.join(deleted)).unwrap(),
"restored"
);
assert!(picker.entries().all(|row| row.status.is_none()));
fs::remove_dir_all(directory.0.join("lost")).unwrap();
picker.refresh();
picker.set_repository_changes(vec![repository_change(
"lost/deep/deleted.txt",
FileStatus::Removed,
None,
)]);
picker.reveal_path(deleted);
picker.set_repository_changes(Vec::new());
assert!(picker.entries().len() == 0);
picker.clear_filter();
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
}
#[test]
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
let directory = Directory::new();
directory.put(".gitignore", "ignored/\n");
directory.put("ignored/hidden.txt", "");
directory.put("directory/keep.txt", "");
let mut picker = directory.scan();
let mut changes: Vec<_> = [
"",
".",
"../outside.txt",
"directory/../../outside.txt",
".git/secret",
"nested/.jj/secret",
"/outside.txt",
"directory",
"ignored/hidden.txt",
]
.into_iter()
.map(|path| repository_change(path, FileStatus::Removed, None))
.collect();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
changes.push(repository_change(
"link/missing.txt",
FileStatus::Removed,
None,
));
}
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
picker.set_repository_changes(changes);
assert_eq!(
paths(&picker),
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
);
assert_eq!(
picker.entries().filter(|row| row.status.is_some()).count(),
1
);
assert_eq!(
picker.entries().last().unwrap().status,
Some(FileStatus::Removed)
);
assert!(!directory.0.join("missing.txt").exists());
}
#[test]
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
let directory = Directory::new();