From b280929d07995b5e333c848601de03b582768dfc 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:55:21 -0400 Subject: [PATCH] Files: recent and found files keep the cursor on the file, not its place RecentFiles and FileSearch keyed their rows by place in the list, so when an open moved a file to the top, the cursor stayed at the place and the next Enter opened another file. Rows are keyed by the file's folder and name, asserted once a list, so the cursor follows the file. A dotfile's kind reads by its extension, as its icon does. --- TASKS.md | 1 + src/files/preview.rs | 2 +- src/files/recent.rs | 24 ++++++++++-- src/files/search.rs | 24 ++++++++++-- src/files/tests/find.rs | 85 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/TASKS.md b/TASKS.md index 6bf4fbf..68d544d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -325,3 +325,4 @@ Per-component lines live in `tasks/`. Tags there: | 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 | | +| T27c | 1 | FAIL | recent and found files were keyed by their place, so the cursor left a file an open moved | diff --git a/src/files/preview.rs b/src/files/preview.rs index d8e24cb..0486af6 100644 --- a/src/files/preview.rs +++ b/src/files/preview.rs @@ -30,7 +30,7 @@ pub(crate) fn kind(entry: &DirEntry) -> String { return "Folder".into(); } match entry.name().rsplit_once('.') { - Some((stem, extension)) if !stem.is_empty() => extension.to_uppercase(), + Some((_, extension)) if !extension.is_empty() => extension.to_uppercase(), _ => "File".into(), } } diff --git a/src/files/recent.rs b/src/files/recent.rs index b762500..336a1c8 100644 --- a/src/files/recent.rs +++ b/src/files/recent.rs @@ -23,6 +23,13 @@ pub struct RecentFile { pub opened: Timestamp, } +impl RecentFile { + /// Where it lives: its folder and name. + fn place(&self) -> SharedString { + format!("{}/{}", self.folder, self.name).into() + } +} + /// 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 { @@ -45,9 +52,17 @@ pub struct RecentFiles { impl RecentFiles { pub fn new(id: impl Into, files: impl IntoIterator) -> Self { + let files: Vec = files.into_iter().collect(); + for (ix, file) in files.iter().enumerate() { + let place = file.place(); + assert!( + !files[..ix].iter().any(|other| other.place() == place), + "{place} twice" + ); + } Self { id: id.into(), - files: files.into_iter().collect(), + files, zone: None, on_open: None, } @@ -96,7 +111,7 @@ impl RenderOnce for RecentFiles { .leading(FileIcon::file(&file.name)) .description(file.folder.clone()) .trailing(time); - list.row(SharedString::from(ix.to_string()), row) + list.row(file.place(), row) }, ); let (id, files, on_open) = (self.id.clone(), files.clone(), self.on_open.clone()); @@ -113,7 +128,10 @@ impl RenderOnce for RecentFiles { .child(day_label(day, today)), ) .child(list.on_activate(move |key, window, cx| { - let file = &files[key.parse::().expect("a row's place")]; + let file = files + .iter() + .find(|file| file.place() == *key) + .expect("a listed file"); log::info!("recent files {id:?}: open {}", file.name); if let Some(on_open) = &on_open { on_open(file, window, cx); diff --git a/src/files/search.rs b/src/files/search.rs index ab389b4..61cd831 100644 --- a/src/files/search.rs +++ b/src/files/search.rs @@ -21,6 +21,13 @@ pub struct FoundFile { pub folder: SharedString, } +impl FoundFile { + /// Where it lives: its folder and name. + fn place(&self) -> SharedString { + format!("{}/{}", self.folder, self.name).into() + } +} + /// 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 { @@ -36,10 +43,18 @@ impl FileSearch { query: &Entity, found: impl IntoIterator, ) -> Self { + let found: Vec = found.into_iter().collect(); + for (ix, file) in found.iter().enumerate() { + let place = file.place(); + assert!( + !found[..ix].iter().any(|other| other.place() == place), + "{place} twice" + ); + } Self { id: id.into(), query: query.clone(), - found: found.into_iter().collect(), + found, on_open: None, } } @@ -79,7 +94,7 @@ impl RenderOnce for FileSearch { ) .leading(FileIcon::file(&file.name)) .description(file.folder.clone()); - list.row(SharedString::from(ix.to_string()), row) + list.row(file.place(), row) }, ); let (id, shown, on_open) = (self.id.clone(), found.clone(), self.on_open); @@ -93,7 +108,10 @@ impl RenderOnce for FileSearch { .gap_1() .child(quiet(count)) .child(list.on_activate(move |key, window, cx| { - let file = &shown[key.parse::().expect("a row's place")]; + let file = shown + .iter() + .find(|file| file.place() == *key) + .expect("a listed file"); log::info!("file search {id:?}: open {}", file.name); if let Some(on_open) = &on_open { on_open(file, window, cx); diff --git a/src/files/tests/find.rs b/src/files/tests/find.rs index 4dc7041..02f6eb5 100644 --- a/src/files/tests/find.rs +++ b/src/files/tests/find.rs @@ -30,7 +30,11 @@ fn tab_to(nth: usize, cx: &mut VisualTestContext) { 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::file(".env", 1, then())), + "ENV", + "as the icon map reads it" + ); assert_eq!(kind(&DirEntry::folder("Design", then())), "Folder"); } @@ -309,3 +313,82 @@ fn an_archive_lists_a_path_once() { }; let _ = ArchiveViewer::new("archive", "a.zip", [entry.clone(), entry]); } + +/// Three files opened today; an open moves a file to the top, as a host records it. +struct Reopening { + files: Vec, + opened: Vec, +} + +impl Render for Reopening { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + div().w(px(320.0)).child( + RecentFiles::new("recent", self.files.clone()) + .zone(TimeZone::UTC) + .on_open(move |file, _, cx| { + owner.update(cx, |host, cx| { + host.opened.push(file.name.clone()); + let now = Timestamp::now(); + for kept in &mut host.files { + if kept.name == file.name { + kept.opened = now; + } + } + cx.notify(); + }) + }), + ) + } +} + +#[gpui::test] +fn the_cursor_stays_on_a_file_an_open_moves(cx: &mut TestAppContext) { + setup(cx); + 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 = vec![file("a.md", 3), file("b.md", 2), file("c.md", 1)]; + let (host, cx) = cx.add_window_view(move |_, _| Reopening { + files, + opened: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(1, cx); + press("down", cx); + press("enter", cx); + press("enter", cx); + assert_eq!( + host.read_with(cx, |host, _| host.opened.clone()), + ["b.md", "b.md"] + ); +} + +#[test] +#[should_panic(expected = "Home/a.md twice")] +fn a_recent_file_is_listed_once() { + let file = RecentFile { + name: "a.md".into(), + folder: "Home".into(), + opened: then(), + }; + let _ = RecentFiles::new("recent", [file.clone(), file]); +} + +#[gpui::test] +#[should_panic(expected = "Home/a.md twice")] +fn a_found_file_is_listed_once(cx: &mut TestAppContext) { + let file = FoundFile { + name: "a.md".into(), + folder: "Home".into(), + }; + let _ = seeking("a", vec![file.clone(), file], cx); +}