T13c Files: FileTree with git status and filter, DirectoryListing, SelectableList activation (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 20:06:00 -04:00
1 parent db3b993cac
commit 9eddfeb48b
11 files changed
+859 -10

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use jiff::Timestamp;
use super::{ListItem, SelectableList};
use crate::{
navigation::{Breadcrumb, Crumb},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
typography::{format, tabular},
};
/// One entry of a directory: a folder, or a file with its size; both with when they last changed.
#[derive(Clone, Debug, PartialEq)]
pub struct DirEntry {
name: SharedString,
size: Option<u64>,
modified: Timestamp,
}
impl DirEntry {
pub fn folder(name: impl Into<SharedString>, modified: Timestamp) -> Self {
Self {
name: name.into(),
size: None,
modified,
}
}
pub fn file(name: impl Into<SharedString>, size: u64, modified: Timestamp) -> Self {
Self {
name: name.into(),
size: Some(size),
modified,
}
}
pub fn name(&self) -> &SharedString {
&self.name
}
pub fn is_folder(&self) -> bool {
self.size.is_none()
}
}
/// A column a listing sorts by.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Column {
Name,
Size,
Modified,
}
/// Entries sorted by `column`, folders always first, ties by name.
fn sorted(mut entries: Vec<DirEntry>, column: Column, ascending: bool) -> Vec<DirEntry> {
let by_name = |a: &DirEntry, b: &DirEntry| a.name.to_lowercase().cmp(&b.name.to_lowercase());
entries.sort_by(|a, b| {
let chosen = match column {
Column::Name => by_name(a, b),
Column::Size => a.size.cmp(&b.size),
Column::Modified => a.modified.cmp(&b.modified),
};
let chosen = if ascending { chosen } else { chosen.reverse() };
b.is_folder()
.cmp(&a.is_folder())
.then(chosen)
.then_with(|| by_name(a, b))
});
entries
}
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
type OnClimb = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// A directory's entries in columns: name, size, and when each changed. A header sorts by its column and a second press reverses it; folders stay first. Enter or a double press opens an entry; the path above climbs to an earlier level.
#[derive(IntoElement)]
pub struct DirectoryListing {
id: ElementId,
path: Vec<SharedString>,
entries: Vec<DirEntry>,
on_open: Option<OnEntry>,
on_climb: Option<OnClimb>,
}
impl DirectoryListing {
/// `path` names each level from the root down to this directory.
pub fn new(
id: impl Into<ElementId>,
path: impl IntoIterator<Item = impl Into<SharedString>>,
entries: impl IntoIterator<Item = DirEntry>,
) -> Self {
let path: Vec<SharedString> = path.into_iter().map(Into::into).collect();
assert!(!path.is_empty(), "a listing needs the directory it lists");
Self {
id: id.into(),
path,
entries: entries.into_iter().collect(),
on_open: None,
on_climb: None,
}
}
pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
/// Gets the level pressed in the path, zero at the root.
pub fn on_climb(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_climb = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DirectoryListing {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let order =
window.use_keyed_state((self.id.clone(), "order"), cx, |_, _| (Column::Name, true));
let picked = window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| {
Vec::<SharedString>::new()
});
let (column, ascending) = *order.read(cx);
let entries = Rc::new(sorted(self.entries, column, ascending));
let theme = cx.theme();
let colors = &theme.colors;
let (size_width, date_width) = (theme.label_width() * 0.5, theme.label_width() * 0.75);
let now = Timestamp::now();
let header = |label: &'static str, this: Column| {
let order = order.clone();
div()
.id((self.id.clone(), label))
.flex()
.items_center()
.gap_1()
.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
order.update(cx, |order, cx| {
*order = if order.0 == this {
(this, !order.1)
} else {
(this, true)
};
log::info!(
"directory listing: sorted by {:?}, {}",
order.0,
if order.1 { "rising" } else { "falling" }
);
cx.notify();
})
})
.child(label)
.when(column == this, |header| {
header.child(
Icon::new(if ascending {
IconName::ArrowUpNarrowWide
} else {
IconName::ArrowDownWideNarrow
})
.size(IconSize::Xs)
.color(colors.fg_muted),
)
})
};
let heading = div()
.flex()
.items_center()
.gap_3()
.px_3()
.py_1p5()
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(div().flex_1().child(header("Name", Column::Name)))
.child(
div()
.w(size_width)
.flex()
.justify_end()
.child(header("Size", Column::Size)),
)
.child(
div()
.w(date_width)
.flex()
.justify_end()
.child(header("Modified", Column::Modified)),
);
let list = entries.iter().enumerate().fold(
SelectableList::new((self.id.clone(), "entries")).selected(picked.read(cx).clone()),
|list, (ix, entry)| {
let size = entry
.size
.map(|bytes| format::file_size(bytes, false))
.unwrap_or_else(|| "—".into());
let cells = div()
.flex()
.gap_3()
.child(
tabular(div())
.w(size_width)
.flex()
.justify_end()
.child(size),
)
.child(
div()
.w(date_width)
.flex()
.justify_end()
.child(format::relative(entry.modified, now)),
);
let item =
ListItem::new((self.id.clone(), format!("entry-{ix}")), entry.name.clone())
.leading(
Icon::new(if entry.is_folder() {
IconName::Folder
} else {
IconName::File
})
.size(IconSize::Md)
.color(colors.fg_muted),
)
.trailing(cells);
list.row(entry.name.clone(), item)
},
);
let (store, shown, on_open) = (picked.clone(), entries.clone(), self.on_open);
let crumbs = self.path.iter().enumerate().map(|(ix, name)| {
let crumb = Crumb::new(SharedString::from(ix.to_string()), name.clone());
if ix == 0 {
crumb.icon(IconName::HardDrive)
} else {
crumb
}
});
let on_climb = self.on_climb;
div()
.flex()
.flex_col()
.gap_2()
.child(
Breadcrumb::new((self.id.clone(), "path"), crumbs).on_select(
move |level, _, window, cx| {
if let Some(on_climb) = &on_climb {
on_climb(level, window, cx);
}
},
),
)
.child(heading)
.child(
list.on_change(move |keys, _, cx| {
store.update(cx, |picked, cx| {
*picked = keys.to_vec();
cx.notify();
})
})
.on_activate(move |name, window, cx| {
let entry = shown
.iter()
.find(|entry| entry.name == *name)
.expect("a listed entry");
if let Some(on_open) = &on_open {
on_open(entry, window, cx);
}
}),
)
}
}
#[cfg(test)]
mod tests {
use jiff::Timestamp;
use super::{Column, DirEntry, sorted};
fn names(entries: Vec<DirEntry>) -> Vec<String> {
entries
.into_iter()
.map(|entry| entry.name.to_string())
.collect()
}
#[test]
fn folders_stay_first_whichever_way_it_sorts() {
let at = |seconds: i64| Timestamp::from_second(seconds).expect("a time");
let entries = vec![
DirEntry::file("notes.md", 900, at(30)),
DirEntry::folder("Photos", at(10)),
DirEntry::file("archive.zip", 50_000, at(20)),
DirEntry::folder("apps", at(40)),
];
assert_eq!(
names(sorted(entries.clone(), Column::Name, true)),
["apps", "Photos", "archive.zip", "notes.md"]
);
assert_eq!(
names(sorted(entries.clone(), Column::Size, false)),
["apps", "Photos", "archive.zip", "notes.md"]
);
assert_eq!(
names(sorted(entries.clone(), Column::Modified, true)),
["Photos", "apps", "archive.zip", "notes.md"]
);
assert_eq!(
names(sorted(entries, Column::Name, false)),
["Photos", "apps", "notes.md", "archive.zip"]
);
}
}