diff --git a/AGENTS.md b/AGENTS.md index 3b670b7..a43351a 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. +- Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike. - 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. @@ -131,7 +132,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`). - Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space. - A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only. -- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. +- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does. - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. - A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width. diff --git a/TASKS.md b/TASKS.md index 5790e4b..6bf4fbf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -95,7 +95,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T26d Media · Audio and controls - [x] T26e Media · Capture and devices - [x] T27a Files · Items -- [ ] T27b Files · Explorer +- [x] T27b Files · Explorer - [ ] T27c Files · Find and look - [ ] T27d Files · Transfers - [ ] T28 Messaging @@ -324,3 +324,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | | | T27b | 1 | FAIL | the listing's fixed columns left no room for names at 280px; a column keyed by its depth handed its cursor to the next folder there | +| T27b | 2 | PASS | | diff --git a/examples/gallery/pages/files/find.rs b/examples/gallery/pages/files/find.rs new file mode 100644 index 0000000..4acd2fb --- /dev/null +++ b/examples/gallery/pages/files/find.rs @@ -0,0 +1,309 @@ +use std::path::Path; + +use ely_gpui_component::{ + debug::HexViewer, + documents::{Favorite, Favorites}, + files::{ + ArchiveEntry, ArchiveViewer, DuplicateFinder, Duplicates, FileGrid, FilePreview, + FileSearch, FoundFile, RecentFile, RecentFiles, + }, + forms::TextInput, + lists::DirEntry, + overlays::Dialog, +}; +use gpui::{ + App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; +use jiff::{SignedDuration, Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +const ATRIUM: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium.jpg" +); +const BRIEF: &str = "# Atrium\n\nA quiet hall for the new library.\n\n- Light from above, all day\n- One stair, curved, in plaster\n- Olive trees by the windows\n\nNext: the model walkthrough on Friday."; + +fn entry(name: &str, bytes: u64, hours: i64) -> DirEntry { + DirEntry::file(name.to_string(), bytes, Timestamp::now() - hours.hours()) +} + +pub fn previews(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let looking = keep("files-looking", || None::, window, cx); + let picked = keep("files-look-picked", || None::, window, cx); + let (open, pick) = (looking.clone(), picked.clone()); + let chosen = picked.read(cx).clone(); + let preview = |name: &SharedString| match name.as_ref() { + "Atrium.jpg" => FilePreview::new("files-look", entry("Atrium.jpg", 2_140_000, 50)) + .picture(Path::new(ATRIUM), 1.5), + "Brief.md" => FilePreview::new("files-look", entry("Brief.md", 4_800, 2)).text(BRIEF), + other => FilePreview::new("files-look", entry(other, 48_000_000, 700)), + }; + let tiles = [ + entry("Atrium.jpg", 2_140_000, 50), + entry("Brief.md", 4_800, 2), + entry("Archive.zip", 48_000_000, 700), + ]; + let grid = FileGrid::new("files-look-grid", tiles) + .on_select(move |name, _, cx| change(&pick, cx, |now| *now = Some(name.clone()))); + let grid = match chosen.clone() { + Some(name) => grid.selected(name), + None => grid, + }; + let shown = looking.read(cx).clone(); + let close = looking.clone(); + section( + "FilePreview / QuickLook", + "A file shown large: its picture in its shape, its text's first lines, or its kind. Space on a picked tile shows it over the page, as a quick look does; Escape puts it away.", + cx, + ) + .child(probe( + "files-previews", + div() + .w(px(640.)) + .flex() + .gap_4() + .child(div().flex_1().min_w_0().child(preview(&"Atrium.jpg".into()))) + .child(div().flex_1().min_w_0().child(preview(&"Brief.md".into()))) + .child(div().flex_1().min_w_0().child(preview(&"Archive.zip".into()))), + )) + .child(probe( + "files-quick", + div() + .w(px(640.)) + .on_key_down(move |event, _, cx| { + if event.keystroke.key == "space" { + let picked = chosen.clone(); + change(&open, cx, |now| *now = picked); + } + }) + .child(grid), + )) + .children(shown.map(|name| { + Dialog::new("files-quick-look", name.clone(), move |_, cx| { + change(&close, cx, |now| *now = None) + }) + .child(div().w(px(420.)).child(preview(&name))) + })) +} + +pub fn recent(cx: &mut App) -> impl IntoElement + use<> { + let now = Timestamp::now(); + let file = |name: &str, folder: &str, minutes: i64| RecentFile { + name: SharedString::from(name.to_string()), + folder: SharedString::from(folder.to_string()), + opened: now - SignedDuration::from_mins(minutes), + }; + let files = [ + file("Brief.md", "Projects/Atrium", 12), + file("Budget.csv", "Projects/Atrium", 95), + file("Walkthrough.mov", "Projects/Atrium/Recordings", 60 * 26), + file("Invoice.pdf", "Documents", 60 * 30), + file("Survey.csv", "Projects/Dunes", 60 * 24 * 4), + ]; + section( + "RecentFiles", + "Files opened lately, newest first, under a header for each day. Enter or a double press opens one.", + cx, + ) + .child(probe( + "files-recent", + div().w(px(420.)).child( + RecentFiles::new("files-recent", files) + .on_open(|file, _, _| log::info!("gallery: open {}", file.name)), + ), + )) +} + +pub fn favorites(cx: &mut App) -> impl IntoElement + use<> { + let pin = |key: &str, icon: &str, title: &str| Favorite { + key: key.to_string().into(), + icon: icon.to_string().into(), + title: title.to_string().into(), + }; + section( + "Favorites / Bookmarks", + "Places pinned for quick reach, as documents::Favorites keeps them: a press opens one, a drag reorders, the star lets one go.", + cx, + ) + .child(probe( + "files-favorites", + div().w(px(280.)).child( + Favorites::new( + "files-favorites", + [ + pin("atrium", "📐", "Atrium"), + pin("roadmap", "🗺️", "Roadmap.md"), + pin("music", "🎧", "Music"), + ], + ) + .on_open(|key, _, _| log::info!("gallery: open {key}")), + ), + )) +} + +/// Every file of the demo whose name holds `text`, ignoring case. +fn matching(text: &str) -> Vec { + let text = text.trim().to_lowercase(); + if text.is_empty() { + return Vec::new(); + } + let mut found = Vec::new(); + let mut walk = vec![vec![SharedString::from("Home")]]; + while let Some(path) = walk.pop() { + for entry in super::explorer::listing(&path) { + let mut next = path.clone(); + next.push(entry.name().clone()); + if entry.is_folder() { + walk.push(next); + } else if entry.name().to_lowercase().contains(&text) { + let folder: Vec<&str> = path.iter().map(|level| level.as_ref()).collect(); + found.push(FoundFile { + name: entry.name().clone(), + folder: folder.join("/").into(), + }); + } + } + } + found +} + +pub fn search(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let query = window.use_keyed_state("files-search-query", cx, |window, cx| { + TextInput::new(window, cx) + }); + let found = matching(query.read(cx).text()); + section( + "FileSearch", + "A search over files: the host searches as the name is typed, and each file found shows its folder. Enter or a double press opens one.", + cx, + ) + .child(probe( + "files-search", + div().w(px(420.)).child( + FileSearch::new("files-search", &query, found) + .on_open(|file, _, _| log::info!("gallery: open {}", file.name)), + ), + )) +} + +pub fn duplicates(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let group = |name: &str, size: u64, paths: &[&str]| Duplicates { + name: name.to_string().into(), + size, + paths: paths + .iter() + .map(|path| SharedString::from(path.to_string())) + .collect(), + }; + let groups = vec![ + group( + "Atrium.jpg", + 2_140_000, + &[ + "Projects/Atrium/Atrium.jpg", + "Downloads/Atrium.jpg", + "Desktop/Atrium copy.jpg", + ], + ), + group( + "Walkthrough.mov", + 184_000_000, + &[ + "Projects/Atrium/Recordings/Walkthrough.mov", + "Downloads/Walkthrough.mov", + ], + ), + ]; + let marked = keep( + "files-duplicates", + || { + vec![ + SharedString::from("Downloads/Atrium.jpg"), + "Desktop/Atrium copy.jpg".into(), + "Downloads/Walkthrough.mov".into(), + ] + }, + window, + cx, + ); + let now = marked.read(cx).clone(); + let (mark, remove) = (marked.clone(), marked); + section( + "DuplicateFinder", + "Files found more than once, each copy with a mark; the last copy a group keeps stays. What the marked copies free sits beside Remove.", + cx, + ) + .child(probe( + "files-duplicates", + div().w(px(560.)).child( + DuplicateFinder::new("files-duplicates", groups) + .marked(now) + .on_mark(move |path, on, _, cx| { + change(&mark, cx, |marked| { + marked.retain(|kept| kept != path); + if on { + marked.push(path.clone()); + } + }) + }) + .on_remove(move |paths, _, cx| { + log::info!("gallery: remove {} copies", paths.len()); + change(&remove, cx, |marked| marked.clear()) + }), + ), + )) +} + +pub fn archive(cx: &mut App) -> impl IntoElement + use<> { + let entry = |path: &str, size: u64, packed: u64| ArchiveEntry { + path: SharedString::from(path.to_string()), + size, + packed, + }; + let entries = [ + entry("Atrium/Brief.md", 4_800, 1_900), + entry("Atrium/Budget.csv", 18_300, 4_100), + entry("Atrium/Design/Moodboard.png", 3_200_000, 3_050_000), + entry("Atrium/Design/Palette.json", 2_100, 600), + entry("Atrium/main.rs", 9_200, 2_700), + entry("README.md", 1_400, 700), + ]; + section( + "ArchiveViewer", + "What an archive holds, as a tree of its folders: each file with its size and how much packing saved.", + cx, + ) + .child(probe( + "files-archive", + div().w(px(420.)).h(px(300.)).child( + ArchiveViewer::new("files-archive", "Atrium.zip", entries) + .on_open(|entry, _, _| log::info!("gallery: open {}", entry.path)), + ), + )) +} + +pub fn hex(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let bytes = keep( + "files-hex", + || std::fs::read(ATRIUM).expect("the gallery's picture reads")[..512].to_vec(), + window, + cx, + ); + let bytes = bytes.read(cx).clone(); + section( + "HexViewer", + "A file's first bytes, as debug::HexViewer shows them: offsets, hex and text side by side.", + cx, + ) + .child(probe( + "files-hex", + div() + .w(px(640.)) + .h(px(220.)) + .child(HexViewer::new("files-hex", bytes)), + )) +} diff --git a/examples/gallery/pages/files/mod.rs b/examples/gallery/pages/files/mod.rs index 38825df..8af60b2 100644 --- a/examples/gallery/pages/files/mod.rs +++ b/examples/gallery/pages/files/mod.rs @@ -8,6 +8,7 @@ use jiff::{Timestamp, ToSpan}; mod actions; mod explorer; +mod find; use super::Page; use crate::{ @@ -51,6 +52,17 @@ const SCRIPT: &[Step] = &[ Step::Key("enter"), Step::Wait(300), Step::Shot("renamed"), + Step::DownAt("files-quick", 150.0, 40.0), + Step::UpAt("files-quick", 150.0, 40.0), + Step::Key("space"), + Step::Wait(300), + Step::Shot("quick-look"), + Step::Key("escape"), + Step::DownAt("files-search", 100.0, 16.0), + Step::UpAt("files-search", 100.0, 16.0), + Step::Type("a"), + Step::Wait(300), + Step::Shot("searched"), ]; /// A project's entries, changed a few hours to a few weeks ago. @@ -197,5 +209,12 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(actions::context_menu(cx)) .child(actions::rename(window, cx)) .child(actions::drop_files(window, cx)) + .child(find::previews(window, cx)) + .child(find::recent(cx)) + .child(find::favorites(cx)) + .child(find::search(window, cx)) + .child(find::duplicates(window, cx)) + .child(find::archive(cx)) + .child(find::hex(window, cx)) .into_any_element() } diff --git a/src/files/archive.rs b/src/files/archive.rs new file mode 100644 index 0000000..623340c --- /dev/null +++ b/src/files/archive.rs @@ -0,0 +1,184 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; + +use super::FileIcon; +use crate::{ + lists::{Folder, Tree, TreeNode}, + primitives::{IconName, file_icon}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::{MiddleEllipsis, format}, +}; + +type OnArchived = Rc; + +/// A file inside an archive: its path there, with `/` between folders, its size and its packed size, in bytes. +#[derive(Clone, Debug, PartialEq)] +pub struct ArchiveEntry { + pub path: SharedString, + pub size: u64, + pub packed: u64, +} + +/// How much smaller packing made `size`, a whole percent. +pub(crate) fn saving(size: u64, packed: u64) -> u64 { + if size == 0 { + return 0; + } + ((size - packed.min(size)) as f64 / size as f64 * 100.0).round() as u64 +} + +/// Nodes for `folder` under `prefix`, folders first, then files, each by name; and the bytes it holds. +fn nodes(folder: &Folder, prefix: &str, entries: &[ArchiveEntry]) -> (Vec, u64) { + let mut out = Vec::new(); + let mut held = 0; + let mut folders: Vec<_> = folder.folders.iter().collect(); + folders.sort_by_key(|(name, _)| name.to_lowercase()); + for (name, inner) in folders { + let path = format!("{prefix}{name}"); + let (children, bytes) = nodes(inner, &format!("{path}/"), entries); + held += bytes; + let node = TreeNode::new(path, name.clone()) + .icon(IconName::Folder) + .note(format::file_size(bytes, false)) + .children(children); + out.push(node); + } + let mut files: Vec<&String> = folder.files.iter().collect(); + files.sort_by_key(|name| name.to_lowercase()); + for name in files { + let path = format!("{prefix}{name}"); + let entry = entries + .iter() + .find(|entry| entry.path.as_ref() == path) + .expect("a listed entry"); + held += entry.size; + let note = format!( + "{} · {}%", + format::file_size(entry.size, false), + saving(entry.size, entry.packed) + ); + out.push( + TreeNode::new(path, name.clone()) + .icon(file_icon(name)) + .note(note), + ); + } + (out, held) +} + +/// What an archive holds, as a tree of its folders: each folder with its size, each file with its size and how much packing saved; the archive's name, its count, its size and its saving above. Enter or a double press on a file hands it to the host. Give it a height. +#[derive(IntoElement)] +pub struct ArchiveViewer { + id: ElementId, + name: SharedString, + entries: Vec, + on_open: Option, +} + +impl ArchiveViewer { + pub fn new( + id: impl Into, + name: impl Into, + entries: impl IntoIterator, + ) -> Self { + let entries: Vec = entries.into_iter().collect(); + for (ix, entry) in entries.iter().enumerate() { + assert!( + !entries[..ix].iter().any(|other| other.path == entry.path), + "{} twice", + entry.path + ); + } + Self { + id: id.into(), + name: name.into(), + entries, + on_open: None, + } + } + + pub fn on_open( + mut self, + handler: impl Fn(&ArchiveEntry, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ArchiveViewer { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let folders = Folder::of(self.entries.iter().map(|entry| entry.path.as_ref())); + let (nodes, size) = nodes(&folders, "", &self.entries); + let packed: u64 = self.entries.iter().map(|entry| entry.packed).sum(); + let top: Vec = folders + .folders + .keys() + .map(|name| name.clone().into()) + .collect(); + let summary = format!( + "{} · {} · {}% smaller", + match self.entries.len() { + 1 => "1 file".to_string(), + count => format!("{count} files"), + }, + format::file_size(size, false), + saving(size, packed) + ); + let (id, entries, on_open) = (self.id.clone(), Rc::new(self.entries), self.on_open); + let tree = Tree::new((self.id.clone(), "tree"), nodes) + .open(top) + .flex_1() + .min_h_0() + .on_activate(move |key, window, cx| { + let entry = entries + .iter() + .find(|entry| entry.path == *key) + .expect("a leaf is a listed file"); + log::info!("archive viewer {id:?}: open {}", entry.path); + if let Some(on_open) = &on_open { + on_open(entry, window, cx); + } + }); + div() + .debug_selector(|| "archive-viewer".into()) + .size_full() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex_none() + .flex() + .items_center() + .gap_3() + .child(FileIcon::file(&self.name).size(IconSize::Xl)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(MiddleEllipsis::new(self.name)), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(summary), + ), + ), + ) + .child(tree) + } +} diff --git a/src/files/duplicates.rs b/src/files/duplicates.rs new file mode 100644 index 0000000..0eb4bec --- /dev/null +++ b/src/files/duplicates.rs @@ -0,0 +1,222 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; + +use super::FileIcon; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Checkbox, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::{MiddleEllipsis, format}, +}; + +type OnMark = Rc; +type OnRemove = Rc; + +/// Copies of one file: its name, its size in bytes, and where each copy lives. +#[derive(Clone, Debug, PartialEq)] +pub struct Duplicates { + pub name: SharedString, + pub size: u64, + pub paths: Vec, +} + +/// The bytes the marked copies take. +pub(crate) fn freed(groups: &[Duplicates], marked: &[SharedString]) -> u64 { + groups + .iter() + .map(|group| { + let copies = group + .paths + .iter() + .filter(|path| marked.contains(path)) + .count(); + group.size * copies as u64 + }) + .sum() +} + +/// Files found more than once, a group each: every copy with a mark to remove it; the one copy a group has left unmarked stays, its mark shut. What the marked copies free sits above, beside Remove. +#[derive(IntoElement)] +pub struct DuplicateFinder { + id: ElementId, + groups: Vec, + marked: Vec, + on_mark: Option, + on_remove: Option, +} + +impl DuplicateFinder { + pub fn new(id: impl Into, groups: impl IntoIterator) -> Self { + let groups: Vec = groups.into_iter().collect(); + for group in &groups { + assert!(group.paths.len() >= 2, "{} has one copy", group.name); + } + let paths: Vec<&SharedString> = groups.iter().flat_map(|group| &group.paths).collect(); + for (ix, path) in paths.iter().enumerate() { + assert!(!paths[..ix].contains(path), "{path} twice"); + } + Self { + id: id.into(), + groups, + marked: Vec::new(), + on_mark: None, + on_remove: None, + } + } + + /// The copies marked to remove, by path; each group keeps one. + pub fn marked(mut self, paths: impl IntoIterator>) -> Self { + let paths: Vec = paths.into_iter().map(Into::into).collect(); + for path in &paths { + assert!( + self.groups.iter().any(|group| group.paths.contains(path)), + "no copy {path}" + ); + } + for group in &self.groups { + assert!( + group.paths.iter().any(|path| !paths.contains(path)), + "every copy of {} is marked", + group.name + ); + } + self.marked = paths; + self + } + + /// Gets a copy's path and whether it is marked now. + pub fn on_mark( + mut self, + handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_mark = Some(Rc::new(handler)); + self + } + + /// Gets the marked copies to remove. + pub fn on_remove( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for DuplicateFinder { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let small = theme.text_size(TextSize::Sm); + let freed = freed(&self.groups, &self.marked); + let marked: Rc<[SharedString]> = self.marked.into(); + let remove = Button::new((self.id.clone(), "remove"), "Remove marked") + .variant(ButtonVariant::Danger) + .size(ControlSize::Sm) + .disabled(marked.is_empty() || self.on_remove.is_none()); + let remove = match self.on_remove { + Some(on_remove) => { + let (id, marked) = (self.id.clone(), marked.clone()); + remove.on_click(move |_, window, cx| { + log::info!("duplicate finder {id:?}: remove {} copies", marked.len()); + on_remove(&marked, window, cx) + }) + } + None => remove, + }; + let header = div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(match self.groups.len() { + 1 => "1 file found more than once".to_string(), + count => format!("{count} files found more than once"), + }), + ) + .child( + div() + .text_size(small) + .text_color(colors.fg_muted) + .child(format!("{} to free", format::file_size(freed, false))), + ), + ) + .child(div().flex_none().child(remove)); + let groups = self.groups.iter().enumerate().map(|(g, group)| { + let left = group + .paths + .iter() + .filter(|path| !marked.contains(path)) + .count(); + let copies = group.paths.iter().enumerate().map(|(c, path)| { + let on = marked.contains(path); + let check = Checkbox::new((self.id.clone(), format!("copy-{g}-{c}")), on) + .label(path.clone()) + .disabled(self.on_mark.is_none() || (!on && left == 1)); + match self.on_mark.clone() { + Some(on_mark) => { + let (id, path) = (self.id.clone(), path.clone()); + check.on_change(move |to, window, cx| { + log::info!("duplicate finder {id:?}: {path} marked {to}"); + on_mark(&path, to, window, cx) + }) + } + None => check, + } + }); + div() + .flex() + .flex_col() + .gap_1p5() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(FileIcon::file(&group.name)) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(MiddleEllipsis::new(group.name.clone())), + ) + .child( + div() + .flex_none() + .text_size(small) + .text_color(colors.fg_muted) + .child(format!( + "{} copies · {} each", + group.paths.len(), + format::file_size(group.size, false) + )), + ), + ) + .child(div().pl_6().flex().flex_col().gap_1().children(copies)) + }); + div() + .debug_selector(|| "duplicate-finder".into()) + .w_full() + .flex() + .flex_col() + .gap_4() + .child(header) + .children(groups) + } +} diff --git a/src/files/mod.rs b/src/files/mod.rs index 7cd2b1f..37ce555 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -4,18 +4,28 @@ use gpui::{App, Window}; use crate::lists::DirEntry; +mod archive; mod columns; +mod duplicates; mod explorer; mod grid; mod icon; mod item; +mod preview; +mod recent; +mod search; type OnEntry = Rc; #[cfg(all(test, feature = "test-support"))] mod tests; +pub use archive::{ArchiveEntry, ArchiveViewer}; +pub use duplicates::{DuplicateFinder, Duplicates}; pub use explorer::{FileExplorer, FileView}; pub use grid::FileGrid; pub use icon::FileIcon; pub use item::FileItem; +pub use preview::FilePreview; +pub use recent::{RecentFile, RecentFiles}; +pub use search::{FileSearch, FoundFile}; diff --git a/src/files/preview.rs b/src/files/preview.rs new file mode 100644 index 0000000..d8e24cb --- /dev/null +++ b/src/files/preview.rs @@ -0,0 +1,146 @@ +use gpui::{ + AnyElement, App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, relative, +}; +use jiff::Timestamp; + +use super::FileIcon; +use crate::{ + lists::DirEntry, + primitives::{Image, checked_ratio, framed}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{Ellipsis, LEADING, MiddleEllipsis, format}, +}; + +/// Lines of a text a preview shows. +const LINES: usize = 12; +/// The shape of the well an icon sits in, wide over high. +const WELL: f32 = 4.0 / 3.0; + +/// What a preview shows of a file. +enum Look { + Picture(ImageSource, f32), + Text(SharedString), + Icon, +} + +/// A file's kind, from its extension: "PDF", "File" without one, "Folder" for a folder. +pub(crate) fn kind(entry: &DirEntry) -> String { + if entry.is_folder() { + return "Folder".into(); + } + match entry.name().rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => extension.to_uppercase(), + _ => "File".into(), + } +} + +/// A file shown large, as a quick look shows it: its picture in the host's shape, the first lines of its text, or its kind's icon; its name, its kind and size, and when it changed below. +#[derive(IntoElement)] +pub struct FilePreview { + id: ElementId, + entry: DirEntry, + look: Look, +} + +impl FilePreview { + pub fn new(id: impl Into, entry: DirEntry) -> Self { + Self { + id: id.into(), + entry, + look: Look::Icon, + } + } + + /// Its picture, `ratio` wide over high. + pub fn picture(mut self, source: impl Into, ratio: f32) -> Self { + self.look = Look::Picture(source.into(), checked_ratio(ratio)); + self + } + + /// Its text; the first lines show. + pub fn text(mut self, text: impl Into) -> Self { + self.look = Look::Text(text.into()); + self + } +} + +impl RenderOnce for FilePreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let round = theme.radius(Radius::Lg); + let stage: AnyElement = match self.look { + Look::Picture(source, ratio) => framed(ratio, cx) + .debug_selector(|| "file-preview-stage".into()) + .rounded(round) + .child( + Image::new((self.id.clone(), "picture"), source) + .size_full() + .rounded(round), + ) + .into_any_element(), + Look::Text(text) => div() + .w_full() + .p_4() + .rounded(round) + .bg(colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .line_height(relative(LEADING)) + .text_color(colors.fg) + .children(text.lines().take(LINES).map(|line| { + let shown = if line.is_empty() { " " } else { line }; + Ellipsis::new(shown.to_string()) + })) + .into_any_element(), + Look::Icon => framed(WELL, cx) + .rounded(round) + .flex() + .items_center() + .justify_center() + .child(FileIcon::entry(&self.entry).size(IconSize::Xxl)) + .into_any_element(), + }; + let detail = match self.entry.size() { + Some(bytes) => format!( + "{} · {}", + kind(&self.entry), + format::file_size(bytes, false) + ), + None => kind(&self.entry), + }; + let changed = format!( + "Changed {}", + format::relative(self.entry.modified(), Timestamp::now()) + ); + let muted = |text: String| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(text) + }; + div() + .debug_selector(|| "file-preview".into()) + .w_full() + .flex() + .flex_col() + .gap_3() + .child(stage) + .child( + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(MiddleEllipsis::new(self.entry.name().clone())), + ) + .child(muted(detail)) + .child(muted(changed)), + ) + } +} diff --git a/src/files/recent.rs b/src/files/recent.rs new file mode 100644 index 0000000..b762500 --- /dev/null +++ b/src/files/recent.rs @@ -0,0 +1,131 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; +use jiff::{Timestamp, civil::Date, tz::TimeZone}; + +use super::FileIcon; +use crate::{ + lists::{ListItem, SelectableList}, + theme::{ActiveTheme, TextSize}, + typography::format::{datetime, system_zone}, +}; + +type OnRecent = Rc; + +/// A file opened lately: its name, the folder it lives in, and when it was opened. +#[derive(Clone, Debug, PartialEq)] +pub struct RecentFile { + pub name: SharedString, + pub folder: SharedString, + pub opened: Timestamp, +} + +/// The header over a day's files: Today, Yesterday, or the weekday and date. +pub(crate) fn day_label(day: Date, today: Date) -> String { + if day == today { + "Today".into() + } else if today.yesterday().ok() == Some(day) { + "Yesterday".into() + } else { + day.strftime("%A, %B %-d").to_string() + } +} + +/// Files opened lately, newest first, under a header for each day: Today, Yesterday, then the weekday and date. Each day is a list; Tab moves between days, and Enter or a double press opens a file. +#[derive(IntoElement)] +pub struct RecentFiles { + id: ElementId, + files: Vec, + zone: Option, + on_open: Option, +} + +impl RecentFiles { + pub fn new(id: impl Into, files: impl IntoIterator) -> Self { + Self { + id: id.into(), + files: files.into_iter().collect(), + zone: None, + on_open: None, + } + } + + /// The zone days turn in; the system's by default. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + pub fn on_open( + mut self, + handler: impl Fn(&RecentFile, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RecentFiles { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self.zone.unwrap_or_else(|| system_zone("recent files")); + let today = Timestamp::now().to_zoned(zone.clone()).date(); + let mut files = self.files; + files.sort_by_key(|file| std::cmp::Reverse(file.opened)); + let files: Rc<[RecentFile]> = files.into(); + let theme = cx.theme(); + let colors = &theme.colors; + let mut days: Vec<(Date, Vec)> = Vec::new(); + for (ix, file) in files.iter().enumerate() { + let day = file.opened.to_zoned(zone.clone()).date(); + match days.last_mut() { + Some((last, those)) if *last == day => those.push(ix), + _ => days.push((day, vec![ix])), + } + } + let groups = days.into_iter().map(|(day, those)| { + let list = those.iter().fold( + SelectableList::new((self.id.clone(), format!("day-{day}"))), + |list, ix| { + let file = &files[*ix]; + let time = datetime(file.opened, &zone, "%H:%M").expect("a time formats"); + let row = + ListItem::new((self.id.clone(), format!("recent-{ix}")), file.name.clone()) + .leading(FileIcon::file(&file.name)) + .description(file.folder.clone()) + .trailing(time); + list.row(SharedString::from(ix.to_string()), row) + }, + ); + let (id, files, on_open) = (self.id.clone(), files.clone(), self.on_open.clone()); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .px_3() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg_muted) + .child(day_label(day, today)), + ) + .child(list.on_activate(move |key, window, cx| { + let file = &files[key.parse::().expect("a row's place")]; + log::info!("recent files {id:?}: open {}", file.name); + if let Some(on_open) = &on_open { + on_open(file, window, cx); + } + })) + }); + div() + .debug_selector(|| "recent-files".into()) + .w_full() + .flex() + .flex_col() + .gap_4() + .children(groups) + } +} diff --git a/src/files/search.rs b/src/files/search.rs new file mode 100644 index 0000000..ab389b4 --- /dev/null +++ b/src/files/search.rs @@ -0,0 +1,114 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, prelude::*, +}; + +use super::FileIcon; +use crate::{ + forms::{SearchInput, TextInput}, + lists::{ListItem, SelectableList}, + theme::{ActiveTheme, TextSize}, +}; + +type OnFound = Rc; + +/// A file a search found: its name and the folder it lives in. +#[derive(Clone, Debug, PartialEq)] +pub struct FoundFile { + pub name: SharedString, + pub folder: SharedString, +} + +/// A search over files: a field the host owns, and what it found, each file with its folder and a count above. The host searches as the text changes; Enter or a double press opens a file. +#[derive(IntoElement)] +pub struct FileSearch { + id: ElementId, + query: Entity, + found: Vec, + on_open: Option, +} + +impl FileSearch { + pub fn new( + id: impl Into, + query: &Entity, + found: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + query: query.clone(), + found: found.into_iter().collect(), + on_open: None, + } + } + + pub fn on_open( + mut self, + handler: impl Fn(&FoundFile, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FileSearch { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let text = self.query.read(cx).text().trim().to_string(); + let theme = cx.theme(); + let quiet = |line: String| { + div() + .debug_selector(|| "file-search-note".into()) + .px_3() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(line) + }; + let found: Rc<[FoundFile]> = self.found.into(); + let results = match (text.is_empty(), found.is_empty()) { + (true, _) => quiet("Type a name to search.".into()).into_any_element(), + (false, true) => quiet(format!("No files match “{text}”.")).into_any_element(), + (false, false) => { + let list = found.iter().enumerate().fold( + SelectableList::new((self.id.clone(), "found")), + |list, (ix, file)| { + let row = ListItem::new( + (self.id.clone(), format!("found-{ix}")), + file.name.clone(), + ) + .leading(FileIcon::file(&file.name)) + .description(file.folder.clone()); + list.row(SharedString::from(ix.to_string()), row) + }, + ); + let (id, shown, on_open) = (self.id.clone(), found.clone(), self.on_open); + let count = match found.len() { + 1 => "1 file".to_string(), + count => format!("{count} files"), + }; + div() + .flex() + .flex_col() + .gap_1() + .child(quiet(count)) + .child(list.on_activate(move |key, window, cx| { + let file = &shown[key.parse::().expect("a row's place")]; + log::info!("file search {id:?}: open {}", file.name); + if let Some(on_open) = &on_open { + on_open(file, window, cx); + } + })) + .into_any_element() + } + }; + div() + .debug_selector(|| "file-search".into()) + .w_full() + .flex() + .flex_col() + .gap_3() + .child(SearchInput::new((self.id.clone(), "field"), &self.query)) + .child(results) + } +} diff --git a/src/files/tests/find.rs b/src/files/tests/find.rs new file mode 100644 index 0000000..4dc7041 --- /dev/null +++ b/src/files/tests/find.rs @@ -0,0 +1,311 @@ +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; +use jiff::{SignedDuration, Timestamp, tz::TimeZone}; + +use super::{press, settle, setup, then}; +use crate::{ + files::{ + ArchiveEntry, ArchiveViewer, DuplicateFinder, Duplicates, FilePreview, FileSearch, + FoundFile, RecentFile, RecentFiles, archive::saving, duplicates::freed, preview::kind, + recent::day_label, + }, + forms::TextInput, + lists::DirEntry, +}; + +/// Moves focus to the `nth` Tab stop from none. +fn tab_to(nth: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + for _ in 0..nth { + window.focus_next(); + } + }); + settle(cx); +} + +#[test] +fn a_kind_reads_from_the_extension() { + assert_eq!(kind(&DirEntry::file("Brief.md", 1, then())), "MD"); + assert_eq!(kind(&DirEntry::file("Makefile", 1, then())), "File"); + assert_eq!(kind(&DirEntry::file(".env", 1, then())), "File"); + assert_eq!(kind(&DirEntry::folder("Design", then())), "Folder"); +} + +#[test] +fn a_day_reads_as_today_yesterday_or_its_date() { + let today = jiff::civil::date(2026, 9, 22); + assert_eq!(day_label(today, today), "Today"); + assert_eq!( + day_label(jiff::civil::date(2026, 9, 21), today), + "Yesterday" + ); + assert_eq!( + day_label(jiff::civil::date(2026, 9, 20), today), + "Sunday, September 20" + ); +} + +#[test] +fn packing_saves_a_whole_percent_and_marks_free_their_bytes() { + assert_eq!( + (saving(1000, 250), saving(0, 0), saving(10, 20)), + (75, 0, 0) + ); + let groups = [Duplicates { + name: "a.jpg".into(), + size: 100, + paths: vec!["x/a.jpg".into(), "y/a.jpg".into(), "z/a.jpg".into()], + }]; + assert_eq!(freed(&groups, &["y/a.jpg".into(), "z/a.jpg".into()]), 200); +} + +/// A preview of a picture four wide over three, 400 across. +struct Looking { + path: std::path::PathBuf, +} + +impl Render for Looking { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let entry = DirEntry::file("a.png", 10, then()); + let preview = FilePreview::new("preview", entry).picture(self.path.clone(), 4.0 / 3.0); + div().w(px(400.0)).child(preview) + } +} + +#[gpui::test] +fn a_picture_keeps_the_hosts_shape(cx: &mut TestAppContext) { + setup(cx); + let path = std::env::temp_dir().join("ely-files-preview.png"); + image::RgbaImage::from_pixel(40, 30, image::Rgba([90, 120, 150, 255])) + .save(&path) + .expect("the picture writes"); + let (_, cx) = cx.add_window_view(|_, _| Looking { path }); + settle(cx); + let stage = cx + .debug_bounds("file-preview-stage") + .expect("the stage draws"); + let shape = stage.size.width / stage.size.height; + assert!((shape - 4.0 / 3.0).abs() < 0.02, "{stage:?}"); +} + +/// Files opened today and yesterday in UTC, and the ones it opened. +struct Recalling { + opened: Vec, +} + +impl Render for Recalling { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + let midnight = Timestamp::now() + .to_zoned(TimeZone::UTC) + .start_of_day() + .expect("a day starts") + .timestamp(); + let file = |name: &str, minutes: i64| RecentFile { + name: SharedString::from(name.to_string()), + folder: "Home".into(), + opened: midnight + SignedDuration::from_mins(minutes), + }; + let files = [ + file("early.md", 5), + file("late.md", -300), + file("later.md", 9), + ]; + div().w(px(320.0)).child( + RecentFiles::new("recent", files) + .zone(TimeZone::UTC) + .on_open(move |file, _, cx| { + owner.update(cx, |host, _| host.opened.push(file.name.clone())) + }), + ) + } +} + +#[gpui::test] +fn days_list_the_newest_first_and_enter_opens(cx: &mut TestAppContext) { + setup(cx); + let (host, cx) = cx.add_window_view(|_, _| Recalling { opened: Vec::new() }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(1, cx); + press("enter", cx); + tab_to(2, cx); + press("enter", cx); + assert_eq!( + host.read_with(cx, |host, _| host.opened.clone()), + ["later.md", "late.md"] + ); +} + +/// A file search over a field it owns, finding what the test says, and what it opened. +struct Seeking { + query: Entity, + found: Vec, + opened: Vec, +} + +impl Render for Seeking { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + div().w(px(320.0)).child( + FileSearch::new("search", &self.query, self.found.clone()).on_open( + move |file, _, cx| owner.update(cx, |host, _| host.opened.push(file.name.clone())), + ), + ) + } +} + +fn seeking<'a>( + text: &str, + found: Vec, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + setup(cx); + let text = text.to_string(); + let (host, cx) = cx.add_window_view(move |window, cx| { + let query = cx.new(|cx| { + let mut field = TextInput::new(window, cx); + field.set_text(text, cx); + field + }); + Seeking { + query, + found, + opened: Vec::new(), + } + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (host, cx) +} + +#[gpui::test] +fn a_search_with_nothing_found_says_so(cx: &mut TestAppContext) { + let (_, cx) = seeking("zebra", Vec::new(), cx); + assert!(cx.debug_bounds("file-search-note").is_some()); +} + +#[gpui::test] +fn a_found_file_opens_on_enter(cx: &mut TestAppContext) { + let found = vec![FoundFile { + name: "Brief.md".into(), + folder: "Home/Atrium".into(), + }]; + let (host, cx) = seeking("brief", found, cx); + tab_to(3, cx); + press("enter", cx); + assert_eq!( + host.read_with(cx, |host, _| host.opened.clone()), + ["Brief.md"] + ); +} + +/// Two copies of a file with the second marked, and what it was told. +struct Sparing { + removed: Vec, + marks: Vec<(SharedString, bool)>, +} + +impl Render for Sparing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (remove, mark) = (cx.entity(), cx.entity()); + let group = Duplicates { + name: "a.jpg".into(), + size: 100, + paths: vec!["x/a.jpg".into(), "y/a.jpg".into()], + }; + div().w(px(320.0)).child( + DuplicateFinder::new("dupes", [group]) + .marked(["y/a.jpg"]) + .on_remove(move |paths, _, cx| { + remove.update(cx, |host, _| host.removed.extend(paths.iter().cloned())) + }) + .on_mark(move |path, on, _, cx| { + mark.update(cx, |host, _| host.marks.push((path.clone(), on))) + }), + ) + } +} + +#[gpui::test] +fn the_last_copy_stays_and_remove_takes_the_marked(cx: &mut TestAppContext) { + setup(cx); + let (host, cx) = cx.add_window_view(|_, _| Sparing { + removed: Vec::new(), + marks: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(1, cx); + press("space", cx); + tab_to(2, cx); + press("space", cx); + let heard = host.read_with(cx, |host, _| (host.removed.clone(), host.marks.clone())); + assert_eq!( + heard, + (vec!["y/a.jpg".into()], vec![("y/a.jpg".into(), false)]), + "x/a.jpg is kept, its mark shut" + ); +} + +#[test] +#[should_panic(expected = "every copy of a.jpg is marked")] +fn a_group_keeps_a_copy() { + let group = Duplicates { + name: "a.jpg".into(), + size: 1, + paths: vec!["x".into(), "y".into()], + }; + let _ = DuplicateFinder::new("dupes", [group]).marked(["x", "y"]); +} + +/// An archive of four files in two folders, and the files it handed over. +struct Unpacking { + opened: Vec, +} + +impl Render for Unpacking { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + let entry = |path: &str| ArchiveEntry { + path: SharedString::from(path.to_string()), + size: 400, + packed: 100, + }; + let entries = ["docs/readme.md", "docs/guide.md", "src/main.rs", "LICENSE"].map(entry); + div().w(px(320.0)).h(px(320.0)).child( + ArchiveViewer::new("archive", "site.zip", entries).on_open(move |entry, _, cx| { + owner.update(cx, |host, _| host.opened.push(entry.path.clone())) + }), + ) + } +} + +#[gpui::test] +fn a_file_in_an_open_folder_goes_to_the_host_on_enter(cx: &mut TestAppContext) { + setup(cx); + let (host, cx) = cx.add_window_view(|_, _| Unpacking { opened: Vec::new() }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(1, cx); + press("down", cx); + press("enter", cx); + assert_eq!( + host.read_with(cx, |host, _| host.opened.clone()), + ["docs/guide.md"] + ); +} + +#[test] +#[should_panic(expected = "LICENSE twice")] +fn an_archive_lists_a_path_once() { + let entry = ArchiveEntry { + path: "LICENSE".into(), + size: 1, + packed: 1, + }; + let _ = ArchiveViewer::new("archive", "a.zip", [entry.clone(), entry]); +} diff --git a/src/files/tests/mod.rs b/src/files/tests/mod.rs index 003cee7..7944212 100644 --- a/src/files/tests/mod.rs +++ b/src/files/tests/mod.rs @@ -8,6 +8,7 @@ use super::{FileGrid, item::detail}; use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme}; mod explorer; +mod find; pub(super) fn setup(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/src/forms/check.rs b/src/forms/check.rs index 444782c..93a8865 100644 --- a/src/forms/check.rs +++ b/src/forms/check.rs @@ -171,7 +171,13 @@ impl RenderOnce for Checkbox { }) }) .child(check_mark(self.state, focused, self.disabled, changes, cx)) - .children(self.label) + .children(self.label.map(|label| { + div() + .debug_selector(|| "checkbox-label".into()) + .flex_1() + .min_w_0() + .child(label) + })) } } diff --git a/src/forms/tests/choices.rs b/src/forms/tests/choices.rs index 0e544ab..8fc0ac9 100644 --- a/src/forms/tests/choices.rs +++ b/src/forms/tests/choices.rs @@ -475,3 +475,24 @@ fn a_long_choice_keeps_its_list_inside_the_window(cx: &mut TestAppContext) { "{list:?}" ); } + +/// A checkbox whose label runs past its narrow box. +struct Worded; + +impl Render for Worded { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let label = "Projects/Atrium/Recordings/Walkthrough of the atrium at noon.mov"; + div() + .w(px(200.0)) + .child(Checkbox::new("worded", false).label(label)) + } +} + +#[gpui::test] +fn a_long_checkbox_label_wraps_inside_its_box(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Worded); + cx.run_until_parked(); + let label = cx.debug_bounds("checkbox-label").expect("the label draws"); + assert!(label.right() <= px(200.0), "{label:?}"); +} diff --git a/src/lists/files.rs b/src/lists/files.rs index e3e22fa..f5656d1 100644 --- a/src/lists/files.rs +++ b/src/lists/files.rs @@ -59,9 +59,25 @@ impl GitStatus { /// A folder of the tree being built: its folders and its files, each by name. #[derive(Default)] -struct Folder { - folders: BTreeMap, - files: Vec, +pub(crate) struct Folder { + pub folders: BTreeMap, + pub files: Vec, +} + +impl Folder { + /// Paths with `/` between folders, gathered into the folders that hold them. + pub(crate) fn of<'a>(paths: impl IntoIterator) -> Self { + let mut root = Folder::default(); + for path in paths { + let mut parts: Vec<&str> = path.split('/').collect(); + let file = parts.pop().expect("a path has a name").to_string(); + let folder = parts.iter().fold(&mut root, |folder, part| { + folder.folders.entry(part.to_string()).or_default() + }); + folder.files.push(file); + } + root + } } /// Whether `name` matches `query`, ignoring case; an empty query matches all. @@ -75,15 +91,7 @@ fn nodes( status: &[(SharedString, GitStatus)], query: &str, ) -> (Vec, Vec) { - let mut root = Folder::default(); - for path in paths { - let mut parts: Vec<&str> = path.split('/').collect(); - let file = parts.pop().expect("a path has a name").to_string(); - let folder = parts.iter().fold(&mut root, |folder, part| { - folder.folders.entry(part.to_string()).or_default() - }); - folder.files.push(file); - } + let root = Folder::of(paths.iter().map(|path| path.as_ref())); let state = |path: &str| { status .iter() diff --git a/src/lists/mod.rs b/src/lists/mod.rs index 71c4f18..60ae88f 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -13,6 +13,7 @@ mod tree; pub use directory::{DirEntry, DirectoryListing}; pub(crate) use directory::{OnClimb, listed, path_bar}; +pub(crate) use files::Folder; pub use files::{FileTree, GitStatus}; pub use grouped::GroupedList; pub use item::{List, ListItem}; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 21d53d7..753f867 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -305,13 +305,13 @@ ## T27c Files · Find and look -- [ ] FilePreview / QuickLook -- [ ] RecentFiles -- [ ] Favorites / Bookmarks → `documents::Favorites` -- [ ] FileSearch -- [ ] DuplicateFinder -- [ ] ArchiveViewer — host entries -- [ ] HexViewer → `debug::HexViewer` +- [x] FilePreview / QuickLook +- [x] RecentFiles +- [x] Favorites / Bookmarks → `documents::Favorites` +- [x] FileSearch +- [x] DuplicateFinder +- [x] ArchiveViewer — host entries +- [x] HexViewer → `debug::HexViewer` ## T27d Files · Transfers