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.
This commit is contained in:
1 parent
501d1b0f1a
commit
14de69fed6
17 files changed
+991
-41
No files matched your search
@@ -0,0 +1,176 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, TestAppContext,
|
||||
VisualTestContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{press, settle, setup, then};
|
||||
use crate::{
|
||||
files::{FileExplorer, FileView},
|
||||
lists::DirEntry,
|
||||
};
|
||||
|
||||
/// An explorer two folders deep, and what it asked for.
|
||||
struct Exploring {
|
||||
view: FileView,
|
||||
selected: Option<&'static str>,
|
||||
went: Vec<Vec<SharedString>>,
|
||||
picked: Vec<SharedString>,
|
||||
opened: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl Render for Exploring {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (view, go, pick, open) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
|
||||
let root = vec![
|
||||
DirEntry::folder("Projects", then()),
|
||||
DirEntry::folder("Documents", then()),
|
||||
DirEntry::file("Notes.txt", 10, then()),
|
||||
];
|
||||
let here = vec![
|
||||
DirEntry::file("Brief.md", 10, then()),
|
||||
DirEntry::folder("Atrium", then()),
|
||||
];
|
||||
let explorer = FileExplorer::new("explorer", ["Home", "Projects"], [root, here], self.view)
|
||||
.on_view(move |to, _, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.view = to;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_navigate(move |path, _, cx| go.update(cx, |view, _| view.went.push(path.to_vec())))
|
||||
.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 explorer = match self.selected {
|
||||
Some(name) => explorer.selected(name),
|
||||
None => explorer,
|
||||
};
|
||||
div().w(px(640.0)).child(explorer)
|
||||
}
|
||||
}
|
||||
|
||||
fn exploring<'a>(
|
||||
view: FileView,
|
||||
selected: Option<&'static str>,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Exploring>, &'a mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(move |_, _| Exploring {
|
||||
view,
|
||||
selected,
|
||||
went: Vec::new(),
|
||||
picked: Vec::new(),
|
||||
opened: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
/// Moves focus to the `nth` Tab stop from none: the three views, the path's first level, then the view's own.
|
||||
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..nth {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn went(host: &Entity<Exploring>, cx: &mut VisualTestContext) -> Vec<Vec<SharedString>> {
|
||||
host.read_with(cx, |host, _| host.went.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_segment_switches_the_view(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::List, None, cx);
|
||||
tab_to(2, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(host.read_with(cx, |host, _| host.view), FileView::Icons);
|
||||
assert!(cx.debug_bounds("file-grid").is_some());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_folder_opens_in_place_and_a_file_goes_to_the_host(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::Icons, None, cx);
|
||||
tab_to(5, cx);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
went(&host, cx),
|
||||
[["Home", "Projects", "Atrium"]],
|
||||
"folders come first"
|
||||
);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.opened.clone()),
|
||||
["Brief.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_path_climbs(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::Icons, None, cx);
|
||||
tab_to(4, cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(went(&host, cx), [["Home"]]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn columns_walk_the_path_and_pick_in_the_last(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::Columns, None, cx);
|
||||
assert!(cx.debug_bounds("file-columns").is_some());
|
||||
tab_to(5, cx);
|
||||
press("up", cx);
|
||||
assert_eq!(
|
||||
went(&host, cx),
|
||||
[["Home", "Documents"]],
|
||||
"the lit folder was Projects"
|
||||
);
|
||||
tab_to(6, cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
let heard = host.read_with(cx, |host, _| (host.picked.clone(), host.opened.clone()));
|
||||
assert_eq!(heard, (vec!["Brief.md".into()], vec!["Brief.md".into()]));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_file_picked_in_an_earlier_column_closes_the_columns_past_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::Columns, None, cx);
|
||||
tab_to(5, cx);
|
||||
press("down", cx);
|
||||
let heard = host.read_with(cx, |host, _| (host.went.clone(), host.picked.clone()));
|
||||
assert_eq!(heard, (vec![vec!["Home".into()]], vec!["Notes.txt".into()]));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_pick_carries_into_the_list(cx: &mut TestAppContext) {
|
||||
let (host, cx) = exploring(FileView::List, Some("Brief.md"), cx);
|
||||
tab_to(5, cx);
|
||||
press("up", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.picked.clone()),
|
||||
["Atrium"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "2 folders on the path, 1 listed")]
|
||||
fn each_folder_on_the_path_is_listed() {
|
||||
let _ = FileExplorer::new(
|
||||
"explorer",
|
||||
["Home", "Projects"],
|
||||
[Vec::new()],
|
||||
FileView::List,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no entry Ghost.md")]
|
||||
fn the_pick_is_in_the_folder_shown() {
|
||||
let here = vec![DirEntry::file("Brief.md", 10, then())];
|
||||
let _ = FileExplorer::new("explorer", ["Home"], [here], FileView::List).selected("Ghost.md");
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user