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.
This commit is contained in:
1 parent
501d1b0f1a
commit
14de69fed6
17 files changed
+991
-41
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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 | |
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-columns-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -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::<DirEntry>::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<DirEntry> = entries.collect();
|
||||
change(&take, cx, |dropped| {
|
||||
for entry in entries {
|
||||
if !dropped.iter().any(|kept| kept.name() == entry.name()) {
|
||||
dropped.push(entry);
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(list),
|
||||
))
|
||||
}
|
||||
@@ -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<DirEntry> {
|
||||
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<SharedString>,
|
||||
view: FileView,
|
||||
selected: Option<SharedString>,
|
||||
}
|
||||
|
||||
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<SharedString>, out: &mut Vec<String>) {
|
||||
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}")),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -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<DirEntry> {
|
||||
pub fn entries() -> Vec<DirEntry> {
|
||||
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()
|
||||
}
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -109,6 +109,7 @@ icons! {
|
||||
CloudUpload => "cloud-upload",
|
||||
Code => "code",
|
||||
Columns2 => "columns-2",
|
||||
Columns3 => "columns-3",
|
||||
Command => "command",
|
||||
Compass => "compass",
|
||||
Contrast => "contrast",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user