Files · Items: one icon map, file icon, file row and file grid

The crate had two maps from a file's name to its icon, in chat and in the
file tree, and they disagreed. One map now lives in primitives, over the
Lucide file-* icons, and chat, agent, the file tree and the directory
listing use it. FileIcon draws it for a file or a folder. FileItem is a
file's row, a ListItem with its size and when it changed. FileList is the
directory listing. FileGrid lays tiles in the order given, with a cursor
seeded from the owner's pick: arrows by tile and by row, Home, End, Enter
and a double press to open. The count of what fits a width has one home,
layout's columns_for, and fit builds on it.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 19:40:10 -04:00
1 parent e75171c995
commit a846e10847
36 files changed
+1024 -77

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
rems, transparent_black,
};
use super::FileIcon;
use crate::{
forms::OnValue,
layout::{columns_for, seeded::use_seeded},
lists::DirEntry,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::LEADING,
};
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
/// The tile a key moves to from `at` in `count` tiles laid `columns` a row: across by one, up and down by a row, Home and End to the ends; down from over a short last row lands on its last tile.
pub(crate) fn stepped(at: usize, count: usize, columns: usize, key: &str) -> Option<usize> {
let last = count.checked_sub(1)?;
match key {
"left" => at.checked_sub(1),
"right" => (at < last).then_some(at + 1),
"up" => at.checked_sub(columns),
"down" if at + columns <= last => Some(at + columns),
"down" => (at / columns < last / columns).then_some(last),
"home" => Some(0),
"end" => Some(last),
_ => None,
}
}
/// Files and folders as tiles, in the order given: a large icon over the name in two lines at most. A press picks a tile and a double press opens it; with focus the arrows move by tile and by row, Home and End go to the ends, and Enter opens.
#[derive(IntoElement)]
pub struct FileGrid {
id: ElementId,
entries: Vec<DirEntry>,
selected: Option<SharedString>,
on_select: Option<OnValue>,
on_open: Option<OnEntry>,
}
impl FileGrid {
pub fn new(id: impl Into<ElementId>, entries: impl IntoIterator<Item = DirEntry>) -> Self {
let entries: Vec<DirEntry> = entries.into_iter().collect();
for (ix, entry) in entries.iter().enumerate() {
assert!(
!entries[..ix]
.iter()
.any(|other| other.name() == entry.name()),
"{} twice",
entry.name()
);
}
Self {
id: id.into(),
entries,
selected: None,
on_select: None,
on_open: None,
}
}
/// The entry picked, by name.
pub fn selected(mut self, name: impl Into<SharedString>) -> Self {
let name = name.into();
assert!(
self.entries.iter().any(|entry| *entry.name() == name),
"no entry {name}"
);
self.selected = Some(name);
self
}
/// Gets the name of the entry picked.
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FileGrid {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let cursor = use_seeded((self.id.clone(), "cursor"), self.selected, window, cx);
let width = window.use_keyed_state((self.id.clone(), "width"), cx, |_, _| Pixels::ZERO);
let entries: Rc<[DirEntry]> = self.entries.into();
let count = entries.len();
let at = cursor
.read(cx)
.value
.as_ref()
.and_then(|name| entries.iter().position(|entry| entry.name() == name));
let theme = cx.theme();
let colors = &theme.colors;
let rem = window.rem_size();
let (tile, gap) = (theme.files().tile, rems(0.5));
let columns =
columns_for(*width.read(cx), tile.to_pixels(rem), gap.to_pixels(rem)) as usize;
let pick = {
let (id, entries, cursor, on_select) = (
self.id.clone(),
entries.clone(),
cursor.clone(),
self.on_select,
);
Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
let name = entries[ix].name().clone();
cursor.update(cx, |cursor, cx| {
cursor.value = Some(name.clone());
cx.notify();
});
log::info!("file grid {id:?}: picked {name}");
if let Some(on_select) = &on_select {
on_select(&name, window, cx);
}
})
};
let open = {
let (id, entries, on_open) = (self.id.clone(), entries.clone(), self.on_open);
Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
log::info!("file grid {id:?}: open {}", entries[ix].name());
if let Some(on_open) = &on_open {
on_open(&entries[ix], window, cx);
}
})
};
let tiles = entries.iter().enumerate().map(|(ix, entry)| {
let (pick, open) = (pick.clone(), open.clone());
div()
.id((self.id.clone(), format!("tile-{ix}")))
.flex_none()
.w(tile)
.flex()
.flex_col()
.items_center()
.gap_1()
.px_1()
.py_2()
.rounded(theme.radius(Radius::Md))
.cursor_pointer()
.when(at == Some(ix), |tile| tile.bg(colors.active))
.when(at != Some(ix), |tile| {
tile.hover(|style| style.bg(colors.hover))
})
.child(FileIcon::entry(entry).size(IconSize::Xxl))
.child(
div()
.w_full()
.text_center()
.text_size(theme.text_size(TextSize::Sm))
.line_height(relative(LEADING))
.text_color(colors.fg)
.line_clamp(2)
.child(entry.name().clone()),
)
.on_click(move |event, window, cx| {
pick(ix, window, cx);
if event.click_count() == 2 {
open(ix, window, cx);
}
})
});
let tiles: Vec<_> = tiles.collect();
let measured = width.clone();
div()
.id(self.id)
.debug_selector(|| "file-grid".into())
.relative()
.w_full()
.flex()
.flex_wrap()
.gap(gap)
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.child(
canvas(
move |bounds, window, cx| {
if *measured.read(cx) != bounds.size.width {
measured.update(cx, |width, cx| {
*width = bounds.size.width;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(tiles)
.on_key_down(move |event, window, cx| {
let held = &event.keystroke.modifiers;
if held.platform || held.control {
return;
}
let key = event.keystroke.key.as_str();
if key == "enter" {
if let Some(at) = at {
cx.stop_propagation();
open(at, window, cx);
}
return;
}
let to = match at {
Some(at) => stepped(at, count, columns, key),
None if key == "end" => count.checked_sub(1),
None => stepped(0, count, columns, "home")
.filter(|_| matches!(key, "left" | "right" | "up" | "down" | "home")),
};
if let Some(to) = to {
cx.stop_propagation();
pick(to, window, cx);
}
})
}
}
#[cfg(test)]
mod tests {
use super::stepped;
#[test]
fn keys_move_across_by_one_and_down_by_a_row() {
assert_eq!(stepped(2, 7, 3, "right"), Some(3));
assert_eq!(stepped(0, 7, 3, "left"), None);
assert_eq!(stepped(1, 7, 3, "down"), Some(4));
assert_eq!(stepped(4, 7, 3, "down"), Some(6), "onto the short last row");
assert_eq!(stepped(6, 7, 3, "down"), None);
assert_eq!(stepped(5, 7, 3, "up"), Some(2));
assert_eq!(stepped(1, 7, 3, "up"), None);
assert_eq!(stepped(3, 7, 3, "end"), Some(6));
assert_eq!(stepped(0, 0, 3, "home"), None);
}
}
+55
View File
@@ -0,0 +1,55 @@
use gpui::{App, IntoElement, RenderOnce, Window};
use crate::{
lists::DirEntry,
primitives::{Icon, IconName, file_icon},
theme::{ActiveTheme, IconSize},
};
/// A file's icon by its name's extension, or a folder's, open or shut.
#[derive(IntoElement)]
pub struct FileIcon {
icon: IconName,
size: IconSize,
}
impl FileIcon {
pub fn file(name: impl AsRef<str>) -> Self {
Self {
icon: file_icon(name.as_ref()),
size: IconSize::Md,
}
}
pub fn folder(open: bool) -> Self {
Self {
icon: if open {
IconName::FolderOpen
} else {
IconName::Folder
},
size: IconSize::Md,
}
}
/// The icon for a listing's entry: a shut folder, or the file's by its name.
pub fn entry(entry: &DirEntry) -> Self {
match entry.is_folder() {
true => Self::folder(false),
false => Self::file(entry.name()),
}
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for FileIcon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
Icon::new(self.icon)
.size(self.size)
.color(cx.theme().colors.fg_muted)
}
}
+48
View File
@@ -0,0 +1,48 @@
use gpui::{App, ElementId, IntoElement, RenderOnce, Window};
use jiff::Timestamp;
use super::FileIcon;
use crate::{
lists::{DirEntry, ListItem},
typography::format,
};
/// A file's size and when it changed, or a folder's word and when it changed.
pub(crate) fn detail(entry: &DirEntry, now: Timestamp) -> String {
let when = format::relative(entry.modified(), now);
match entry.size() {
Some(bytes) => format!("{} · {when}", format::file_size(bytes, false)),
None => format!("Folder · {when}"),
}
}
/// A file or folder as a row: its icon and name over its size and when it changed. As a `ListItem`, it goes into a `SelectableList`.
#[derive(IntoElement)]
pub struct FileItem {
id: ElementId,
entry: DirEntry,
}
impl FileItem {
pub fn new(id: impl Into<ElementId>, entry: DirEntry) -> Self {
Self {
id: id.into(),
entry,
}
}
}
impl From<FileItem> for ListItem {
fn from(item: FileItem) -> Self {
let detail = detail(&item.entry, Timestamp::now());
ListItem::new(item.id, item.entry.name().clone())
.leading(FileIcon::entry(&item.entry))
.description(detail)
}
}
impl RenderOnce for FileItem {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
ListItem::from(self)
}
}
+10
View File
@@ -0,0 +1,10 @@
mod grid;
mod icon;
mod item;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use grid::FileGrid;
pub use icon::FileIcon;
pub use item::FileItem;
+158
View File
@@ -0,0 +1,158 @@
use gpui::{
Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement,
Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use jiff::{Timestamp, ToSpan};
use super::{FileGrid, item::detail};
use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn then() -> Timestamp {
Timestamp::from_second(1_700_000_000).expect("a time")
}
#[test]
fn a_row_says_a_files_size_and_a_folders_kind_with_when_each_changed() {
let now = then() + 2.hours();
assert_eq!(
detail(&DirEntry::file("a.txt", 2_400, then()), now),
"2.4 KB · 2 hours ago"
);
assert_eq!(
detail(&DirEntry::folder("Docs", then()), now),
"Folder · 2 hours ago"
);
}
/// Seven files in a grid three tiles across, and what it picked and opened.
struct Tiled {
selected: Option<SharedString>,
picked: Vec<SharedString>,
opened: Vec<SharedString>,
}
impl Render for Tiled {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (pick, open) = (cx.entity(), cx.entity());
let names = ["a", "b", "c", "d", "e", "f", "g"];
let grid = FileGrid::new("grid", names.map(|name| DirEntry::file(name, 10, then())))
.on_select(move |name, _, cx| pick.update(cx, |view, _| view.picked.push(name.clone())))
.on_open(move |entry, _, cx| {
open.update(cx, |view, _| view.opened.push(entry.name().clone()))
});
let grid = match self.selected.clone() {
Some(name) => grid.selected(name),
None => grid,
};
div().w(px(320.0)).child(grid)
}
}
fn tiled<'a>(
selected: Option<&'static str>,
cx: &'a mut TestAppContext,
) -> (Entity<Tiled>, &'a mut VisualTestContext) {
setup(cx);
let selected = selected.map(|name| SharedString::from(name.to_string()));
let (view, cx) = cx.add_window_view(move |_, _| Tiled {
selected,
picked: Vec::new(),
opened: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
fn heard(
view: &Entity<Tiled>,
cx: &mut VisualTestContext,
) -> (Vec<SharedString>, Vec<SharedString>) {
view.read_with(cx, |view, _| (view.picked.clone(), view.opened.clone()))
}
#[gpui::test]
fn the_arrows_move_by_tile_and_by_row_and_enter_opens(cx: &mut TestAppContext) {
let (view, cx) = tiled(None, cx);
cx.update(|window, _| window.focus_next());
for key in ["right", "right", "down", "down", "cmd-left"] {
press(key, cx);
}
press("enter", cx);
let (picked, opened) = heard(&view, cx);
assert_eq!(picked, ["a", "b", "e", "g"], "a command key passes");
assert_eq!(opened, ["g"]);
}
#[gpui::test]
fn the_keys_start_from_the_tile_picked(cx: &mut TestAppContext) {
let (view, cx) = tiled(Some("c"), cx);
cx.update(|window, _| window.focus_next());
press("down", cx);
assert_eq!(heard(&view, cx).0, ["f"]);
}
#[gpui::test]
fn a_press_picks_a_tile_and_a_double_press_opens_it(cx: &mut TestAppContext) {
let (view, cx) = tiled(None, cx);
let grid = cx.debug_bounds("file-grid").expect("the grid draws");
let first = grid.origin + point(px(12.0), px(8.0));
cx.simulate_click(first, Modifiers::none());
settle(cx);
assert_eq!(heard(&view, cx), (vec!["a".into()], vec![]));
cx.simulate_event(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position: first,
modifiers: Modifiers::none(),
click_count: 2,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
button: gpui::MouseButton::Left,
position: first,
modifiers: Modifiers::none(),
click_count: 2,
});
settle(cx);
assert_eq!(heard(&view, cx).1, ["a"]);
}
#[test]
#[should_panic(expected = "a twice")]
fn a_name_is_listed_once() {
let _ = FileGrid::new(
"grid",
["a", "a"].map(|name| DirEntry::file(name, 1, then())),
);
}
#[test]
#[should_panic(expected = "no entry z")]
fn the_pick_is_one_of_the_entries() {
let _ = FileGrid::new("grid", [DirEntry::file("a", 1, then())]).selected("z");
}