workspace: Group recent projects by date (#49414)

Cameron Mcloughlin and Zed Zippy created

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

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(-)

Detailed changes

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",

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

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<PathBuf>,
     pub workspace_id: WorkspaceId,
+    pub timestamp: DateTime<Utc>,
 }
 
 #[derive(Clone, Debug)]
@@ -92,9 +95,9 @@ pub async fn get_recent_projects(
 
     let entries: Vec<RecentProjectEntry> = 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<PathBuf> = 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<Workspace>,
     open_folders: Vec<OpenFolderEntry>,
-    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
+    workspaces: Vec<(
+        WorkspaceId,
+        SerializedWorkspaceLocation,
+        PathList,
+        DateTime<Utc>,
+    )>,
     filtered_entries: Vec<ProjectPickerEntry>,
     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<Utc>,
+        )>,
     ) {
         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 {

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

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<TimeBucket> = 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<PathBuf>, window: &mut Window, cx: &mut App) {
-        let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() 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<PathBuf>, window: &mut Window, cx: &mut App) {
+    let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() 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);
             }
         }
     }

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

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 {

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<Utc> {
+    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<Vec<(WorkspaceId, PathList, Option<RemoteConnectionId>)>> {
+    ) -> Result<
+        Vec<(
+            WorkspaceId,
+            PathList,
+            Option<RemoteConnectionId>,
+            DateTime<Utc>,
+        )>,
+    > {
         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(&timestamp),
                 )
             })
             .collect())
     }
 
     query! {
-        fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>)>> {
-            SELECT workspace_id, paths, paths_order, remote_connection_id
+        fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, 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<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+    ) -> Result<
+        Vec<(
+            WorkspaceId,
+            SerializedWorkspaceLocation,
+            PathList,
+            DateTime<Utc>,
+        )>,
+    > {
         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<Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+    ) -> Result<
+        Option<(
+            WorkspaceId,
+            SerializedWorkspaceLocation,
+            PathList,
+            DateTime<Utc>,
+        )>,
+    > {
         Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
     }
 

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<Workspace>,
     focus_handle: FocusHandle,
     fallback_to_recent_projects: bool,
-    recent_workspaces: Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>>,
+    recent_workspaces: Option<
+        Vec<(
+            WorkspaceId,
+            SerializedWorkspaceLocation,
+            PathList,
+            DateTime<Utc>,
+        )>,
+    >,
 }
 
 impl WelcomePage {
@@ -272,7 +280,9 @@ impl WelcomePage {
         cx: &mut Context<Self>,
     ) {
         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::<Vec<_>>();

crates/workspace/src/workspace.rs 🔗

@@ -7883,7 +7883,11 @@ impl WorkspaceHandle for Entity<Workspace> {
 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(

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"

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);