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

+130
View File
@@ -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<usize>,
}
/// 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<Vec<DirEntry>>,
selected: Option<SharedString>,
(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<SharedString> = 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))
}),
)
}
+279
View File
@@ -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<dyn Fn(&[SharedString], &mut Window, &mut App)>;
type OnView = Rc<dyn Fn(FileView, &mut Window, &mut App)>;
/// 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<SharedString>,
levels: Vec<Vec<DirEntry>>,
view: FileView,
selected: Option<SharedString>,
on_view: Option<OnView>,
on_navigate: Option<OnPath>,
on_select: Option<OnValue>,
on_open: Option<OnEntry>,
}
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<ElementId>,
path: impl IntoIterator<Item = impl Into<SharedString>>,
levels: impl IntoIterator<Item = Vec<DirEntry>>,
view: FileView,
) -> Self {
let path: Vec<SharedString> = path.into_iter().map(Into::into).collect();
let levels: Vec<Vec<DirEntry>> = 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<SharedString>) -> 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<SharedString> =
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)
}
}
+1 -3
View File
@@ -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<dyn Fn(&DirEntry, &mut Window, &mut App)>;
/// The tile a key moves to from `at` in `count` tiles laid `columns` a row: across by one, up and down by a row, Home and End to the ends; down from over a short last row lands on its last tile.
pub(crate) fn stepped(at: usize, count: usize, columns: usize, key: &str) -> Option<usize> {
let last = count.checked_sub(1)?;
+11
View File
@@ -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<dyn Fn(&DirEntry, &mut Window, &mut App)>;
#[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;
+176
View File
@@ -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");
}
@@ -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")
}
+71 -26
View File
@@ -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<DirEntry>) -> Vec<DirEntry> {
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<ElementId>,
path: &[SharedString],
on_climb: Option<OnClimb>,
) -> 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<DirEntry>, column: Column, ascending: bool) -> Vec<DirEntry> {
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<DirEntry>, column: Column, ascending: bool) -> Vec<Di
}
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
type OnClimb = Rc<dyn Fn(usize, &mut Window, &mut App)>;
pub(crate) type OnClimb = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// 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<SharedString>,
entries: Vec<DirEntry>,
selected: Option<SharedString>,
on_select: Option<OnValue>,
on_open: Option<OnEntry>,
on_climb: Option<OnClimb>,
}
@@ -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<SharedString>) -> Self {
let name = name.into();
assert!(
self.entries.iter().any(|entry| entry.name == name),
"no entry {name}"
);
self.selected = Some(name);
self
}
/// Gets the name of the entry picked.
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
@@ -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::<SharedString>::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
+1
View File
@@ -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};
+1
View File
@@ -109,6 +109,7 @@ icons! {
CloudUpload => "cloud-upload",
Code => "code",
Columns2 => "columns-2",
Columns3 => "columns-3",
Command => "command",
Compass => "compass",
Contrast => "contrast",
+6
View File
@@ -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),
}
}
}