From 4717aad596aeeef7ea74d8f34a6665e0a0b7327c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 20:22:12 -0400 Subject: [PATCH] Files: a narrow listing keeps its names; a column starts fresh for a new folder The directory listing's size and date columns are fixed, so at 280px the name had no room and rows showed no names. The heading and rows now sit in a sideways scroll box whose child keeps the least width: the name's least, the fixed columns and each row's icon, gaps and padding. A column of the explorer was keyed by its depth, so a new folder there inherited the old list's cursor; columns and rows are keyed by their folder. The explorer asserts each folder on its path is a folder in its parent. --- AGENTS.md | 2 +- TASKS.md | 1 + src/files/columns.rs | 5 ++- src/files/explorer.rs | 11 ++++- src/files/tests/explorer.rs | 80 +++++++++++++++++++++++++++++++++++++ src/lists/directory.rs | 66 ++++++++++++++++++------------ src/lists/tests.rs | 35 +++++++++++++++- 7 files changed, 169 insertions(+), 31 deletions(-) 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:?}"); +}