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:
1 parent
906e6c8ac1
commit
f694f0d413
17 files changed
+991
-41
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user