From 14de69fed6d853eeb8328232b135251ea592b8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 20:07:33 -0400 Subject: [PATCH] =?UTF-8?q?Files=20=C2=B7=20Explorer:=20a=20folder=20as=20?= =?UTF-8?q?a=20list,=20tiles=20or=20columns,=20with=20one=20path=20and=20o?= =?UTF-8?q?ne=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 1 + TASKS.md | 3 +- assets/icons/columns-3.svg | 17 ++ examples/gallery/pages/files/actions.rs | 120 ++++++++++ examples/gallery/pages/files/explorer.rs | 135 +++++++++++ examples/gallery/pages/files/mod.rs | 29 ++- src/files/columns.rs | 130 +++++++++++ src/files/explorer.rs | 279 +++++++++++++++++++++++ src/files/grid.rs | 4 +- src/files/mod.rs | 11 + src/files/tests/explorer.rs | 176 ++++++++++++++ src/files/{tests.rs => tests/mod.rs} | 10 +- src/lists/directory.rs | 97 +++++--- src/lists/mod.rs | 1 + src/primitives/icon.rs | 1 + src/theme/files.rs | 6 + tasks/ch21-30.md | 12 +- 17 files changed, 991 insertions(+), 41 deletions(-) create mode 100644 assets/icons/columns-3.svg create mode 100644 examples/gallery/pages/files/actions.rs create mode 100644 examples/gallery/pages/files/explorer.rs create mode 100644 src/files/columns.rs create mode 100644 src/files/explorer.rs create mode 100644 src/files/tests/explorer.rs rename src/files/{tests.rs => tests/mod.rs} (96%) diff --git a/AGENTS.md b/AGENTS.md index 5cd5889..1b3b46b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui 0.2.2's `line_clamp` wraps once and lays the rest on the last line, cut hard; centered, it loses both ends. A name whose ends matter is a `typography::MiddleEllipsis` on one line (`files::FileGrid`). - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. +- A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order. - A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. - A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model). diff --git a/TASKS.md b/TASKS.md index e4b4dcb..637df52 100644 --- a/TASKS.md +++ b/TASKS.md @@ -94,7 +94,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T26c Media · Video - [x] T26d Media · Audio and controls - [x] T26e Media · Capture and devices -- [ ] T27a Files · Items +- [x] T27a Files · Items - [ ] T27b Files · Explorer - [ ] T27c Files · Find and look - [ ] T27d Files · Transfers @@ -322,3 +322,4 @@ Per-component lines live in `tasks/`. Tags there: | T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window | | T26e | 2 | PASS | | | T27a | 1 | FAIL | a tile's long name leaned on gpui's line_clamp, which cut its second line at both ends and lost the extension | +| T27a | 2 | PASS | | diff --git a/assets/icons/columns-3.svg b/assets/icons/columns-3.svg new file mode 100644 index 0000000..45cd587 --- /dev/null +++ b/assets/icons/columns-3.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/examples/gallery/pages/files/actions.rs b/examples/gallery/pages/files/actions.rs new file mode 100644 index 0000000..5271fe1 --- /dev/null +++ b/examples/gallery/pages/files/actions.rs @@ -0,0 +1,120 @@ +use ely_gpui_component::{ + files::{FileGrid, FileIcon, FileItem}, + forms::{DropZone, InlineEdit}, + lists::{DirEntry, SelectableList}, + menus::{ContextMenu, Menu, MenuItem}, + primitives::IconName, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::Timestamp; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +pub fn context_menu(cx: &mut App) -> impl IntoElement + use<> { + let item = |label: &'static str, icon: IconName| { + MenuItem::new(label) + .icon(icon) + .on_click(move |_, _| log::info!("gallery: {label}")) + }; + let menu = Menu::new() + .item(item("Open", IconName::FolderOpen)) + .item(item("Rename", IconName::PenLine)) + .item(item("Duplicate", IconName::Copy)) + .separator() + .item(item("Move to Trash", IconName::Trash2)); + section( + "FileContextMenu", + "What a right press on files offers, as menus::ContextMenu draws it: open, rename, duplicate, and the trash apart.", + cx, + ) + .child(probe( + "files-context", + div().w(px(640.)).child( + ContextMenu::new("files-context", menu) + .child(FileGrid::new("files-context-grid", super::entries().into_iter().take(6))), + ), + )) +} + +pub fn rename(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let name = keep( + "files-rename", + || SharedString::from("Brief.md"), + window, + cx, + ); + let now = name.read(cx).clone(); + section( + "RenameInline", + "A name edited in place, as forms::InlineEdit does it: a press or Enter edits, Enter keeps and Escape drops; the icon follows the new name.", + cx, + ) + .child(probe( + "files-rename", + div() + .w(px(320.)) + .flex() + .items_center() + .gap_2() + .child(FileIcon::file(&now)) + .child(div().flex_1().min_w_0().child( + InlineEdit::new("files-rename", now).on_commit(move |text, _, cx| { + change(&name, cx, |name| *name = text.clone()) + }), + )), + )) +} + +pub fn drop_files(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let dropped = keep("files-dropped", Vec::::new, window, cx); + let now = dropped.read(cx).clone(); + let take = dropped.clone(); + let list = now + .iter() + .fold(SelectableList::new("files-dropped"), |list, entry| { + let key = entry.name().clone(); + let id = SharedString::from(format!("files-dropped-{key}")); + list.row(key, FileItem::new(id, entry.clone()).into()) + }); + section( + "DragDropFiles", + "Files dropped in, as forms::DropZone takes them; each lands in the list below.", + cx, + ) + .child(probe( + "files-drop", + div() + .w(px(420.)) + .flex() + .flex_col() + .gap_3() + .child( + DropZone::new("files-drop") + .multiple() + .hint("Any kind of file") + .on_drop(move |paths, _, cx| { + let entries = paths.iter().map(|path| { + let bytes = path.metadata().expect("a dropped file reads").len(); + let name = path.file_name().expect("a file name"); + DirEntry::file( + name.to_string_lossy().to_string(), + bytes, + Timestamp::now(), + ) + }); + let entries: Vec = entries.collect(); + change(&take, cx, |dropped| { + for entry in entries { + if !dropped.iter().any(|kept| kept.name() == entry.name()) { + dropped.push(entry); + } + } + }) + }), + ) + .child(list), + )) +} diff --git a/examples/gallery/pages/files/explorer.rs b/examples/gallery/pages/files/explorer.rs new file mode 100644 index 0000000..5cb82e5 --- /dev/null +++ b/examples/gallery/pages/files/explorer.rs @@ -0,0 +1,135 @@ +use ely_gpui_component::{ + files::{FileExplorer, FileView}, + lists::{DirEntry, FileTree}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// What the demo's folder at `path` holds, the root first. +pub fn listing(path: &[SharedString]) -> Vec { + let now = Timestamp::now(); + let file = |name: &str, bytes: u64, hours: i64| { + DirEntry::file(name.to_string(), bytes, now - hours.hours()) + }; + let folder = |name: &str, hours: i64| DirEntry::folder(name.to_string(), now - hours.hours()); + let joined: Vec<&str> = path.iter().map(|level| level.as_ref()).collect(); + match joined.join("/").as_str() { + "Home" => vec![ + folder("Projects", 2), + folder("Documents", 30), + folder("Music", 400), + file("Notes.txt", 1_200, 5), + ], + "Home/Projects" => vec![ + folder("Atrium", 1), + folder("Dunes", 90), + file("Roadmap.md", 6_400, 20), + ], + "Home/Projects/Atrium" => super::entries(), + "Home/Projects/Atrium/Design" => vec![ + file("Moodboard.png", 3_200_000, 4), + file("Palette.json", 2_100, 8), + file("Display.ttf", 180_000, 300), + ], + "Home/Projects/Atrium/Recordings" => vec![ + file("Walkthrough.mov", 184_000_000, 120), + file("Voiceover.m4a", 12_000_000, 60), + ], + "Home/Projects/Dunes" => vec![ + file("Survey.csv", 42_000, 100), + file("Dunes.jpg", 2_800_000, 200), + ], + "Home/Documents" => vec![ + file("Invoice.pdf", 88_000, 40), + file("Contract.docx", 64_000, 700), + ], + "Home/Music" => vec![ + file("Theme.mp3", 7_400_000, 800), + file("Ambient.flac", 31_000_000, 900), + ], + other => panic!("the demo has no folder {other}"), + } +} + +/// Where the demo's explorer stands, how it shows it, and its pick. +#[derive(Clone)] +struct Browsing { + path: Vec, + view: FileView, + selected: Option, +} + +pub fn explorer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "files-explorer", + || Browsing { + path: ["Home", "Projects", "Atrium"] + .map(SharedString::from) + .to_vec(), + view: FileView::Columns, + selected: None, + }, + window, + cx, + ); + let now = state.read(cx).clone(); + let levels = (1..=now.path.len()).map(|depth| listing(&now.path[..depth])); + let (view, go, pick) = (state.clone(), state.clone(), state); + let explorer = FileExplorer::new("files-explorer", now.path.clone(), levels, now.view) + .on_view(move |to, _, cx| change(&view, cx, |it| it.view = to)) + .on_navigate(move |path, _, cx| { + change(&go, cx, |it| { + it.path = path.to_vec(); + it.selected = None; + }) + }) + .on_select(move |name, _, cx| change(&pick, cx, |it| it.selected = Some(name.clone()))) + .on_open(|entry, _, _| log::info!("gallery: open {}", entry.name())); + let explorer = match now.selected { + Some(name) => explorer.selected(name), + None => explorer, + }; + section( + "FileExplorer", + "A folder three ways: a list that sorts by column, tiles, or columns that walk the path. Its name, its count and a switch sit above the path, which climbs; a folder opens in place.", + cx, + ) + .child(probe("files-explorer", div().w(px(640.)).child(explorer))) +} + +/// Every file of the demo under `path`, by its path below the root. +fn files_under(path: Vec, out: &mut Vec) { + for entry in listing(&path) { + let mut next = path.clone(); + next.push(entry.name().clone()); + if entry.is_folder() { + files_under(next, out); + } else { + let below: Vec<&str> = next[1..].iter().map(|level| level.as_ref()).collect(); + out.push(below.join("/")); + } + } +} + +pub fn tree(cx: &mut App) -> impl IntoElement + use<> { + let mut paths = Vec::new(); + files_under(vec!["Home".into()], &mut paths); + section( + "FolderTree", + "The same folders as lists::FileTree draws them: folders first, a filter above, Enter or a double press opens a file.", + cx, + ) + .child(probe( + "files-tree", + div().w(px(320.)).h(px(320.)).child( + FileTree::new("files-tree", paths) + .size_full() + .on_open(|path, _, _| log::info!("gallery: open {path}")), + ), + )) +} diff --git a/examples/gallery/pages/files/mod.rs b/examples/gallery/pages/files/mod.rs index ccbfd61..38825df 100644 --- a/examples/gallery/pages/files/mod.rs +++ b/examples/gallery/pages/files/mod.rs @@ -6,6 +6,9 @@ use ely_gpui_component::{ use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; use jiff::{Timestamp, ToSpan}; +mod actions; +mod explorer; + use super::Page; use crate::{ probe::probe, @@ -29,10 +32,29 @@ const SCRIPT: &[Step] = &[ Step::Key("down"), Step::Wait(200), Step::Shot("grid-picked"), + Step::DownAt("files-explorer", 515.0, 14.0), + Step::UpAt("files-explorer", 515.0, 14.0), + Step::Wait(300), + Step::Shot("explorer-icons"), + Step::DownAt("files-explorer", 48.0, 120.0), + Step::UpAt("files-explorer", 48.0, 120.0), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("explorer-into"), + Step::RightAt("files-context", 150.0, 50.0), + Step::Wait(300), + Step::Shot("context-menu"), + Step::Key("escape"), + Step::DownAt("files-rename", 80.0, 14.0), + Step::UpAt("files-rename", 80.0, 14.0), + Step::Type("Brief notes.md"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("renamed"), ]; /// A project's entries, changed a few hours to a few weeks ago. -fn entries() -> Vec { +pub fn entries() -> Vec { let now = Timestamp::now(); let ago = |hours: i64| now - hours.hours(); vec![ @@ -170,5 +192,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(items(window, cx)) .child(listing(cx)) .child(grid(window, cx)) + .child(explorer::explorer(window, cx)) + .child(explorer::tree(cx)) + .child(actions::context_menu(cx)) + .child(actions::rename(window, cx)) + .child(actions::drop_files(window, cx)) .into_any_element() } diff --git a/src/files/columns.rs b/src/files/columns.rs new file mode 100644 index 0000000..edb3eb9 --- /dev/null +++ b/src/files/columns.rs @@ -0,0 +1,130 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{FileIcon, OnEntry, explorer::OnPath}; +use crate::{ + forms::{OnValue, reveal, revealer}, + layout::on_axis, + lists::{DirEntry, ListItem, SelectableList}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius}, +}; + +/// The columns' scroll, and the deepest column it last brought into view. +#[derive(Default)] +struct Walk { + scroll: ScrollHandle, + revealed: Option, +} + +/// Each folder along the path as a column, the next folder on the path lit in it and the pick lit in the last. A pick of a folder goes into it; a pick of a file closes the columns past its own and picks it; Enter or a double press opens a file. A new deepest column scrolls into view. +pub(super) fn columns( + id: ElementId, + path: Rc<[SharedString]>, + levels: Vec>, + selected: Option, + (go, select, open): (OnPath, OnValue, OnEntry), + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let walk = window.use_keyed_state((id.clone(), "walk"), cx, |_, _| Walk::default()); + let last = levels.len() - 1; + let shown = reveal(&walk, |walk| &mut walk.revealed, true, last, cx); + let scroll = walk.read(cx).scroll.clone(); + let theme = cx.theme(); + let colors = &theme.colors; + let sizes = theme.files(); + let levels = Rc::new(levels); + let columns: Vec<_> = levels + .iter() + .enumerate() + .map(|(depth, entries)| { + let rows = entries.iter().enumerate().fold( + SelectableList::new((id.clone(), format!("column-{depth}"))), + |list, (ix, entry)| { + let row = ListItem::new( + (id.clone(), format!("row-{depth}-{ix}")), + entry.name().clone(), + ) + .leading(FileIcon::entry(entry)) + .when(entry.is_folder(), |row| { + row.trailing( + Icon::new(IconName::ChevronRight) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + }); + list.row(entry.name().clone(), row) + }, + ); + let lit = match depth < last { + true => Some(path[depth + 1].clone()), + false => selected.clone(), + }; + let (go, select, path, picked) = + (go.clone(), select.clone(), path.clone(), levels.clone()); + let (open, opened) = (open.clone(), levels.clone()); + div() + .flex_none() + .w(sizes.column) + .h_full() + .p_1() + .border_r_1() + .border_color(colors.border) + .child( + rows.selected(lit) + .size_full() + .on_change(move |keys, window, cx| { + let Some(name) = keys.first() else { + return; + }; + let entry = picked[depth] + .iter() + .find(|entry| entry.name() == name) + .expect("a listed entry"); + if entry.is_folder() { + let inside: Vec = path[..=depth] + .iter() + .cloned() + .chain([name.clone()]) + .collect(); + return go(&inside, window, cx); + } + if depth < last { + go(&path[..=depth], window, cx); + } + select(name, window, cx); + }) + .on_activate(move |name, window, cx| { + let entry = opened[depth] + .iter() + .find(|entry| entry.name() == name) + .expect("a listed entry"); + if !entry.is_folder() { + open(entry, window, cx); + } + }), + ) + }) + .collect(); + on_axis( + div() + .id(id) + .debug_selector(|| "file-columns".into()) + .h(sizes.columns) + .flex() + .overflow_x_scroll() + .track_scroll(&scroll) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .children(columns) + .when_some(shown, |row, (ix, done)| { + row.child(revealer(&scroll, ix, done)) + }), + ) +} diff --git a/src/files/explorer.rs b/src/files/explorer.rs new file mode 100644 index 0000000..52adf01 --- /dev/null +++ b/src/files/explorer.rs @@ -0,0 +1,279 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; + +use super::{FileGrid, OnEntry, columns::columns}; +use crate::{ + buttons::SegmentedControl, + forms::OnValue, + lists::{DirEntry, DirectoryListing, OnClimb, listed, path_bar}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::Ellipsis, +}; + +pub(super) type OnPath = Rc; +type OnView = Rc; + +/// How an explorer shows a folder: a list that sorts by column, tiles, or columns along the path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileView { + List, + Icons, + Columns, +} + +/// Each view with its key, label and icon, in the switch's order. +const VIEWS: [(FileView, &str, &str, IconName); 3] = [ + (FileView::List, "list", "List", IconName::List), + (FileView::Icons, "icons", "Icons", IconName::LayoutGrid), + (FileView::Columns, "columns", "Columns", IconName::Columns3), +]; + +/// A folder three ways: a list that sorts by column, tiles, or columns that walk the path. Its name, its count and a switch of views sit above the path, which climbs. Opening a folder goes into it; opening a file hands it to the host, which lists each folder it goes to. +#[derive(IntoElement)] +pub struct FileExplorer { + id: ElementId, + path: Vec, + levels: Vec>, + view: FileView, + selected: Option, + on_view: Option, + on_navigate: Option, + on_select: Option, + on_open: Option, +} + +impl FileExplorer { + /// `path` names each folder from the root down; `levels` lists each of them, the root's first. + pub fn new( + id: impl Into, + path: impl IntoIterator>, + levels: impl IntoIterator>, + view: FileView, + ) -> Self { + let path: Vec = path.into_iter().map(Into::into).collect(); + let levels: Vec> = levels.into_iter().collect(); + assert!( + !path.is_empty() && path.len() == levels.len(), + "{} folders on the path, {} listed", + path.len(), + levels.len() + ); + Self { + id: id.into(), + path, + levels, + view, + selected: None, + on_view: None, + on_navigate: None, + on_select: None, + on_open: None, + } + } + + /// The entry picked in the folder shown, by name. + pub fn selected(mut self, name: impl Into) -> Self { + let name = name.into(); + let here = self.levels.last().expect("a listed folder"); + assert!( + here.iter().any(|entry| *entry.name() == name), + "no entry {name}" + ); + self.selected = Some(name); + self + } + + pub fn on_view(mut self, handler: impl Fn(FileView, &mut Window, &mut App) + 'static) -> Self { + self.on_view = Some(Rc::new(handler)); + self + } + + /// Gets the path to go to, from the root down. + pub fn on_navigate( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_navigate = Some(Rc::new(handler)); + 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 + } + + /// Gets a file to open; a folder opens in place. + 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 FileExplorer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let path: Rc<[SharedString]> = self.path.into(); + let go: OnPath = { + let (id, on_navigate) = (id.clone(), self.on_navigate); + Rc::new(move |to, window, cx| { + log::info!("file explorer {id:?}: go to {}", to.join("/")); + if let Some(on_navigate) = &on_navigate { + on_navigate(to, window, cx); + } + }) + }; + let climb: OnClimb = { + let (go, path) = (go.clone(), path.clone()); + Rc::new(move |level, window, cx| go(&path[..=level], window, cx)) + }; + let open: OnEntry = { + let (id, go, path, on_open) = (id.clone(), go.clone(), path.clone(), self.on_open); + Rc::new(move |entry, window, cx| { + if entry.is_folder() { + let inside: Vec = + path.iter().cloned().chain([entry.name().clone()]).collect(); + return go(&inside, window, cx); + } + log::info!("file explorer {id:?}: open {}", entry.name()); + if let Some(on_open) = &on_open { + on_open(entry, window, cx); + } + }) + }; + let select: OnValue = { + let (id, on_select) = (id.clone(), self.on_select); + Rc::new(move |name, window, cx| { + log::info!("file explorer {id:?}: picked {name}"); + if let Some(on_select) = &on_select { + on_select(name, window, cx); + } + }) + }; + let mut levels = self.levels; + let here = levels.last().expect("a listed folder").clone(); + let theme = cx.theme(); + let colors = &theme.colors; + let count = here.len(); + let key = VIEWS + .iter() + .find(|each| each.0 == self.view) + .expect("a listed view") + .1; + let on_view = self.on_view; + let switch = VIEWS + .iter() + .fold( + SegmentedControl::new((id.clone(), "view"), key).size(ControlSize::Sm), + |switch, (_, key, label, icon)| switch.segment(*key, *label, Some(*icon)), + ) + .on_change(move |key, window, cx| { + let view = VIEWS + .iter() + .find(|each| each.1 == key.as_ref()) + .expect("a listed view") + .0; + if let Some(on_view) = &on_view { + on_view(view, window, cx); + } + }); + let header = div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .items_baseline() + .gap_2() + .child( + div() + .min_w_0() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(Ellipsis::new(path[path.len() - 1].clone())), + ) + .child( + div() + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(match count { + 1 => "1 item".to_string(), + count => format!("{count} items"), + }), + ), + ) + .child(div().flex_none().child(switch)); + let body = match self.view { + FileView::List => { + let listing = DirectoryListing::new((id.clone(), "list"), path.to_vec(), here) + .on_select(move |name, window, cx| select(name, window, cx)) + .on_open(move |entry, window, cx| open(entry, window, cx)) + .on_climb(move |level, window, cx| climb(level, window, cx)); + match self.selected { + Some(name) => listing.selected(name), + None => listing, + } + .into_any_element() + } + FileView::Icons => { + let grid = FileGrid::new((id.clone(), "icons"), listed(here)) + .on_select(move |name, window, cx| select(name, window, cx)) + .on_open(move |entry, window, cx| open(entry, window, cx)); + let grid = match self.selected { + Some(name) => grid.selected(name), + None => grid, + }; + div() + .flex() + .flex_col() + .gap_2() + .child(path_bar((id.clone(), "path"), &path, Some(climb))) + .child(grid) + .into_any_element() + } + FileView::Columns => { + levels + .iter_mut() + .for_each(|level| *level = listed(std::mem::take(level))); + div() + .flex() + .flex_col() + .gap_2() + .child(path_bar((id.clone(), "path"), &path, Some(climb))) + .child(columns( + (id.clone(), "columns").into(), + path.clone(), + levels, + self.selected, + (go, select, open), + window, + cx, + )) + .into_any_element() + } + }; + div() + .debug_selector(|| "file-explorer".into()) + .w_full() + .flex() + .flex_col() + .gap_3() + .child(header) + .child(body) + } +} diff --git a/src/files/grid.rs b/src/files/grid.rs index c729736..c147f5d 100644 --- a/src/files/grid.rs +++ b/src/files/grid.rs @@ -6,7 +6,7 @@ use gpui::{ rems, transparent_black, }; -use super::FileIcon; +use super::{FileIcon, OnEntry}; use crate::{ forms::OnValue, layout::{columns_for, seeded::use_seeded}, @@ -16,8 +16,6 @@ use crate::{ typography::{LEADING, MiddleEllipsis}, }; -type OnEntry = Rc; - /// 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 { let last = count.checked_sub(1)?; diff --git a/src/files/mod.rs b/src/files/mod.rs index 9f61dfc..7cd2b1f 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -1,10 +1,21 @@ +use std::rc::Rc; + +use gpui::{App, Window}; + +use crate::lists::DirEntry; + +mod columns; +mod explorer; mod grid; mod icon; mod item; +type OnEntry = Rc; + #[cfg(all(test, feature = "test-support"))] mod tests; +pub use explorer::{FileExplorer, FileView}; pub use grid::FileGrid; pub use icon::FileIcon; pub use item::FileItem; diff --git a/src/files/tests/explorer.rs b/src/files/tests/explorer.rs new file mode 100644 index 0000000..42eff52 --- /dev/null +++ b/src/files/tests/explorer.rs @@ -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>, + picked: Vec, + opened: Vec, +} + +impl Render for Exploring { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &'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, cx: &mut VisualTestContext) -> Vec> { + 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"); +} diff --git a/src/files/tests.rs b/src/files/tests/mod.rs similarity index 96% rename from src/files/tests.rs rename to src/files/tests/mod.rs index 8926f69..003cee7 100644 --- a/src/files/tests.rs +++ b/src/files/tests/mod.rs @@ -7,7 +7,9 @@ use jiff::{Timestamp, ToSpan}; use super::{FileGrid, item::detail}; use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme}; -fn setup(cx: &mut TestAppContext) { +mod explorer; + +pub(super) fn setup(cx: &mut TestAppContext) { cx.update(|cx| { Theme::init(cx); Theme::update(cx, |theme| theme.reduced_motion = true); @@ -15,7 +17,7 @@ fn setup(cx: &mut TestAppContext) { }); } -fn settle(cx: &mut VisualTestContext) { +pub(super) fn settle(cx: &mut VisualTestContext) { for _ in 0..3 { cx.run_until_parked(); cx.update(|window, _| window.refresh()); @@ -23,7 +25,7 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } -fn press(key: &str, cx: &mut VisualTestContext) { +pub(super) fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); settle(cx); cx.simulate_event(KeyUpEvent { @@ -32,7 +34,7 @@ fn press(key: &str, cx: &mut VisualTestContext) { settle(cx); } -fn then() -> Timestamp { +pub(super) fn then() -> Timestamp { Timestamp::from_second(1_700_000_000).expect("a time") } diff --git a/src/lists/directory.rs b/src/lists/directory.rs index 7c211cb..1559eac 100644 --- a/src/lists/directory.rs +++ b/src/lists/directory.rs @@ -8,6 +8,8 @@ use jiff::Timestamp; use super::{ListItem, SelectableList}; use crate::{ + forms::OnValue, + layout::seeded::use_seeded, navigation::{Breadcrumb, Crumb}, primitives::{Icon, IconName, file_icon}, theme::{ActiveTheme, IconSize, TextSize}, @@ -65,6 +67,32 @@ enum Column { Modified, } +/// Entries as a listing first shows them: folders first, then by name. +pub(crate) fn listed(entries: Vec) -> Vec { + sorted(entries, Column::Name, true) +} + +/// A directory's path from the root down as a breadcrumb; a press on a level climbs to it. +pub(crate) fn path_bar( + id: impl Into, + path: &[SharedString], + on_climb: Option, +) -> Breadcrumb { + let crumbs = 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 + } + }); + Breadcrumb::new(id, crumbs).on_select(move |level, _, window, cx| { + if let Some(on_climb) = &on_climb { + on_climb(level, window, cx); + } + }) +} + /// Entries sorted by `column`, folders always first, ties by name. fn sorted(mut entries: Vec, column: Column, ascending: bool) -> Vec { let by_name = |a: &DirEntry, b: &DirEntry| a.name.to_lowercase().cmp(&b.name.to_lowercase()); @@ -84,7 +112,7 @@ fn sorted(mut entries: Vec, column: Column, ascending: bool) -> Vec; -type OnClimb = Rc; +pub(crate) type OnClimb = Rc; /// 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)] @@ -92,6 +120,8 @@ pub struct DirectoryListing { id: ElementId, path: Vec, entries: Vec, + selected: Option, + on_select: Option, on_open: Option, on_climb: Option, } @@ -109,11 +139,33 @@ impl DirectoryListing { id: id.into(), path, entries: entries.into_iter().collect(), + selected: None, + on_select: None, on_open: None, on_climb: None, } } + /// The entry picked, by name. + pub fn selected(mut self, name: impl Into) -> 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 @@ -130,9 +182,7 @@ 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::::new() - }); + let picked = use_seeded((self.id.clone(), "picked"), self.selected, window, cx); let (column, ascending) = *order.read(cx); let entries = Rc::new(sorted(self.entries, column, ascending)); let theme = cx.theme(); @@ -203,7 +253,8 @@ impl RenderOnce for DirectoryListing { .child(header("Modified", Column::Modified)), ); let list = entries.iter().enumerate().fold( - SelectableList::new((self.id.clone(), "entries")).selected(picked.read(cx).clone()), + SelectableList::new((self.id.clone(), "entries")) + .selected(picked.read(cx).value.clone()), |list, (ix, entry)| { let size = entry .size @@ -242,35 +293,29 @@ impl RenderOnce for DirectoryListing { }, ); 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; + let on_select = self.on_select; 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(path_bar( + (self.id.clone(), "path"), + &self.path, + self.on_climb, + )) .child(heading) .child( - list.on_change(move |keys, _, cx| { + list.on_change(move |keys, window, cx| { + let Some(name) = keys.first() else { + return; + }; store.update(cx, |picked, cx| { - *picked = keys.to_vec(); + picked.value = Some(name.clone()); cx.notify(); - }) + }); + if let Some(on_select) = &on_select { + on_select(name, window, cx); + } }) .on_activate(move |name, window, cx| { let entry = shown diff --git a/src/lists/mod.rs b/src/lists/mod.rs index 8d8a304..71c4f18 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -12,6 +12,7 @@ mod tests; mod tree; pub use directory::{DirEntry, DirectoryListing}; +pub(crate) use directory::{OnClimb, listed, path_bar}; pub use files::{FileTree, GitStatus}; pub use grouped::GroupedList; pub use item::{List, ListItem}; diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index a27d1da..6333c7f 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -109,6 +109,7 @@ icons! { CloudUpload => "cloud-upload", Code => "code", Columns2 => "columns-2", + Columns3 => "columns-3", Command => "command", Compass => "compass", Contrast => "contrast", diff --git a/src/theme/files.rs b/src/theme/files.rs index 57e34df..de00d5e 100644 --- a/src/theme/files.rs +++ b/src/theme/files.rs @@ -7,12 +7,18 @@ use super::{Theme, tokens::px_to_rems}; pub struct FileSizes { /// A tile in a grid of files, wide. pub tile: Rems, + /// A column of a folder along a path, wide. + pub column: Rems, + /// The columns view, tall. + pub columns: Rems, } impl Theme { pub fn files(&self) -> FileSizes { FileSizes { tile: px_to_rems(96.0), + column: px_to_rems(200.0), + columns: px_to_rems(288.0), } } } diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 308de0c..21d53d7 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -296,12 +296,12 @@ ## T27b Files · Explorer -- [ ] FileExplorer — list, icons, columns -- [ ] FileBreadcrumb / PathBar → `navigation::Breadcrumb` -- [ ] FolderTree → `lists::FileTree` -- [ ] FileContextMenu → `menus::ContextMenu` -- [ ] RenameInline → `forms::InlineEdit` -- [ ] DragDropFiles → `forms::DropZone` +- [x] FileExplorer — list, icons, columns +- [x] FileBreadcrumb / PathBar → `navigation::Breadcrumb` +- [x] FolderTree → `lists::FileTree` +- [x] FileContextMenu → `menus::ContextMenu` +- [x] RenameInline → `forms::InlineEdit` +- [x] DragDropFiles → `forms::DropZone` ## T27c Files · Find and look