feat: show repository changes in normal file pickers
This commit is contained in:
@@ -185,6 +185,7 @@ pub(crate) fn run_status(
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_NO_REPLACE_OBJECTS", "1")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
+26
-1
@@ -1351,6 +1351,7 @@ pub struct DocumentInfo<'a> {
|
||||
pub path: Option<&'a Path>,
|
||||
pub lines: &'a [String],
|
||||
pub save_revision: u64,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
struct AnnotationSet {
|
||||
@@ -1674,6 +1675,7 @@ impl Buffer {
|
||||
.or(self.path.as_deref()),
|
||||
lines: &self.lines,
|
||||
save_revision: self.save_revision,
|
||||
dirty: self.dirty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4693,6 +4695,29 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normal_file_picker(&self) -> Option<&FilePicker> {
|
||||
self.file_picker.as_ref().filter(|_| self.diff.is_none())
|
||||
}
|
||||
|
||||
pub(crate) fn set_picker_repository_changes(
|
||||
&mut self,
|
||||
tab: usize,
|
||||
root: &Path,
|
||||
changes: Vec<crate::repository::WorkingFileChange>,
|
||||
) -> bool {
|
||||
let picker = if tab == self.tab_id {
|
||||
self.file_picker.as_mut().filter(|_| self.diff.is_none())
|
||||
} else {
|
||||
self.tabs
|
||||
.get_mut(&tab)
|
||||
.and_then(|tab| tab.file_picker.as_mut().filter(|_| tab.diff.is_none()))
|
||||
};
|
||||
let Some(picker) = picker.filter(|picker| picker.root == root) else {
|
||||
return false;
|
||||
};
|
||||
picker.set_repository_changes(changes)
|
||||
}
|
||||
|
||||
pub fn file_explorer_side(&self) -> DockSide {
|
||||
self.file_explorer_side
|
||||
}
|
||||
@@ -10485,7 +10510,7 @@ fn lsp_navigation_position(
|
||||
Ok(TextPosition { row, column })
|
||||
}
|
||||
|
||||
fn path_identity(path: &Path) -> io::Result<PathBuf> {
|
||||
pub(crate) fn path_identity(path: &Path) -> io::Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
|
||||
+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();
|
||||
|
||||
+99
-11
@@ -3,7 +3,9 @@ use crate::{
|
||||
blame::{git, git_without_literal_pathspecs, run, run_status},
|
||||
diff::line_hunks,
|
||||
editor::normalize_newlines,
|
||||
repository::{jj_command, jj_file_pattern, safe_path, store_for_jj},
|
||||
repository::{
|
||||
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
@@ -23,7 +25,13 @@ const FILE_COUNT_LIMIT: usize = 1024;
|
||||
type Files = Vec<(usize, PathBuf, Arc<str>)>;
|
||||
type FileResult = Result<Vec<Option<Change>>, String>;
|
||||
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
|
||||
type Request = (u64, u64, Files);
|
||||
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
|
||||
type Request = (u64, u64, Files, Option<PickerRequest>);
|
||||
|
||||
pub(crate) struct PickerChanges {
|
||||
pub(crate) root: PathBuf,
|
||||
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -45,7 +53,7 @@ pub(crate) enum Change {
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
request: Option<Request>,
|
||||
result: Option<(u64, ResultBatch)>,
|
||||
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
@@ -65,6 +73,7 @@ impl Worker {
|
||||
let thread = thread::Builder::new()
|
||||
.name("vamp-gutter".into())
|
||||
.spawn(move || {
|
||||
let mut picker_reader = WorkingFileReader::default();
|
||||
loop {
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
@@ -78,7 +87,7 @@ impl Worker {
|
||||
if worker.stopped.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (generation, id, files) = state.request.take().unwrap();
|
||||
let (generation, id, files, picker) = state.request.take().unwrap();
|
||||
drop(state);
|
||||
let cancelled = || {
|
||||
worker.stopped.load(Ordering::Acquire)
|
||||
@@ -91,11 +100,15 @@ impl Worker {
|
||||
}
|
||||
results.push((buffer, read_file(&path, &contents, &cancelled)));
|
||||
}
|
||||
let picker = picker.map(|(root, buffers)| PickerChanges {
|
||||
result: picker_reader.read(&root, &buffers, &cancelled),
|
||||
root,
|
||||
});
|
||||
let Ok(mut state) = worker.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if !cancelled() {
|
||||
state.result = Some((id, results));
|
||||
state.result = Some((id, results, picker));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
@@ -105,12 +118,31 @@ impl Worker {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&mut self, id: u64, files: Files) -> io::Result<()> {
|
||||
pub(crate) fn request(
|
||||
&mut self,
|
||||
id: u64,
|
||||
files: Files,
|
||||
picker: Option<PickerRequest>,
|
||||
) -> io::Result<()> {
|
||||
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
});
|
||||
if files.len() > FILE_COUNT_LIMIT || size.is_none_or(|size| size > BATCH_LIMIT) {
|
||||
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
|
||||
buffers
|
||||
.iter()
|
||||
.try_fold(root.as_os_str().len(), |size, (path, text)| {
|
||||
size.checked_add(text.len())?
|
||||
.checked_add(path.as_os_str().len())
|
||||
})
|
||||
});
|
||||
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
|
||||
if count > FILE_COUNT_LIMIT
|
||||
|| size
|
||||
.zip(picker_size)
|
||||
.and_then(|(files, picker)| files.checked_add(picker))
|
||||
.is_none_or(|size| size > BATCH_LIMIT)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Gutter batch exceeds 64 MiB or 1024 files",
|
||||
@@ -126,13 +158,13 @@ impl Worker {
|
||||
.generation
|
||||
.fetch_add(1, Ordering::AcqRel)
|
||||
.wrapping_add(1);
|
||||
state.request = Some((generation, id, files));
|
||||
state.request = Some((generation, id, files, picker));
|
||||
state.result = None;
|
||||
self.shared.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch)> {
|
||||
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
|
||||
self.shared.state.lock().ok()?.result.take()
|
||||
}
|
||||
}
|
||||
@@ -766,11 +798,13 @@ mod tests {
|
||||
(5, path.clone(), "first\nchanged\nthird\n".into()),
|
||||
(8, path.clone(), Arc::clone(&oversized)),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
loop {
|
||||
if let Some((id, result)) = worker.poll() {
|
||||
if let Some((id, result, picker)) = worker.poll() {
|
||||
assert!(picker.is_none());
|
||||
assert_eq!(id, 19);
|
||||
assert_eq!(result[0].0, 5);
|
||||
assert_eq!(
|
||||
@@ -788,10 +822,64 @@ mod tests {
|
||||
}
|
||||
assert_eq!(
|
||||
worker
|
||||
.request(20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())])
|
||||
.request(
|
||||
20,
|
||||
vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())],
|
||||
None
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_status_is_optional_and_errors_do_not_discard_line_results() {
|
||||
let (temp, path) = git_fixture();
|
||||
let mut worker = Worker::start().unwrap();
|
||||
worker
|
||||
.request(
|
||||
1,
|
||||
vec![],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path.clone(), "dirty buffer\n".into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 1);
|
||||
assert!(lines.is_empty());
|
||||
let picker = picker.unwrap();
|
||||
assert_eq!(picker.root, temp.0);
|
||||
let changes = picker.result.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path, path.strip_prefix(&temp.0).unwrap());
|
||||
assert_eq!(changes[0].status, crate::diff::FileStatus::Modified);
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
worker
|
||||
.request(
|
||||
2,
|
||||
vec![(4, path.clone(), "first\nchanged\nthird\n".into())],
|
||||
Some((
|
||||
temp.0.clone(),
|
||||
vec![(path, "x".repeat(FILE_LIMIT + 1).into())],
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
loop {
|
||||
if let Some((id, lines, picker)) = worker.poll() {
|
||||
assert_eq!(id, 2);
|
||||
assert_eq!(lines[0].0, 4);
|
||||
assert!(lines[0].1.is_ok());
|
||||
assert!(picker.unwrap().result.unwrap_err().contains("16 MiB"));
|
||||
break;
|
||||
}
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1188
File diff suppressed because it is too large
Load Diff
+181
-9
@@ -170,6 +170,7 @@ struct GutterSource {
|
||||
revision: u64,
|
||||
save_revision: u64,
|
||||
path: PathBuf,
|
||||
dirty: bool,
|
||||
contents: Arc<str>,
|
||||
}
|
||||
|
||||
@@ -198,6 +199,7 @@ pub struct EditorSession {
|
||||
gutter_request_id: u64,
|
||||
gutter_pending: bool,
|
||||
last_gutter_refresh: Option<Instant>,
|
||||
picker_source: Option<(usize, PathBuf)>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
last_file_refresh: Instant,
|
||||
@@ -312,6 +314,7 @@ impl EditorSession {
|
||||
gutter_request_id: 0,
|
||||
gutter_pending: false,
|
||||
last_gutter_refresh: None,
|
||||
picker_source: None,
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
last_file_refresh: Instant::now(),
|
||||
@@ -356,6 +359,14 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, input: Input) {
|
||||
if input == Input::Ctrl('r')
|
||||
&& self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.is_some_and(|picker| picker.focused)
|
||||
{
|
||||
self.last_gutter_refresh = None;
|
||||
}
|
||||
self.cancel_pending_popup_request();
|
||||
let invocation = self.editor.handle(input);
|
||||
self.synchronize_documents();
|
||||
@@ -1056,21 +1067,36 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
fn poll_gutter_at(&mut self, now: Instant) -> bool {
|
||||
if self.editor.should_quit()
|
||||
|| self.editor.gutter_style() == crate::gutter::GutterStyle::None
|
||||
let line_gutter = self.editor.gutter_style() != crate::gutter::GutterStyle::None;
|
||||
let picker_changed = match (self.editor.normal_file_picker(), &self.picker_source) {
|
||||
(Some(picker), Some((tab, root))) => {
|
||||
*tab != self.editor.tab_id() || *root != picker.root
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if self.editor.should_quit() || (!line_gutter && self.editor.normal_file_picker().is_none())
|
||||
{
|
||||
self.gutter_worker = None;
|
||||
self.gutter_sources.clear();
|
||||
self.gutter_pending = false;
|
||||
self.picker_source = None;
|
||||
return false;
|
||||
}
|
||||
let sources_changed = !self
|
||||
.editor
|
||||
.documents()
|
||||
.filter(|document| line_gutter || document.dirty)
|
||||
.filter_map(|document| {
|
||||
document
|
||||
.path
|
||||
.map(|path| (document.id, document.revision, document.save_revision, path))
|
||||
document.path.map(|path| {
|
||||
(
|
||||
document.id,
|
||||
document.revision,
|
||||
document.save_revision,
|
||||
path,
|
||||
document.dirty,
|
||||
)
|
||||
})
|
||||
})
|
||||
.eq(self.gutter_sources.iter().map(|source| {
|
||||
(
|
||||
@@ -1078,17 +1104,25 @@ impl EditorSession {
|
||||
source.revision,
|
||||
source.save_revision,
|
||||
source.path.as_path(),
|
||||
source.dirty,
|
||||
)
|
||||
}));
|
||||
let refresh_due = !self.gutter_pending
|
||||
&& self
|
||||
.last_gutter_refresh
|
||||
.is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_secs(1));
|
||||
if sources_changed || refresh_due {
|
||||
if sources_changed || picker_changed || refresh_due {
|
||||
if picker_changed {
|
||||
self.picker_source = self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.map(|picker| (self.editor.tab_id(), picker.root.clone()));
|
||||
}
|
||||
if sources_changed {
|
||||
self.gutter_sources = self
|
||||
.editor
|
||||
.documents()
|
||||
.filter(|document| line_gutter || document.dirty)
|
||||
.filter_map(|document| {
|
||||
let path = document.path?;
|
||||
let contents = self
|
||||
@@ -1108,6 +1142,7 @@ impl EditorSession {
|
||||
revision: document.revision,
|
||||
save_revision: document.save_revision,
|
||||
path: path.to_path_buf(),
|
||||
dirty: document.dirty,
|
||||
contents,
|
||||
})
|
||||
})
|
||||
@@ -1115,7 +1150,7 @@ impl EditorSession {
|
||||
}
|
||||
self.gutter_request_id = self.gutter_request_id.wrapping_add(1);
|
||||
self.last_gutter_refresh = Some(now);
|
||||
if self.gutter_sources.is_empty() {
|
||||
if self.gutter_sources.is_empty() && self.picker_source.is_none() {
|
||||
self.gutter_worker = None;
|
||||
self.gutter_pending = false;
|
||||
return false;
|
||||
@@ -1132,6 +1167,7 @@ impl EditorSession {
|
||||
let files = self
|
||||
.gutter_sources
|
||||
.iter()
|
||||
.filter(|_| line_gutter)
|
||||
.map(|source| {
|
||||
(
|
||||
source.buffer,
|
||||
@@ -1140,11 +1176,20 @@ impl EditorSession {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let picker = self.picker_source.as_ref().map(|(_, root)| {
|
||||
let buffers = self
|
||||
.gutter_sources
|
||||
.iter()
|
||||
.filter(|source| source.dirty)
|
||||
.map(|source| (source.path.clone(), Arc::clone(&source.contents)))
|
||||
.collect();
|
||||
(root.clone(), buffers)
|
||||
});
|
||||
match self
|
||||
.gutter_worker
|
||||
.as_mut()
|
||||
.expect("gutter worker started")
|
||||
.request(self.gutter_request_id, files)
|
||||
.request(self.gutter_request_id, files, picker)
|
||||
{
|
||||
Ok(()) => self.gutter_pending = true,
|
||||
Err(error) => {
|
||||
@@ -1156,12 +1201,15 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
while let Some((id, result)) = self
|
||||
while let Some((id, result, picker)) = self
|
||||
.gutter_worker
|
||||
.as_mut()
|
||||
.and_then(crate::gutter::Worker::poll)
|
||||
{
|
||||
changed |= self.apply_gutter_result(id, result);
|
||||
if let Some(picker) = picker {
|
||||
changed |= self.apply_picker_result(id, picker);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -1195,6 +1243,36 @@ impl EditorSession {
|
||||
changed
|
||||
}
|
||||
|
||||
fn apply_picker_result(&mut self, id: u64, reply: crate::gutter::PickerChanges) -> bool {
|
||||
let Some((tab, root)) = &self.picker_source else {
|
||||
return false;
|
||||
};
|
||||
if id != self.gutter_request_id
|
||||
|| *tab != self.editor.tab_id()
|
||||
|| *root != reply.root
|
||||
|| self
|
||||
.editor
|
||||
.normal_file_picker()
|
||||
.is_none_or(|picker| picker.root != *root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let tab = *tab;
|
||||
let mut changed = false;
|
||||
let changes = match reply.result {
|
||||
Ok(changes) => changes,
|
||||
Err(error) => {
|
||||
self.editor.set_message(format!("File status: {error}"));
|
||||
changed = true;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
changed
|
||||
| self
|
||||
.editor
|
||||
.set_picker_repository_changes(tab, &reply.root, changes)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_gutter(&mut self) -> bool {
|
||||
self.last_gutter_refresh = None;
|
||||
self.poll_gutter_at(Instant::now())
|
||||
@@ -1399,6 +1477,100 @@ mod tests {
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn normal_picker_tracks_unsaved_text_and_head_with_line_gutter_disabled() {
|
||||
use crate::diff::FileStatus;
|
||||
let fixture = Fixture::new();
|
||||
fixture.file("buffer.txt", "base\n");
|
||||
fixture.file("clean.txt", "clean\n");
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&fixture.0)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_AUTHOR_NAME", "Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "author@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "author@example.com")
|
||||
.env("GIT_AUTHOR_DATE", "2025-01-01T00:00:00Z")
|
||||
.env("GIT_COMMITTER_DATE", "2025-01-01T00:00:00Z")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
git(&["init", "-q", "--template="]);
|
||||
git(&["add", "buffer.txt", "clean.txt"]);
|
||||
git(&["commit", "-qm", "baseline"]);
|
||||
fixture.file("buffer.txt", "disk\n");
|
||||
fixture.file("added.txt", "new\n");
|
||||
let mut session = fixture.open(
|
||||
"Vamp.configure do |config|\nconfig.git_gutter = \"none\"\nconfig.git_blame = false\nend\n",
|
||||
);
|
||||
session.editor.set_project_root(fixture.0.clone());
|
||||
let now = session.last_file_refresh;
|
||||
let open = |session: &mut EditorSession| {
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Char('o'));
|
||||
};
|
||||
let ready = |session: &mut EditorSession, clock: Instant, expected| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
session.poll_gutter_at(clock);
|
||||
let picker = session.editor.normal_file_picker().unwrap();
|
||||
let status = picker
|
||||
.entries()
|
||||
.find(|row| row.path == Path::new("buffer.txt"))
|
||||
.expect("normal picker retained the buffer file")
|
||||
.status;
|
||||
if !session.gutter_pending && status == expected {
|
||||
assert!(
|
||||
picker.entries().any(|row| {
|
||||
row.path == Path::new("clean.txt") && row.status.is_none()
|
||||
})
|
||||
);
|
||||
assert!(picker.entries().any(|row| {
|
||||
row.path == Path::new("added.txt") && row.status == Some(FileStatus::Added)
|
||||
}));
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"normal picker status did not settle: {}",
|
||||
session.editor.message()
|
||||
);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
};
|
||||
open(&mut session);
|
||||
ready(&mut session, now, Some(FileStatus::Modified));
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.poll_gutter_at(now);
|
||||
session
|
||||
.editor
|
||||
.apply(vec![Effect::Replace("base\n".into())])
|
||||
.unwrap();
|
||||
assert!(session.editor.dirty());
|
||||
open(&mut session);
|
||||
ready(&mut session, now, None);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
|
||||
"disk\n"
|
||||
);
|
||||
session.handle(Input::Ctrl('w'));
|
||||
session.poll_gutter_at(now);
|
||||
session.handle(Input::Char('u'));
|
||||
open(&mut session);
|
||||
ready(&mut session, now, Some(FileStatus::Modified));
|
||||
git(&["add", "buffer.txt"]);
|
||||
git(&["commit", "-qm", "new baseline"]);
|
||||
ready(&mut session, now + Duration::from_secs(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_results_follow_buffer_identity_and_reject_stale_edits() {
|
||||
use crate::gutter::Change;
|
||||
|
||||
+263
-8
@@ -4269,6 +4269,7 @@ fn draw_file_list(
|
||||
);
|
||||
}
|
||||
let entries = picker.entries();
|
||||
let normal_markers = changed_files.is_none() && picker.has_repository_changes();
|
||||
if entries.len() == 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if picker.query.is_empty() {
|
||||
@@ -4304,6 +4305,10 @@ fn draw_file_list(
|
||||
let changed = entry
|
||||
.catalog_index
|
||||
.and_then(|index| changed_files.and_then(|(files, _)| files.get(index)));
|
||||
let status = changed.map(|file| file.status).or(entry.status);
|
||||
let old_path = changed
|
||||
.and_then(|file| file.old_path.as_deref())
|
||||
.or(entry.old_path);
|
||||
let prefix = format!(
|
||||
"{} {:indent$}",
|
||||
if selected { ">" } else { " " },
|
||||
@@ -4314,9 +4319,9 @@ fn draw_file_list(
|
||||
0
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::with_capacity(if changed.is_some() { 7 } else { 3 });
|
||||
let mut spans = Vec::with_capacity(if status.is_some() { 7 } else { 3 });
|
||||
spans.push(Span::raw(prefix));
|
||||
if let Some(file) = changed {
|
||||
if changed.is_some() {
|
||||
let reviewed = entry.catalog_index.is_some_and(|index| {
|
||||
changed_files.is_some_and(|(_, reviewed)| reviewed.contains(&index))
|
||||
});
|
||||
@@ -4324,25 +4329,32 @@ fn draw_file_list(
|
||||
if reviewed { "✓ " } else { " " },
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
let color = match file.status {
|
||||
}
|
||||
if let Some(status) = status {
|
||||
let color = match status {
|
||||
FileStatus::Added => Color::Green,
|
||||
FileStatus::Removed => Color::Red,
|
||||
FileStatus::Modified => Color::Yellow,
|
||||
FileStatus::Renamed => Color::Cyan,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
diff_status(file.status),
|
||||
diff_status(status),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
} else if normal_markers {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
if let Some(file) = changed
|
||||
&& file.status == FileStatus::Renamed
|
||||
&& let Some(old_path) = &file.old_path
|
||||
if status == Some(FileStatus::Renamed)
|
||||
&& let Some(old_path) = old_path
|
||||
{
|
||||
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
|
||||
spans.push(Span::raw(" -> "));
|
||||
spans.push(Span::raw(display_text(&file.path.to_string_lossy())));
|
||||
spans.push(Span::raw(display_text(
|
||||
&changed
|
||||
.map_or(entry.path, |file| file.path.as_path())
|
||||
.to_string_lossy(),
|
||||
)));
|
||||
} else {
|
||||
spans.push(Span::raw(entry.label));
|
||||
if entry.is_dir && picker.view == FileView::Tree {
|
||||
@@ -5956,6 +5968,249 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() {
|
||||
use crate::repository::WorkingFileChange;
|
||||
|
||||
let directory = Directory::new();
|
||||
let changes: Vec<_> = [
|
||||
("z.txt", FileStatus::Added),
|
||||
("chain/a/m.txt", FileStatus::Modified),
|
||||
("gone/deep/d.txt", FileStatus::Removed),
|
||||
("r.txt", FileStatus::Renamed),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, status)| {
|
||||
if status != FileStatus::Removed {
|
||||
directory.file(path, "");
|
||||
}
|
||||
WorkingFileChange {
|
||||
path: path.into(),
|
||||
status,
|
||||
old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
directory.file("clean.txt", "");
|
||||
let mut picker = FilePicker::scan(
|
||||
directory.0.clone(),
|
||||
FilePresentation::Floating,
|
||||
FileView::List,
|
||||
)
|
||||
.unwrap();
|
||||
picker.set_repository_changes(changes.clone());
|
||||
for change in &changes {
|
||||
picker.reveal_path(&change.path);
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||||
picker.presentation = presentation;
|
||||
for view in [FileView::Tree, FileView::List] {
|
||||
picker.set_view(view);
|
||||
for focused in [true, false] {
|
||||
picker.focused = focused;
|
||||
for selected in changes
|
||||
.iter()
|
||||
.map(|change| change.path.as_path())
|
||||
.chain(std::iter::once(std::path::Path::new("clean.txt")))
|
||||
{
|
||||
picker.reveal_path(selected);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("clean.txt"));
|
||||
assert!(!text.contains('✓'));
|
||||
assert!(!text.contains("old\nname.txt"));
|
||||
let buffer = terminal.backend().buffer();
|
||||
for (change, color) in changes.iter().zip([
|
||||
Color::Green,
|
||||
Color::Yellow,
|
||||
Color::Red,
|
||||
Color::Cyan,
|
||||
]) {
|
||||
let path = if view == FileView::Tree {
|
||||
std::path::Path::new(change.path.file_name().unwrap())
|
||||
} else {
|
||||
change.path.as_path()
|
||||
};
|
||||
let expected = if change.status == FileStatus::Renamed {
|
||||
"R old�name.txt -> r.txt".to_owned()
|
||||
} else {
|
||||
format!("{} {}", diff_status(change.status), path.to_string_lossy())
|
||||
};
|
||||
let (x, y) = (0..24)
|
||||
.find_map(|y| {
|
||||
let row = (0..100)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>();
|
||||
row.find(&expected).map(|offset| {
|
||||
(UnicodeWidthStr::width(&row[..offset]) as u16, y)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing {expected}"));
|
||||
assert_eq!(buffer[(x, y)].fg, color);
|
||||
assert_eq!(
|
||||
buffer[(x, y)].bg,
|
||||
if change.path == selected {
|
||||
if focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}
|
||||
} else {
|
||||
Color::Reset
|
||||
}
|
||||
);
|
||||
}
|
||||
let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap();
|
||||
assert!(clean_row.contains(
|
||||
if selected == std::path::Path::new("clean.txt") {
|
||||
"> clean.txt"
|
||||
} else {
|
||||
" clean.txt"
|
||||
}
|
||||
));
|
||||
}
|
||||
for query in ["m.txt", "clean"] {
|
||||
picker.query = query.into();
|
||||
picker.refilter();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
if query == "m.txt" {
|
||||
assert!(text.contains("M "));
|
||||
assert!(!text.contains("clean.txt"));
|
||||
} else {
|
||||
assert!(text.contains("> clean.txt"));
|
||||
assert!(!text.contains("M "));
|
||||
}
|
||||
assert!(!text.contains('✓'));
|
||||
picker.clear_filter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
picker.refresh();
|
||||
assert!(picker.has_repository_changes());
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|row| row.status == Some(FileStatus::Renamed))
|
||||
);
|
||||
picker.set_repository_changes(Vec::new());
|
||||
picker.set_view(FileView::List);
|
||||
picker.reveal_path(std::path::Path::new("clean.txt"));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
frame.area(),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> clean.txt"));
|
||||
assert!(text.contains("r.txt"));
|
||||
assert!(!text.contains("old�name.txt"));
|
||||
assert!(!text.contains("d.txt"));
|
||||
assert!(!text.contains('✓'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() {
|
||||
let directory = Directory::new();
|
||||
directory.file("界.txt", "");
|
||||
let mut picker = FilePicker::scan(
|
||||
directory.0.clone(),
|
||||
FilePresentation::Floating,
|
||||
FileView::List,
|
||||
)
|
||||
.unwrap();
|
||||
picker.set_repository_changes(vec![crate::repository::WorkingFileChange {
|
||||
path: "界.txt".into(),
|
||||
status: FileStatus::Modified,
|
||||
old_path: None,
|
||||
}]);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap();
|
||||
for focused in [true, false] {
|
||||
picker.focused = focused;
|
||||
for height in 0..=3 {
|
||||
for width in 0..=8 {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX",
|
||||
),
|
||||
frame.area(),
|
||||
);
|
||||
draw_file_list(
|
||||
frame,
|
||||
&picker,
|
||||
Rect::new(0, 0, width, height),
|
||||
"",
|
||||
&mut WheelPanels::default(),
|
||||
None,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
for y in 0..4 {
|
||||
for x in 0..12 {
|
||||
if x >= width || y >= height {
|
||||
assert_eq!(buffer[(x, y)].symbol(), "X");
|
||||
}
|
||||
}
|
||||
}
|
||||
if height < 2 {
|
||||
continue;
|
||||
}
|
||||
for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() {
|
||||
assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string());
|
||||
}
|
||||
if width >= 3 {
|
||||
assert_eq!(buffer[(2, 1)].fg, Color::Yellow);
|
||||
assert_eq!(
|
||||
buffer[(2, 1)].bg,
|
||||
if focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
}
|
||||
);
|
||||
}
|
||||
if width >= 6 {
|
||||
assert_eq!(buffer[(4, 1)].symbol(), "界");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
|
||||
let mut diff = diff_comparison(vec![deletion_file()]);
|
||||
|
||||
Reference in New Issue
Block a user