Files
Ely-GPUI-Components/src/files/explorer.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5779daef82 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:22 -04:00

288 lines
10 KiB
Rust

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()
);
for (depth, name) in path.iter().enumerate().skip(1) {
assert!(
levels[depth - 1]
.iter()
.any(|entry| entry.is_folder() && entry.name() == name),
"{name} is no folder in {}",
path[depth - 1]
);
}
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; the pick stays with the folder left, so the host drops it.
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())
.flex()
.flex_col()
.gap_3()
.child(header)
.child(body)
}
}