Files · Explorer: a folder as a list, tiles or columns, with one path and one order

FileExplorer shows the folder at the end of a path three ways: the
directory listing, a grid of tiles, or a column per folder along the
path with the next folder lit. Its name, its count and a switch of views
sit above one path bar that climbs. A folder opens in place; a file goes
to the host, which lists each folder it goes to. A pick in an earlier
column closes the columns past it, and a new deepest column scrolls into
view. The directory listing takes its pick from its owner and reports
it, so the pick carries across views; its path bar and its first order
are shared. The gallery walks a small file system and shows the tree,
the context menu, renaming in place and a drop zone in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 20:07:33 -04:00
1 parent 906e6c8ac1
commit f694f0d413
17 files changed
+991 -41

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
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};
mod explorer;
pub(super) 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)]);
});
}
pub(super) fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
pub(super) 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);
}
pub(super) 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");
}
/// One tile whose name runs long.
struct Named;
impl Render for Named {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let name = "A very long file name that keeps going past two whole lines.pdf";
div()
.w(px(280.0))
.child(FileGrid::new("grid", [DirEntry::file(name, 10, then())]))
}
}
#[gpui::test]
fn a_long_name_takes_one_line_inside_its_tile(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Named);
settle(cx);
let (name, grid) = (
cx.debug_bounds("file-name 0").expect("the name draws"),
cx.debug_bounds("file-grid").expect("the grid draws"),
);
assert!(name.size.height < px(24.0), "one line: {name:?}");
assert!(
name.left() >= grid.left() && name.right() <= grid.right(),
"{name:?} in {grid:?}"
);
}