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