T13: the right column's Git and Files switch, kept in settings; Files shows the shown session's folder as a tree, folders first, Git's marks on names from git status without optional locks, unreadable folders noted; a double press opens a folder, or a file in the system's app; it reloads when the session reports a folder, so at each prompt; a program without shell integration gets a line saying it reports no folder; header controls keep their presses from the header's double-press zoom
This commit is contained in:
11 files changed
+700
-27
No files matched your search
+246
@@ -0,0 +1,246 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
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: String,
|
||||
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().to_string_lossy().into_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut entries = read().map_err(|error| error.to_string())?;
|
||||
entries
|
||||
.sort_by_cached_key(|entry| (!entry.folder, entry.name.to_lowercase(), entry.name.clone()));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Changes by path, and the folders from `root` down that hold one.
|
||||
fn marks(
|
||||
root: &Path,
|
||||
changes: Vec<(PathBuf, GitStatus)>,
|
||||
) -> (HashMap<PathBuf, GitStatus>, HashSet<PathBuf>) {
|
||||
let dirty = changes
|
||||
.iter()
|
||||
.flat_map(|(path, _)| {
|
||||
let up = path.ancestors().skip(1);
|
||||
up.take_while(|folder| folder.starts_with(root))
|
||||
})
|
||||
.map(Path::to_path_buf)
|
||||
.collect();
|
||||
(changes.into_iter().collect(), 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)
|
||||
}
|
||||
|
||||
fn key(path: &Path) -> SharedString {
|
||||
path.to_string_lossy().into_owned().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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(path).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);
|
||||
if entry.folder {
|
||||
return self.folder(&path, entry.name.clone(), inside);
|
||||
}
|
||||
let file = TreeNode::new(key(&path), entry.name.clone()).icon(IconName::File);
|
||||
badge(
|
||||
file,
|
||||
own(&path).or(inside.then_some(GitStatus::Untracked)),
|
||||
false,
|
||||
)
|
||||
})),
|
||||
};
|
||||
badge(node, mark, self.dirty.contains(path))
|
||||
}
|
||||
}
|
||||
|
||||
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()) => {
|
||||
name.to_string_lossy().into()
|
||||
}
|
||||
_ => sessions::shown(&self.root, home.as_deref()),
|
||||
};
|
||||
let files = cx.entity();
|
||||
Tree::new(
|
||||
("files", self.generation),
|
||||
[self.folder(&self.root, label, false)],
|
||||
)
|
||||
.open([key(&self.root)])
|
||||
.on_load(move |key, _, cx| {
|
||||
let folder = PathBuf::from(key.as_ref());
|
||||
files.update(cx, |files, cx| files.load(folder, cx))
|
||||
})
|
||||
.on_activate(|key, _, cx| {
|
||||
log::info!("files: opening {key}");
|
||||
cx.open_with_system(Path::new(key.as_ref()))
|
||||
})
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user