feat: show repository changes in normal file pickers
This commit is contained in:
+396
-1
@@ -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(¤t) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user