Files
vamp/src/files.rs
T
2026-10-16 18:53:02 +02:00

1073 lines
36 KiB
Rust

use crate::diff::FileStatus;
use crate::repository::WorkingFileChange;
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FilePresentation {
Floating,
Docked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileView {
List,
Tree,
}
impl FileView {
fn index(self) -> usize {
match self {
Self::List => 0,
Self::Tree => 1,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockSide {
Left,
Right,
Top,
Bottom,
}
pub struct FileRow<'a> {
/// The actual project-relative OS path, never reconstructed from the label.
pub path: &'a Path,
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,
}
#[derive(Debug)]
struct Node {
path: PathBuf,
label: String,
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>,
}
#[derive(Debug)]
struct TreeNode {
node: usize,
label: String,
parent: Option<usize>,
children: Vec<usize>,
}
#[derive(Debug)]
struct VisibleRow {
node: usize,
tree: Option<usize>,
depth: usize,
expanded: bool,
}
#[derive(Debug)]
pub struct FilePicker {
pub root: PathBuf,
pub query: String,
pub selected: usize,
pub presentation: FilePresentation,
pub view: FileView,
pub searching: bool,
pub focused: bool,
pub error: Option<String>,
nodes: Vec<Node>,
tree: Vec<TreeNode>,
tree_for_node: Vec<usize>,
tree_roots: Vec<usize>,
rows: Vec<VisibleRow>,
viewport_revision: u64,
expanded: HashSet<usize>,
filter_collapsed: HashSet<usize>,
saved_selection: [Option<usize>; 2],
filtered_selection: [Option<usize>; 2],
tree_searching: bool,
warning: Option<String>,
repository_changes: Vec<WorkingFileChange>,
conflicted_paths: HashSet<PathBuf>,
has_virtual_entries: bool,
}
// Lowercased field/token. Score: match class, missed word boundaries, gaps, start, length.
pub(crate) fn fuzzy_match(field: &str, token: &str) -> Option<[usize; 5]> {
if field == token {
return Some([0, 0, 0, 0, field.len()]);
}
if field.starts_with(token) {
return Some([1, 0, 0, 0, field.len()]);
}
let contiguous = field
.match_indices(token)
.map(|(start, _)| {
let boundary = field[..start]
.chars()
.next_back()
.is_none_or(|ch| !ch.is_alphanumeric());
[2, usize::from(!boundary), 0, start, field.len()]
})
.min();
if contiguous.is_some() {
return contiguous;
}
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
// programming if that ranking precision becomes necessary.
let mut wanted = token.chars();
let mut next = wanted.next()?;
let mut start = 0;
let mut matched = 0;
let mut boundaries = 0;
let mut previous_is_word = false;
for (position, ch) in field.chars().enumerate() {
if ch == next {
if matched == 0 {
start = position;
}
matched += 1;
boundaries += usize::from(previous_is_word);
if let Some(ch) = wanted.next() {
next = ch;
} else {
return Some([
3,
boundaries,
position - start + 1 - matched,
start,
field.len(),
]);
}
}
previous_is_word = ch.is_alphanumeric();
}
None
}
fn label(path: &Path) -> String {
path.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.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() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"Project root is not a directory",
));
}
// Root failures are fatal; descendant failures leave a visibly incomplete catalog.
fs::read_dir(&root)?;
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
let mut walk = WalkBuilder::new(&root);
walk.require_git(false)
.parents(false)
.hidden(false)
.git_global(false)
.follow_links(false)
.filter_entry(|entry| {
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
});
let mut nodes = Vec::new();
let mut warning = None;
for entry in walk.build() {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
if error.depth() == Some(0) {
return Err(io::Error::other(error));
}
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
continue;
}
};
if let Some(error) = entry.error() {
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
}
// Symlinks (including file symlinks) and the implicit project root are excluded.
if entry.depth() == 0
|| !entry
.file_type()
.is_some_and(|kind| kind.is_file() || kind.is_dir())
{
continue;
}
let path = entry
.path()
.strip_prefix(&root)
.expect("walk stays within root")
.to_owned();
let label = label(&path);
nodes.push(Node {
path,
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(),
});
}
Ok(Self::from_nodes(root, nodes, presentation, view, warning))
}
/// Builds a changed-path catalog, retaining each path's comparison index.
pub fn from_paths(root: PathBuf, paths: impl IntoIterator<Item = (usize, PathBuf)>) -> Self {
let mut catalog = HashMap::new();
for (index, path) in paths {
for parent in path
.ancestors()
.skip(1)
.filter(|path| !path.as_os_str().is_empty())
{
catalog.insert((parent.to_owned(), true), None);
}
catalog.entry((path, false)).or_insert(Some(index));
}
let nodes = catalog
.into_iter()
.map(|((path, is_dir), catalog_index)| {
let label = label(&path);
Node {
path,
search_label: label.to_lowercase(),
label,
catalog_index,
status: None,
old_path: None,
virtual_entry: false,
is_dir,
parent: None,
children: Vec::new(),
}
})
.collect();
Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None)
}
fn from_nodes(
root: PathBuf,
mut nodes: Vec<Node>,
presentation: FilePresentation,
view: FileView,
warning: Option<String>,
) -> Self {
nodes.sort_by(|left, right| left.path.cmp(&right.path));
let by_path: HashMap<_, _> = nodes
.iter()
.enumerate()
.filter(|(_, node)| node.is_dir)
.map(|(index, node)| (node.path.clone(), index))
.collect();
let mut roots = Vec::new();
for index in 0..nodes.len() {
let parent = nodes[index]
.path
.parent()
.and_then(|path| by_path.get(path))
.copied();
nodes[index].parent = parent;
if let Some(parent) = parent {
nodes[parent].children.push(index);
} else {
roots.push(index);
}
}
let mut children: Vec<_> = nodes
.iter_mut()
.map(|node| std::mem::take(&mut node.children))
.collect();
let order = |left: &usize, right: &usize| {
nodes[*right]
.is_dir
.cmp(&nodes[*left].is_dir)
.then_with(|| {
nodes[*left]
.path
.file_name()
.cmp(&nodes[*right].path.file_name())
})
};
roots.sort_by(order);
for children in &mut children {
children.sort_by(order);
}
for (node, children) in nodes.iter_mut().zip(children) {
node.children = children;
}
let mut picker = Self {
root,
query: String::new(),
selected: 0,
presentation,
view,
searching: false,
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(),
rows: Vec::new(),
viewport_revision: 0,
expanded: HashSet::new(),
filter_collapsed: HashSet::new(),
saved_selection: [None, None],
filtered_selection: [None, None],
tree_searching: false,
warning,
repository_changes: Vec::new(),
conflicted_paths: HashSet::new(),
};
for root in roots {
let tree = picker.compact(root, None);
picker.tree_roots.push(tree);
}
picker.project(None, false);
picker.remember_selection(false);
picker
}
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
let tree = self.tree.len();
let mut terminal = start;
let mut name = label(Path::new(self.nodes[start].path.file_name().unwrap()));
self.tree_for_node[start] = tree;
while self.nodes[terminal].children.len() == 1 {
let child = self.nodes[terminal].children[0];
if !self.nodes[terminal].is_dir || !self.nodes[child].is_dir {
break;
}
terminal = child;
self.tree_for_node[terminal] = tree;
name.push('/');
name.push_str(&label(Path::new(
self.nodes[terminal].path.file_name().unwrap(),
)));
}
self.tree.push(TreeNode {
node: terminal,
label: name,
parent,
children: Vec::new(),
});
for index in 0..self.nodes[terminal].children.len() {
let child = self.compact(self.nodes[terminal].children[index], Some(tree));
self.tree[tree].children.push(child);
}
tree
}
pub(crate) fn viewport_revision(&self) -> u64 {
self.viewport_revision
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
self.rows.iter().map(|row| {
let node = &self.nodes[row.node];
FileRow {
path: &node.path,
label: row
.tree
.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,
}
})
}
/// Full catalog descendants, independent of filtering and folder expansion.
pub(crate) fn descendant_catalog_indices<'a>(
&'a self,
path: &'a Path,
) -> impl Iterator<Item = usize> + 'a {
let start = self
.nodes
.partition_point(|node| node.path.as_path() <= path);
self.nodes[start..]
.iter()
.take_while(move |node| node.path.starts_with(path))
.filter_map(|node| node.catalog_index)
}
fn selected_node(&self) -> Option<usize> {
self.rows.get(self.selected).map(|row| row.node)
}
fn remember_selection(&mut self, explicit: bool) {
let Some(node) = self.selected_node() else {
return;
};
self.saved_selection[self.view.index()] = Some(node);
if !self.query.is_empty() {
self.filtered_selection[self.view.index()] = Some(node);
}
if explicit && !self.nodes[node].is_dir {
self.saved_selection = [Some(node), Some(node)];
if !self.query.is_empty() {
self.filtered_selection = [Some(node), Some(node)];
}
}
}
fn set_expanded(&mut self, tree: usize, expanded: bool) {
let mut node = Some(self.tree[tree].node);
while let Some(index) = node {
if self.tree_for_node[index] != tree {
break;
}
if expanded {
self.expanded.insert(index);
} else {
self.expanded.remove(&index);
}
if !self.query.is_empty() {
if expanded {
self.filter_collapsed.remove(&index);
} else {
self.filter_collapsed.insert(index);
}
}
node = self.nodes[index].parent;
}
}
fn reveal(&mut self, node: usize) {
let mut parent = self.tree[self.tree_for_node[node]].parent;
while let Some(tree) = parent {
self.set_expanded(tree, true);
parent = self.tree[tree].parent;
}
}
pub(crate) fn reveal_path(&mut self, path: &Path) {
if let Some(node) = self
.nodes
.iter()
.position(|node| node.path == path && !node.is_dir)
{
self.reveal(node);
self.project(Some(node), false);
self.remember_selection(true);
}
}
pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool {
use crate::editor::{BackspaceKind, Input};
match input {
Input::Ctrl('u') => self.clear_filter(),
input
if BackspaceKind::from_input(input).is_some()
&& (self.view == FileView::List || self.searching) =>
{
if BackspaceKind::from_input(input)
.expect("guard checked backspace input")
.truncate(&mut self.query)
{
self.refilter();
}
}
Input::Char('i') if self.view == FileView::Tree && !self.searching => {
self.start_search()
}
Input::Char(ch)
if !ch.is_control() && (self.view == FileView::List || self.searching) =>
{
self.query.push(*ch);
self.refilter();
}
_ => return false,
}
true
}
pub fn set_view(&mut self, view: FileView) {
if self.view == view {
return;
}
let current = self.selected_node();
if !self.searching {
self.remember_selection(false);
}
if self.view == FileView::Tree {
self.tree_searching = self.searching;
}
self.view = view;
let selected = if self.query.is_empty() {
self.saved_selection[view.index()]
} else {
self.filtered_selection[view.index()].or(self.saved_selection[view.index()])
}
.or(current);
if view == FileView::Tree {
self.searching = self.tree_searching;
if !self.searching
&& self.query.is_empty()
&& let Some(node) = selected
{
self.reveal(node);
}
} else {
self.searching = false;
}
self.project(selected, false);
}
pub fn toggle_view(&mut self) {
self.set_view(match self.view {
FileView::List => FileView::Tree,
FileView::Tree => FileView::List,
});
}
pub(crate) fn start_search(&mut self) {
let selected = self.selected_node();
self.searching = true;
self.tree_searching = true;
self.project(selected, false);
self.clear_error();
}
pub(crate) fn finish_search(&mut self) {
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()]);
self.searching = false;
self.tree_searching = false;
if self.query.is_empty()
&& let Some(node) = selected
{
self.reveal(node);
}
self.project(selected, false);
self.remember_selection(false);
self.clear_error();
}
pub(crate) fn clear_filter(&mut self) {
if self.query.is_empty() {
return;
}
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()]);
self.query.clear();
self.filter_collapsed.clear();
self.filtered_selection = [None, None];
if self.view == FileView::Tree
&& !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
self.project(selected, false);
self.remember_selection(false);
self.clear_error();
}
fn project(&mut self, selected: Option<usize>, select_match: bool) {
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
let matches = |node: &Node| {
tokens
.iter()
.all(|token| fuzzy_match(&node.search_label, token).is_some())
};
self.rows.clear();
if self.view == FileView::List {
self.rows
.extend(self.nodes.iter().enumerate().filter_map(|(node, entry)| {
(!entry.is_dir && matches(entry)).then_some(VisibleRow {
node,
tree: None,
depth: 0,
expanded: false,
})
}));
} else {
let mut included = vec![false; self.tree.len()];
for (index, _) in self
.nodes
.iter()
.enumerate()
.filter(|(_, node)| matches(node))
{
let mut tree = Some(self.tree_for_node[index]);
while let Some(index) = tree {
if included[index] {
break;
}
included[index] = true;
tree = self.tree[index].parent;
}
}
let filtered = self.searching || !tokens.is_empty();
let mut pending: Vec<_> = self
.tree_roots
.iter()
.rev()
.map(|&tree| (tree, 0))
.collect();
while let Some((tree, depth)) = pending.pop() {
if !included[tree] {
continue;
}
let entry = &self.tree[tree];
let mut node = Some(entry.node);
let mut collapsed = false;
let mut saved_expansion = false;
while let Some(index) = node {
if self.tree_for_node[index] != tree {
break;
}
collapsed |= self.filter_collapsed.contains(&index);
saved_expansion |= self.expanded.contains(&index);
node = self.nodes[index].parent;
}
let expanded = self.nodes[entry.node].is_dir
&& !collapsed
&& (saved_expansion
|| (filtered && entry.children.iter().any(|&child| included[child])));
self.rows.push(VisibleRow {
node: entry.node,
tree: Some(tree),
depth,
expanded,
});
if expanded {
pending.extend(entry.children.iter().rev().map(|&child| (child, depth + 1)));
}
}
}
// Query edits prefer direct matches; navigation keeps context directories.
// Missing selections fall back to a visible ancestor, then the first row.
let mut target = selected;
self.selected = self
.rows
.iter()
.position(|row| !select_match || matches(&self.nodes[row.node]))
.unwrap_or(0);
while let Some(node) = target {
if let Some(index) = self.rows.iter().position(|row| {
(!select_match || matches(&self.nodes[row.node]))
&& (row.node == node
|| (self.view == FileView::Tree
&& row.tree == Some(self.tree_for_node[node])))
}) {
self.selected = index;
break;
}
target = self.nodes[node].parent;
}
}
pub(crate) fn refilter(&mut self) {
let view = self.view.index();
let selected = self
.selected_node()
.or(self.filtered_selection[view])
.or(self.saved_selection[view]);
self.filter_collapsed.clear();
if self.query.is_empty() {
self.filtered_selection = [None, None];
if self.view == FileView::Tree
&& !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
} else if self.filtered_selection[view].is_none() {
self.filtered_selection[view] = selected;
}
self.project(selected, !self.query.is_empty());
self.remember_selection(false);
self.clear_error();
}
pub(crate) fn navigate(&mut self, forward: bool) {
let count = self.rows.len();
self.selected = if count == 0 {
0
} else if forward {
(self.selected + 1) % count
} else if self.selected == 0 {
count - 1
} else {
self.selected - 1
};
self.remember_selection(true);
self.clear_error();
}
pub(crate) fn right(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if self.searching {
if self
.rows
.get(self.selected + 1)
.is_some_and(|child| child.depth > row.depth)
{
self.selected += 1;
}
self.remember_selection(false);
self.clear_error();
return;
}
if !self.nodes[row.node].is_dir {
return;
}
if row.expanded {
if self
.rows
.get(self.selected + 1)
.is_some_and(|child| child.depth > row.depth)
{
self.selected += 1;
}
} else {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, true);
self.project(Some(node), false);
}
self.remember_selection(true);
self.clear_error();
}
pub(crate) fn left(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if self.searching {
if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) {
let node = self.tree[parent].node;
if let Some(index) = self.rows.iter().position(|row| row.node == node) {
self.selected = index;
}
}
self.remember_selection(false);
self.clear_error();
return;
}
if row.expanded {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, false);
self.project(Some(node), false);
} else if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) {
let node = self.tree[parent].node;
if let Some(index) = self.rows.iter().position(|row| row.node == node) {
self.selected = index;
}
}
self.remember_selection(true);
self.clear_error();
}
/// Directories finish query editing and toggle; files retain the authoritative path.
pub(crate) fn accept(&mut self) -> Option<PathBuf> {
self.clear_error();
let node = self.selected_node()?;
if self.nodes[node].is_dir {
if self.searching {
self.finish_search();
}
let tree = self.tree_for_node[node];
let expanded = !self.rows.get(self.selected).is_some_and(|row| row.expanded);
self.set_expanded(tree, expanded);
self.project(Some(node), false);
self.remember_selection(true);
None
} else {
self.remember_selection(true);
Some(self.root.join(&self.nodes[node].path))
}
}
pub(crate) fn clear_error(&mut self) {
self.error = self.warning.clone();
}
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation, self.view) {
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 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.repository_changes = changes;
topology_changed || metadata_changed
}
/// Last authoritative conflict state, independent of ordinary change status.
pub fn is_conflicted(&self, path: &Path) -> bool {
self.conflicted_paths.contains(path)
}
pub(crate) fn set_conflicted(&mut self, path: &Path, conflicted: bool) {
if conflicted {
if !self.conflicted_paths.contains(path) {
self.conflicted_paths.insert(path.to_owned());
}
} else {
self.conflicted_paths.remove(path);
}
}
pub(crate) fn replace_paths(&mut self, paths: impl IntoIterator<Item = (usize, PathBuf)>) {
self.replace_catalog(Self::from_paths(self.root.clone(), paths));
}
fn replace_catalog(&mut self, mut replacement: Self) {
replacement.presentation = self.presentation;
replacement.conflicted_paths = std::mem::take(&mut self.conflicted_paths);
let by_path: HashMap<_, _> = replacement
.nodes
.iter()
.enumerate()
.map(|(index, node)| (node.path.clone(), index))
.collect();
let surviving = |node: usize| {
let mut path = self.nodes[node].path.as_path();
loop {
if let Some(&node) = by_path.get(path) {
return Some(node);
}
path = path.parent()?;
}
};
replacement.expanded = self
.expanded
.iter()
.filter_map(|&node| {
by_path
.get(&self.nodes[node].path)
.copied()
.filter(|&node| replacement.nodes[node].is_dir)
})
.collect();
replacement.filter_collapsed = self
.filter_collapsed
.iter()
.filter_map(|&node| {
by_path
.get(&self.nodes[node].path)
.copied()
.filter(|&node| replacement.nodes[node].is_dir)
})
.collect();
replacement.saved_selection = self.saved_selection.map(|node| node.and_then(surviving));
replacement.filtered_selection =
self.filtered_selection.map(|node| node.and_then(surviving));
replacement.query = self.query.clone();
replacement.focused = self.focused;
replacement.view = self.view;
replacement.searching = self.searching;
replacement.tree_searching = self.tree_searching;
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()])
.and_then(surviving);
replacement.project(selected, false);
replacement.remember_selection(false);
*self = replacement;
}
}
#[cfg(test)]
#[path = "../tests/unit/files.rs"]
mod tests;