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
+1
View File
@@ -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
View File
@@ -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
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();
+99 -11
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+181 -9
View File
@@ -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
View File
@@ -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()]);