Detailed changes
@@ -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",
@@ -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
@@ -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 {
@@ -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
@@ -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);
}
}
}
@@ -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
@@ -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 {
@@ -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(×tamp),
)
})
.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())
}
@@ -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<_>>();
@@ -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(
@@ -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"
@@ -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);