From 7eca6a9527cae26ea0cc7a63c6ec358ab00adc83 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Tue, 17 Feb 2026 23:30:28 +0000 Subject: [PATCH] workspace: Group recent projects by date (#49414) Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 3 + crates/recent_projects/Cargo.toml | 1 + crates/recent_projects/src/recent_projects.rs | 38 +++++--- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 93 +++++++++++++++---- crates/workspace/Cargo.toml | 1 + crates/workspace/src/history_manager.rs | 2 +- crates/workspace/src/persistence.rs | 46 +++++++-- crates/workspace/src/welcome.rs | 16 +++- crates/workspace/src/workspace.rs | 6 +- crates/zed/Cargo.toml | 3 +- crates/zed/src/visual_test_runner.rs | 11 +++ 12 files changed, 178 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f3bd8843256f1b217ae0e7835bf201666ee83af..f04291074480c4acc0a6644b6143d5b21f6bdd19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13452,6 +13452,7 @@ version = "0.1.0" dependencies = [ "anyhow", "askpass", + "chrono", "dap", "db", "dev_container", @@ -15297,6 +15298,7 @@ version = "0.1.0" dependencies = [ "acp_thread", "agent_ui", + "chrono", "db", "editor", "feature_flags", @@ -20668,6 +20670,7 @@ dependencies = [ "anyhow", "async-recursion", "call", + "chrono", "client", "clock", "collections", diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index fcb1dfe8cad163b3b5267645c7b8bb56dd72faf7..11daee79adc8099a8915b427394256eeed8b5e20 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -18,6 +18,7 @@ test-support = ["remote/test-support", "remote_connection/test-support", "projec [dependencies] anyhow.workspace = true +chrono.workspace = true askpass.workspace = true db.workspace = true dev_container.workspace = true diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 137b4d1da3a7785b5c28a87a84facefef521cba2..110a702437d463d6f296510c8f4a3a68d28d7d60 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -9,6 +9,8 @@ use std::{ sync::Arc, }; +use chrono::{DateTime, Utc}; + use fs::Fs; #[cfg(target_os = "windows")] @@ -56,6 +58,7 @@ pub struct RecentProjectEntry { pub full_path: SharedString, pub paths: Vec, pub workspace_id: WorkspaceId, + pub timestamp: DateTime, } #[derive(Clone, Debug)] @@ -92,9 +95,9 @@ pub async fn get_recent_projects( let entries: Vec = workspaces .into_iter() - .filter(|(id, _, _)| Some(*id) != current_workspace_id) - .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local)) - .map(|(workspace_id, _, path_list)| { + .filter(|(id, _, _, _)| Some(*id) != current_workspace_id) + .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local)) + .map(|(workspace_id, _, path_list, timestamp)| { let paths: Vec = path_list.paths().to_vec(); let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect(); @@ -123,6 +126,7 @@ pub async fn get_recent_projects( full_path: SharedString::from(full_path), paths, workspace_id, + timestamp, } }) .collect(); @@ -623,7 +627,12 @@ impl Render for RecentProjects { pub struct RecentProjectsDelegate { workspace: WeakEntity, open_folders: Vec, - workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, filtered_entries: Vec, selected_index: usize, render_paths: bool, @@ -666,13 +675,18 @@ impl RecentProjectsDelegate { pub fn set_workspaces( &mut self, - workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, ) { self.workspaces = workspaces; let has_non_local_recent = !self .workspaces .iter() - .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local)); + .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local)); self.has_any_non_local_projects = self.project_connection_options.is_some() || has_non_local_recent; } @@ -783,8 +797,8 @@ impl PickerDelegate for RecentProjectsDelegate { .workspaces .iter() .enumerate() - .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx)) - .map(|(id, (_, _, paths))| { + .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx)) + .map(|(id, (_, _, paths, _))| { let combined_string = paths .ordered_paths() .map(|path| path.compact().to_string_lossy().into_owned()) @@ -839,7 +853,7 @@ impl PickerDelegate for RecentProjectsDelegate { entries.push(ProjectPickerEntry::Header("Recent Projects".into())); if is_empty_query { - for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() { + for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() { if self.is_valid_recent_candidate(*workspace_id, paths, cx) { entries.push(ProjectPickerEntry::RecentProject(StringMatch { candidate_id: id, @@ -891,6 +905,7 @@ impl PickerDelegate for RecentProjectsDelegate { candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths, + _, )) = self.workspaces.get(selected_match.candidate_id) else { return; @@ -1095,7 +1110,7 @@ impl PickerDelegate for RecentProjectsDelegate { } ProjectPickerEntry::RecentProject(hit) => { let popover_style = matches!(self.style, ProjectPickerStyle::Popover); - let (_, location, paths) = self.workspaces.get(hit.candidate_id)?; + let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?; let is_local = matches!(location, SerializedWorkspaceLocation::Local); let paths_to_add = paths.paths().to_vec(); let tooltip_path: SharedString = paths @@ -1479,7 +1494,7 @@ impl RecentProjectsDelegate { if let Some(ProjectPickerEntry::RecentProject(selected_match)) = self.filtered_entries.get(ix) { - let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id]; + let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id]; let workspace_id = *workspace_id; let fs = self .workspace @@ -1654,6 +1669,7 @@ mod tests { WorkspaceId::default(), SerializedWorkspaceLocation::Local, PathList::new(&[path!("/test/path")]), + Utc::now(), )]); delegate.filtered_entries = vec![ProjectPickerEntry::RecentProject(StringMatch { diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index da4f29da8208540a483049687bae5a9715b2c710..f80a6f16725e00dfc353377e49339692be0837af 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] [dependencies] acp_thread.workspace = true agent_ui.workspace = true +chrono.workspace = true db.workspace = true fs.workspace = true fuzzy.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 14c5ce52c52350a347891d9a15bb5c44ff9f8f8e..9fd6c368800c0cf88b09af817883c1159230ab4a 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,5 +1,6 @@ use acp_thread::ThreadStatus; use agent_ui::{AgentPanel, AgentPanelEvent}; +use chrono::{Datelike, Local, NaiveDate, TimeDelta}; use db::kvp::KEY_VALUE_STORE; use fs::Fs; use fuzzy::StringMatchCandidate; @@ -10,6 +11,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use project::Event as ProjectEvent; use recent_projects::{RecentProjectEntry, get_recent_projects}; +use std::fmt::Display; use std::collections::{HashMap, HashSet}; @@ -294,32 +296,89 @@ impl WorkspacePickerDelegate { .collect(); if !recent.is_empty() { - self.entries - .push(SidebarEntry::Separator("Recent Projects".into())); + let today = Local::now().naive_local().date(); + let mut current_bucket: Option = None; + for project in recent { + let entry_date = project.timestamp.with_timezone(&Local).naive_local().date(); + let bucket = TimeBucket::from_dates(today, entry_date); + + if current_bucket != Some(bucket) { + current_bucket = Some(bucket); + self.entries + .push(SidebarEntry::Separator(bucket.to_string().into())); + } + self.entries.push(SidebarEntry::RecentProject(project)); } } } +} - fn open_recent_project(paths: Vec, window: &mut Window, cx: &mut App) { - let Some(handle) = window.window_handle().downcast::() else { - return; - }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} - cx.defer(move |cx| { - if let Some(task) = handle - .update(cx, |multi_workspace, window, cx| { - multi_workspace.open_project(paths, window, cx) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - }); +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } } } +fn open_recent_project(paths: Vec, window: &mut Window, cx: &mut App) { + let Some(handle) = window.window_handle().downcast::() else { + return; + }; + + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); +} + impl PickerDelegate for WorkspacePickerDelegate { type ListItem = AnyElement; @@ -500,7 +559,7 @@ impl PickerDelegate for WorkspacePickerDelegate { } SidebarEntry::RecentProject(project_entry) => { let paths = project_entry.paths.clone(); - Self::open_recent_project(paths, window, cx); + open_recent_project(paths, window, cx); } } } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index b8296d13af4275b6eef8fccc654be5c813a9ef61..3d9146250cd1df385761676b18d47ddcd3813dc6 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -32,6 +32,7 @@ anyhow.workspace = true async-recursion.workspace = true call.workspace = true client.workspace = true +chrono.workspace = true clock.workspace = true collections.workspace = true component.workspace = true diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index ebb4792ef0d6a49c05e2d98f166f7e8260a2ae0a..52f6be08b5972ab77a384aa8c0cf34fb29c2753c 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -47,7 +47,7 @@ impl HistoryManager { .unwrap_or_default() .into_iter() .rev() - .filter_map(|(id, location, paths)| { + .filter_map(|(id, location, paths, _timestamp)| { if matches!(location, SerializedWorkspaceLocation::Local) { Some(HistoryManagerEntry::new(id, &paths)) } else { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index bcff7bc24d5fe49e41b3ce3eda057323819f5589..5176fc4b2327c486c5ec07d30c8e85ed126730c4 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -8,6 +8,7 @@ use std::{ sync::Arc, }; +use chrono::{DateTime, NaiveDateTime, Utc}; use fs::Fs; use anyhow::{Context as _, Result, bail}; @@ -57,6 +58,12 @@ use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace}; // > which defaults to <..> 32766 for SQLite versions after 3.32.0. const MAX_QUERY_PLACEHOLDERS: usize = 32000; +fn parse_timestamp(text: &str) -> DateTime { + NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S") + .map(|naive| naive.and_utc()) + .unwrap_or_else(|_| Utc::now()) +} + #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); impl sqlez::bindable::StaticColumnCount for SerializedAxis {} @@ -1600,23 +1607,31 @@ impl WorkspaceDb { fn recent_workspaces( &self, - ) -> Result)>> { + ) -> Result< + Vec<( + WorkspaceId, + PathList, + Option, + DateTime, + )>, + > { Ok(self .recent_workspaces_query()? .into_iter() - .map(|(id, paths, order, remote_connection_id)| { + .map(|(id, paths, order, remote_connection_id, timestamp)| { ( id, PathList::deserialize(&SerializedPathList { paths, order }), remote_connection_id.map(RemoteConnectionId), + parse_timestamp(×tamp), ) }) .collect()) } query! { - fn recent_workspaces_query() -> Result)>> { - SELECT workspace_id, paths, paths_order, remote_connection_id + fn recent_workspaces_query() -> Result, String)>> { + SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp FROM workspaces WHERE paths IS NOT NULL OR @@ -1788,18 +1803,26 @@ impl WorkspaceDb { pub async fn recent_workspaces_on_disk( &self, fs: &dyn Fs, - ) -> Result> { + ) -> Result< + Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + > { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); let remote_connections = self.remote_connections()?; - for (id, paths, remote_connection_id) in self.recent_workspaces()? { + for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? { if let Some(remote_connection_id) = remote_connection_id { if let Some(connection_options) = remote_connections.get(&remote_connection_id) { result.push(( id, SerializedWorkspaceLocation::Remote(connection_options.clone()), paths, + timestamp, )); } else { delete_tasks.push(self.delete_workspace_by_id(id)); @@ -1821,7 +1844,7 @@ impl WorkspaceDb { // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { - result.push((id, SerializedWorkspaceLocation::Local, paths)); + result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -1834,7 +1857,14 @@ impl WorkspaceDb { pub async fn last_workspace( &self, fs: &dyn Fs, - ) -> Result> { + ) -> Result< + Option<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + > { Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next()) } diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 21b180e566af66a18b4cba9c3d260876041bff8b..1fffb704c5194e99b89f65ee3879342362f0917f 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -2,6 +2,7 @@ use crate::{ NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, item::{Item, ItemEvent}, }; +use chrono::{DateTime, Utc}; use git::Clone as GitClone; use gpui::WeakEntity; use gpui::{ @@ -212,7 +213,14 @@ pub struct WelcomePage { workspace: WeakEntity, focus_handle: FocusHandle, fallback_to_recent_projects: bool, - recent_workspaces: Option>, + recent_workspaces: Option< + Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + >, } impl WelcomePage { @@ -272,7 +280,9 @@ impl WelcomePage { cx: &mut Context, ) { if let Some(recent_workspaces) = &self.recent_workspaces { - if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { + if let Some((_workspace_id, location, paths, _timestamp)) = + recent_workspaces.get(action.index) + { let is_local = matches!(location, SerializedWorkspaceLocation::Local); if is_local { @@ -349,7 +359,7 @@ impl Render for WelcomePage { .flatten() .take(5) .enumerate() - .map(|(index, (_, loc, paths))| { + .map(|(index, (_, loc, paths, _))| { self.render_recent_project(index, first_section_entries + index, loc, paths) }) .collect::>(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4f44d282daf92ac3c952a9e447c9772c0912d4d1..dbd3324b78c6751bd4dc5a8c9e197e492d5fc2f6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7883,7 +7883,11 @@ impl WorkspaceHandle for Entity { pub async fn last_opened_workspace_location( fs: &dyn fs::Fs, ) -> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> { - DB.last_workspace(fs).await.log_err().flatten() + DB.last_workspace(fs) + .await + .log_err() + .flatten() + .map(|(id, location, paths, _timestamp)| (id, location, paths)) } pub async fn last_session_workspace_locations( diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 8315bf76cafd30fa275c263ca73072278cce918e..f696d4da2c6985650fd4d7d636eab15cc8398d84 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -78,6 +78,7 @@ auto_update_ui.workspace = true bincode.workspace = true breadcrumbs.workspace = true call.workspace = true +chrono.workspace = true channel.workspace = true clap.workspace = true cli.workspace = true @@ -226,11 +227,9 @@ zed_actions.workspace = true zed_env_vars.workspace = true zlog.workspace = true zlog_settings.workspace = true -chrono.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true -chrono.workspace = true [target.'cfg(target_os = "windows")'.build-dependencies] winresource = "0.1" diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index fea329d27c3946bd62e4a00c370b2a012c991c25..38ec26896dedad1cfb11c4a86341fc6015c4af14 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -50,6 +50,7 @@ use { agent_servers::{AgentServer, AgentServerDelegate}, anyhow::{Context as _, Result}, assets::Assets, + chrono::{Duration as ChronoDuration, Utc}, editor::display_map::DisplayRow, feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, @@ -2654,30 +2655,40 @@ fn run_multi_workspace_sidebar_visual_tests( // be inside a MultiWorkspace update when that happens. cx.update(|cx| { sidebar.update(cx, |sidebar, cx| { + let now = Utc::now(); + let today_timestamp = now; + let yesterday_timestamp = now - ChronoDuration::days(1); + let past_week_timestamp = now - ChronoDuration::days(10); + let all_timestamp = now - ChronoDuration::days(60); + let recent_projects = vec![ RecentProjectEntry { name: "tiny-project".into(), full_path: recent1_dir.to_string_lossy().to_string().into(), paths: vec![recent1_dir.clone()], workspace_id: WorkspaceId::default(), + timestamp: today_timestamp, }, RecentProjectEntry { name: "font-kit".into(), full_path: recent2_dir.to_string_lossy().to_string().into(), paths: vec![recent2_dir.clone()], workspace_id: WorkspaceId::default(), + timestamp: yesterday_timestamp, }, RecentProjectEntry { name: "ideas".into(), full_path: recent3_dir.to_string_lossy().to_string().into(), paths: vec![recent3_dir.clone()], workspace_id: WorkspaceId::default(), + timestamp: past_week_timestamp, }, RecentProjectEntry { name: "tmp".into(), full_path: recent4_dir.to_string_lossy().to_string().into(), paths: vec![recent4_dir.clone()], workspace_id: WorkspaceId::default(), + timestamp: all_timestamp, }, ]; sidebar.set_test_recent_projects(recent_projects, cx);