diff --git a/AGENTS.md b/AGENTS.md index 1b3b46b..3b670b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time. - Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::glyphs`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. -- Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. +- Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. A child keyed by its place inherits the state of what stood there before; key it by what it shows (`files::FileExplorer` keys a column by its folder). - Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). - gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`. - 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`). diff --git a/TASKS.md b/TASKS.md index 637df52..5790e4b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -323,3 +323,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | | +| 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 | diff --git a/src/files/columns.rs b/src/files/columns.rs index edb3eb9..937f0db 100644 --- a/src/files/columns.rs +++ b/src/files/columns.rs @@ -43,11 +43,12 @@ pub(super) fn columns( .iter() .enumerate() .map(|(depth, entries)| { + let folder = path[..=depth].join("/"); let rows = entries.iter().enumerate().fold( - SelectableList::new((id.clone(), format!("column-{depth}"))), + SelectableList::new((id.clone(), format!("column-{folder}"))), |list, (ix, entry)| { let row = ListItem::new( - (id.clone(), format!("row-{depth}-{ix}")), + (id.clone(), format!("row-{folder}-{ix}")), entry.name().clone(), ) .leading(FileIcon::entry(entry)) diff --git a/src/files/explorer.rs b/src/files/explorer.rs index 52adf01..b424055 100644 --- a/src/files/explorer.rs +++ b/src/files/explorer.rs @@ -63,6 +63,15 @@ impl FileExplorer { path.len(), levels.len() ); + for (depth, name) in path.iter().enumerate().skip(1) { + assert!( + levels[depth - 1] + .iter() + .any(|entry| entry.is_folder() && entry.name() == name), + "{name} is no folder in {}", + path[depth - 1] + ); + } Self { id: id.into(), path, @@ -93,7 +102,7 @@ impl FileExplorer { self } - /// Gets the path to go to, from the root down. + /// Gets the path to go to, from the root down; the pick stays with the folder left, so the host drops it. pub fn on_navigate( mut self, handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, diff --git a/src/files/tests/explorer.rs b/src/files/tests/explorer.rs index 42eff52..2ef6b4b 100644 --- a/src/files/tests/explorer.rs +++ b/src/files/tests/explorer.rs @@ -174,3 +174,83 @@ 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"); } + +/// An explorer that goes where it is sent, over a small tree, and the files it opened. +struct Walking { + path: Vec, + opened: Vec, +} + +fn folder(path: &[SharedString]) -> Vec { + let names: Vec<&str> = path.iter().map(|level| level.as_ref()).collect(); + match names.join("/").as_str() { + "Home" => vec![ + DirEntry::folder("Projects", then()), + DirEntry::folder("Documents", then()), + ], + "Home/Projects" => vec![ + DirEntry::folder("Atrium", then()), + DirEntry::folder("Dunes", then()), + ], + "Home/Projects/Dunes" => vec![DirEntry::file("Survey.csv", 10, then())], + "Home/Documents" => vec![ + DirEntry::file("Contract.docx", 10, then()), + DirEntry::file("Invoice.pdf", 10, then()), + ], + other => panic!("no folder {other}"), + } +} + +impl Render for Walking { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (go, open) = (cx.entity(), cx.entity()); + let levels = (1..=self.path.len()).map(|depth| folder(&self.path[..depth])); + let explorer = FileExplorer::new("explorer", self.path.clone(), levels, FileView::Columns) + .on_navigate(move |path, _, cx| { + go.update(cx, |host, cx| { + host.path = path.to_vec(); + cx.notify(); + }) + }) + .on_open(move |entry, _, cx| { + open.update(cx, |host, _| host.opened.push(entry.name().clone())) + }); + div().w(px(640.0)).child(explorer) + } +} + +#[gpui::test] +fn a_new_folder_at_a_depth_starts_its_column_fresh(cx: &mut TestAppContext) { + setup(cx); + let (host, cx) = cx.add_window_view(|_, _| Walking { + path: ["Home", "Projects"].map(SharedString::from).to_vec(), + opened: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(6, cx); + press("down", cx); + host.update(cx, |host, cx| { + host.path = ["Home", "Documents"].map(SharedString::from).to_vec(); + cx.notify(); + }); + settle(cx); + tab_to(6, cx); + press("enter", cx); + assert_eq!( + host.read_with(cx, |host, _| host.opened.clone()), + ["Contract.docx"] + ); +} + +#[test] +#[should_panic(expected = "Projects is no folder in Home")] +fn each_folder_on_the_path_is_listed_in_its_parent() { + let root = vec![DirEntry::file("Projects", 10, then())]; + let _ = FileExplorer::new( + "explorer", + ["Home", "Projects"], + [root, Vec::new()], + FileView::List, + ); +} diff --git a/src/lists/directory.rs b/src/lists/directory.rs index 1559eac..959acb9 100644 --- a/src/lists/directory.rs +++ b/src/lists/directory.rs @@ -2,14 +2,14 @@ use std::rc::Rc; use gpui::{ App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, rems, }; use jiff::Timestamp; use super::{ListItem, SelectableList}; use crate::{ forms::OnValue, - layout::seeded::use_seeded, + layout::{on_axis, seeded::use_seeded}, navigation::{Breadcrumb, Crumb}, primitives::{Icon, IconName, file_icon}, theme::{ActiveTheme, IconSize, TextSize}, @@ -67,6 +67,9 @@ enum Column { Modified, } +/// Each row's side padding and three gaps, in rems, as `ListItem` lays them. +const ROW_CHROME: f32 = 3.75; + /// Entries as a listing first shows them: folders first, then by name. pub(crate) fn listed(entries: Vec) -> Vec { sorted(entries, Column::Name, true) @@ -188,6 +191,11 @@ impl RenderOnce for DirectoryListing { let theme = cx.theme(); let colors = &theme.colors; let (size_width, date_width) = (theme.label_width() * 0.5, theme.label_width() * 0.75); + let least = theme.label_width() + + size_width + + date_width + + theme.icon_size(IconSize::Md) + + rems(ROW_CHROME); let now = Timestamp::now(); let header = |label: &'static str, this: Column| { let order = order.clone(); @@ -237,7 +245,12 @@ impl RenderOnce for DirectoryListing { .border_color(colors.border) .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_muted) - .child(div().flex_1().child(header("Name", Column::Name))) + .child( + div() + .debug_selector(|| "listing-name".into()) + .flex_1() + .child(header("Name", Column::Name)), + ) .child( div() .w(size_width) @@ -303,29 +316,32 @@ impl RenderOnce for DirectoryListing { &self.path, self.on_climb, )) - .child(heading) .child( - list.on_change(move |keys, window, cx| { - let Some(name) = keys.first() else { - return; - }; - store.update(cx, |picked, cx| { - 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 - .iter() - .find(|entry| entry.name == *name) - .expect("a listed entry"); - if let Some(on_open) = &on_open { - on_open(entry, window, cx); - } - }), + on_axis(div().id((self.id.clone(), "sideways")).overflow_x_scroll()).child( + div().flex().flex_col().min_w(least).child(heading).child( + list.on_change(move |keys, window, cx| { + let Some(name) = keys.first() else { + return; + }; + store.update(cx, |picked, cx| { + 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 + .iter() + .find(|entry| entry.name == *name) + .expect("a listed entry"); + if let Some(on_open) = &on_open { + on_open(entry, window, cx); + } + }), + ), + ), ) } } diff --git a/src/lists/tests.rs b/src/lists/tests.rs index 0a1221c..821922f 100644 --- a/src/lists/tests.rs +++ b/src/lists/tests.rs @@ -10,8 +10,8 @@ use gpui::{ }; use super::{ - InfiniteList, ListItem, SelectableList, SortableList, SwipeAction, SwipeableListItem, - VirtualList, + DirEntry, DirectoryListing, InfiniteList, ListItem, SelectableList, SortableList, SwipeAction, + SwipeableListItem, VirtualList, }; use crate::{ primitives::{IconName, Measure}, @@ -400,3 +400,34 @@ fn the_cursor_stays_on_its_row_when_the_rows_reorder(cx: &mut TestAppContext) { assert_eq!(opened, ["a"], "enter opens the row the cursor was on"); assert_eq!(picks(&view, cx), ["b"], "up moves from where that row went"); } + +/// A listing 280 wide. +struct Narrow; + +impl Render for Narrow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let then = jiff::Timestamp::from_second(1_700_000_000).expect("a time"); + let entries = [ + DirEntry::folder("Design", then), + DirEntry::file("Brief.md", 4_800, then), + ]; + div() + .w(px(280.0)) + .child(DirectoryListing::new("listing", ["Home"], entries)) + } +} + +#[gpui::test] +fn a_narrow_listing_keeps_room_for_the_name(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Narrow); + settle(cx); + let name = cx + .debug_bounds("listing-name") + .expect("the name heads its column"); + let least = cx.update(|window, cx| { + use crate::theme::ActiveTheme; + cx.theme().label_width().to_pixels(window.rem_size()) + }); + assert!(name.size.width >= least, "{name:?} under {least:?}"); +}