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
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m12s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 19:09:21 -04:00
1 parent 2ddc40d939
commit 8369d96b36
11 files changed
+700 -27

No files matched your search

+246
View File
@@ -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;