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, String>; /// Reads `folder`: folders first, then files, each by name ignoring case. fn list(folder: &Path) -> Listing { let read = || -> io::Result> { 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::().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, HashSet) { let root = mark_key(root); let marks: HashMap = 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, 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, /// The root and the folders the tree opened. wanted: HashSet, marks: HashMap, /// Folders holding a mark. dirty: HashSet, 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) { 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>>, cx: &mut Context, ) { 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) { let folders: Vec = self.wanted.iter().cloned().collect(); let read = cx.background_spawn(async move { let read = folders.into_iter().map(|folder| (list(&folder), folder)); read.collect::>() }); 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) { 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) { 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 { 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) -> 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;