Files
ELYTerminal/src/files.rs
T

295 lines
10 KiB
Rust

use std::{
borrow::Cow,
collections::{HashMap, HashSet},
ffi::OsString,
fs, io,
path::{Path, PathBuf},
};
use ely_gpui_component::{
data_display::Tone,
lists::{GitStatus, Tree, TreeNode},
primitives::IconName,
};
use gpui::{AppContext, Context, IntoElement, Render, SharedString, Styled, Task, Window};
use crate::{git, sessions};
/// A folder's entry: its name, and whether it is a folder, links followed.
#[derive(Debug, PartialEq)]
struct Entry {
name: OsString,
folder: bool,
}
/// A folder's entries, or why it could not be read.
type Listing = Result<Vec<Entry>, String>;
/// Reads `folder`: folders first, then files, each by name ignoring case.
fn list(folder: &Path) -> Listing {
let read = || -> io::Result<Vec<Entry>> {
fs::read_dir(folder)?
.map(|entry| {
let entry = entry?;
let kind = entry.file_type()?;
Ok(Entry {
folder: kind.is_dir() || (kind.is_symlink() && entry.path().is_dir()),
name: entry.file_name(),
})
})
.collect()
};
let mut entries = read().map_err(|error| error.to_string())?;
entries.sort_by_cached_key(|entry| {
let name = entry.name.to_string_lossy().to_lowercase();
(!entry.folder, name, entry.name.clone())
});
Ok(entries)
}
/// `path` as marks are keyed: on macOS, whose file systems ignore Unicode forms, composed as Git writes it.
fn mark_key(path: &Path) -> Cow<'_, Path> {
#[cfg(target_os = "macos")]
if let Some(text) = path.to_str()
&& !unicode_normalization::is_nfc(text)
{
use unicode_normalization::UnicodeNormalization;
return Cow::Owned(text.nfc().collect::<String>().into());
}
Cow::Borrowed(path)
}
/// Changes by their keys, and the folders from `root` down that hold one.
fn marks(
root: &Path,
changes: Vec<(PathBuf, GitStatus)>,
) -> (HashMap<PathBuf, GitStatus>, HashSet<PathBuf>) {
let root = mark_key(root);
let marks: HashMap<PathBuf, GitStatus> = changes
.into_iter()
.map(|(path, status)| (mark_key(&path).into_owned(), status))
.collect();
let dirty = marks
.keys()
.flat_map(|path| {
let up = path.ancestors().skip(1);
up.take_while(|folder| folder.starts_with(&root))
})
.map(Path::to_path_buf)
.collect();
(marks, dirty)
}
/// Git's mark on a node: its letter and tint; a dot on a folder that holds changes.
fn badge(node: TreeNode, mark: Option<GitStatus>, dirty: bool) -> TreeNode {
let (note, tone) = match mark {
Some(GitStatus::Modified) => ("M", Tone::Warning),
Some(GitStatus::Added) => ("A", Tone::Success),
Some(GitStatus::Deleted) => ("D", Tone::Danger),
Some(GitStatus::Untracked) => ("U", Tone::Success),
Some(GitStatus::Renamed) => ("R", Tone::Info),
Some(GitStatus::Conflicted) => ("!", Tone::Danger),
None if dirty => ("•", Tone::Warning),
None => return node,
};
node.note(note).tone(tone)
}
/// `path`'s tree key, one to a path: a path not in UTF-8 adds its bytes after a NUL, which no path holds.
fn key(path: &Path) -> SharedString {
match path.to_str() {
Some(text) => text.to_string().into(),
None => {
let bytes = path.as_os_str().as_encoded_bytes();
let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
format!("{}\0{hex}", path.to_string_lossy()).into()
}
}
}
/// The Files panel: the shown session's folder as a tree, Git's marks on names.
pub struct Files {
root: PathBuf,
/// Counts roots, so each root starts a fresh tree.
generation: usize,
listings: HashMap<PathBuf, Listing>,
/// The root and the folders the tree opened.
wanted: HashSet<PathBuf>,
marks: HashMap<PathBuf, GitStatus>,
/// Folders holding a mark.
dirty: HashSet<PathBuf>,
reading: Task<()>,
checking: Task<()>,
}
impl Files {
pub fn new(root: PathBuf) -> Self {
Self {
wanted: HashSet::from([root.clone()]),
root,
generation: 0,
listings: HashMap::new(),
marks: HashMap::new(),
dirty: HashSet::new(),
reading: Task::ready(()),
checking: Task::ready(()),
}
}
/// Shows `folder`, read afresh with its marks; a new folder starts a new tree.
pub fn show(&mut self, folder: &Path, cx: &mut Context<Self>) {
if folder != self.root {
log::info!("files: the tree shows {}", folder.display());
*self = Self {
generation: self.generation + 1,
..Self::new(folder.to_path_buf())
};
}
self.read(cx);
let root = self.root.clone();
let changes = cx.background_spawn(async move { git::changes(&root) });
self.checking = cx.spawn(async move |files, cx| {
let changes = changes.await;
files.update(cx, |files, cx| files.mark(changes, cx)).ok();
});
}
fn mark(
&mut self,
changes: anyhow::Result<Option<Vec<(PathBuf, GitStatus)>>>,
cx: &mut Context<Self>,
) {
let changes = match changes {
Ok(changes) => changes.unwrap_or_default(),
Err(error) => {
log::error!("files: no Git marks in {}: {error:#}", self.root.display());
Vec::new()
}
};
let (marks, dirty) = marks(&self.root, changes);
if marks != self.marks {
log::debug!(
"files: {} Git marks in {}",
marks.len(),
self.root.display()
);
}
(self.marks, self.dirty) = (marks, dirty);
cx.notify();
}
/// Reads the root and every folder the tree opened, off the main thread.
fn read(&mut self, cx: &mut Context<Self>) {
let folders: Vec<PathBuf> = self.wanted.iter().cloned().collect();
let read = cx.background_spawn(async move {
let read = folders.into_iter().map(|folder| (list(&folder), folder));
read.collect::<Vec<_>>()
});
self.reading = cx.spawn(async move |files, cx| {
let read = read.await;
files.update(cx, |files, cx| files.store(read, cx)).ok();
});
}
fn store(&mut self, read: Vec<(Listing, PathBuf)>, cx: &mut Context<Self>) {
let mut changed = false;
for (listing, folder) in read {
if self.listings.get(&folder) == Some(&listing) {
continue;
}
match &listing {
Ok(entries) => {
log::debug!("files: {} holds {}", folder.display(), entries.len())
}
Err(error) => log::warn!("files: {} is unreadable: {error}", folder.display()),
}
self.listings.insert(folder, listing);
changed = true;
}
if changed {
cx.notify();
}
}
/// The tree opened `folder`, unread so far.
fn load(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
if self.wanted.insert(folder) {
self.read(cx);
}
}
/// The path a tree key stands for: the root, or an entry read.
fn path(&self, of: &str) -> Option<PathBuf> {
let entries = self.listings.iter().flat_map(|(folder, listing)| {
listing
.iter()
.flatten()
.map(|entry| folder.join(&entry.name))
});
std::iter::once(self.root.clone())
.chain(entries)
.find(|path| key(path).as_ref() == of)
}
/// Folder `path`'s node, its entries once read; `untracked` when an untracked folder holds it.
fn folder(&self, path: &Path, label: String, untracked: bool) -> TreeNode {
let own = |path: &Path| self.marks.get(mark_key(path).as_ref()).copied();
let mark = own(path).or(untracked.then_some(GitStatus::Untracked));
let inside = mark == Some(GitStatus::Untracked);
let node = TreeNode::new(key(path), label).icon(IconName::Folder);
let node = match self.listings.get(path) {
None => node.pending(),
Some(Err(_)) => return node.children([]).note("unreadable").tone(Tone::Danger),
Some(Ok(entries)) => node.children(entries.iter().map(|entry| {
let path = path.join(&entry.name);
let label = sessions::one_line(&entry.name.to_string_lossy());
if entry.folder {
return self.folder(&path, label, inside);
}
let file = TreeNode::new(key(&path), label).icon(IconName::File);
badge(
file,
own(&path).or(inside.then_some(GitStatus::Untracked)),
false,
)
})),
};
badge(node, mark, self.dirty.contains(mark_key(path).as_ref()))
}
}
impl Render for Files {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let home = dirs::home_dir();
let label = match self.root.file_name() {
Some(name) if home.as_deref() != Some(self.root.as_path()) => {
sessions::one_line(&name.to_string_lossy())
}
_ => sessions::shown(&self.root, home.as_deref()),
};
let (load, open) = (cx.entity(), cx.entity());
Tree::new(
("files", self.generation),
[self.folder(&self.root, label, false)],
)
.open([key(&self.root)])
.on_load(move |key, _, cx| {
load.update(cx, |files, cx| match files.path(key) {
Some(folder) => files.load(folder, cx),
None => log::warn!("files: {key} to read is gone"),
})
})
.on_activate(move |key, _, cx| match open.read(cx).path(key) {
Some(path) => {
log::info!("files: opening {}", path.display());
cx.open_with_system(&path)
}
None => log::warn!("files: {key} to open is gone"),
})
.size_full()
}
}
#[cfg(test)]
mod tests;