1073 lines
36 KiB
Rust
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(¤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() {
|
|
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;
|