Improve grouping of worktrees by repo in recent projects (#55715)

* Perform grouping even for repositories that have no main worktree
* Enable grouping for remote projects
* Delete entire project groups when deleting via the recent project
picker

Release Notes:

- Fixed a bug where each linked worktree appeared as its own entry in
recent projects for repositories without main worktrees
- Fixed a bug where deleting projects from the recent projects sometimes
appeared to have no effect.
This commit is contained in:
Max Brunsfeld 2026-05-05 01:21:03 -07:00 committed by GitHub
parent ec0fb05a44
commit 0fd49c840a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 800 additions and 344 deletions

View file

@ -200,27 +200,30 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::
return Ok(());
}
let recent_workspaces = workspace_db.recent_project_workspaces(fs.as_ref()).await?;
let recent_workspaces = workspace_db
.recent_project_workspaces_ungrouped(fs.as_ref())
.await?;
let mut local_path_lists = HashSet::<PathList>::default();
let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();
recent_workspaces
.iter()
.filter(|(_, location, path_list, _)| {
!path_list.is_empty() && matches!(location, &SerializedWorkspaceLocation::Local)
.filter(|workspace| {
!workspace.paths.is_empty()
&& matches!(workspace.location, SerializedWorkspaceLocation::Local)
})
.for_each(|(_, _, path_list, _)| {
local_path_lists.insert(path_list.clone());
.for_each(|workspace| {
local_path_lists.insert(workspace.paths.clone());
});
for (_, location, path_list, _) in recent_workspaces {
match location {
for workspace in recent_workspaces {
match workspace.location {
SerializedWorkspaceLocation::Remote(remote_connection)
if !local_path_lists.contains(&path_list) =>
if !local_path_lists.contains(&workspace.paths) =>
{
remote_path_lists
.entry(path_list)
.entry(workspace.paths)
.or_insert(remote_connection);
}
_ => {}

View file

@ -39,8 +39,8 @@ use ui_input::ErasedEditor;
use util::ResultExt;
use util::paths::PathExt;
use workspace::{
CloseWindow, ModalView, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb,
WorkspaceId, resolve_worktree_workspaces,
CloseWindow, ModalView, PathList, RecentWorkspace, SerializedWorkspaceLocation, Workspace,
WorkspaceDb, WorkspaceId,
};
use zed_actions::agents_sidebar::FocusSidebarFilter;
@ -1127,7 +1127,6 @@ impl ProjectPickerModal {
.await
.log_err()
.unwrap_or_default();
let workspaces = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
this.update_in(cx, move |this, window, cx| {
this.picker.update(cx, move |picker, cx| {
picker.delegate.workspaces = workspaces;
@ -1182,12 +1181,7 @@ struct ProjectPickerDelegate {
archive_view: WeakEntity<ThreadsArchiveView>,
current_workspace_id: Option<WorkspaceId>,
sibling_workspace_ids: HashSet<WorkspaceId>,
workspaces: Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
workspaces: Vec<RecentWorkspace>,
filtered_entries: Vec<ProjectPickerEntry>,
selected_index: usize,
focus_handle: FocusHandle,
@ -1332,9 +1326,10 @@ impl PickerDelegate for ProjectPickerDelegate {
.workspaces
.iter()
.enumerate()
.filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id))
.map(|(id, (_, _, paths, _))| {
let combined_string = paths
.filter(|(_, workspace)| self.is_sibling_workspace(workspace.workspace_id))
.map(|(id, workspace)| {
let combined_string = workspace
.identity_paths
.ordered_paths()
.map(|path| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
@ -1364,11 +1359,13 @@ impl PickerDelegate for ProjectPickerDelegate {
.workspaces
.iter()
.enumerate()
.filter(|(_, (id, _, _, _))| {
!self.is_current_workspace(*id) && !self.is_sibling_workspace(*id)
.filter(|(_, workspace)| {
!self.is_current_workspace(workspace.workspace_id)
&& !self.is_sibling_workspace(workspace.workspace_id)
})
.map(|(id, (_, _, paths, _))| {
let combined_string = paths
.map(|(id, workspace)| {
let combined_string = workspace
.identity_paths
.ordered_paths()
.map(|path| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
@ -1406,8 +1403,8 @@ impl PickerDelegate for ProjectPickerDelegate {
entries.push(ProjectPickerEntry::Header("This Window".into()));
if is_empty_query {
for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
if self.is_sibling_workspace(*workspace_id) {
for (id, workspace) in self.workspaces.iter().enumerate() {
if self.is_sibling_workspace(workspace.workspace_id) {
entries.push(ProjectPickerEntry::Workspace(StringMatch {
candidate_id: id,
score: 0.0,
@ -1433,9 +1430,9 @@ impl PickerDelegate for ProjectPickerDelegate {
entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
if is_empty_query {
for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
if !self.is_current_workspace(*workspace_id)
&& !self.is_sibling_workspace(*workspace_id)
for (id, workspace) in self.workspaces.iter().enumerate() {
if !self.is_current_workspace(workspace.workspace_id)
&& !self.is_sibling_workspace(workspace.workspace_id)
{
entries.push(ProjectPickerEntry::Workspace(StringMatch {
candidate_id: id,
@ -1468,11 +1465,11 @@ impl PickerDelegate for ProjectPickerDelegate {
Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id,
_ => return,
};
let Some((_workspace_id, _location, paths, _)) = self.workspaces.get(candidate_id) else {
let Some(workspace) = self.workspaces.get(candidate_id) else {
return;
};
self.update_working_directories_and_unarchive(paths.clone(), window, cx);
self.update_working_directories_and_unarchive(workspace.paths.clone(), window, cx);
cx.emit(DismissEvent);
}
@ -1504,9 +1501,11 @@ impl PickerDelegate for ProjectPickerDelegate {
.into_any_element(),
),
ProjectPickerEntry::Workspace(hit) => {
let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
let workspace = self.workspaces.get(hit.candidate_id)?;
let location = &workspace.location;
let ordered_paths: Vec<_> = paths
let ordered_paths: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact().to_string_lossy().to_string())
.collect();
@ -1514,7 +1513,8 @@ impl PickerDelegate for ProjectPickerDelegate {
let tooltip_path: SharedString = ordered_paths.join("\n").into();
let mut path_start_offset = 0;
let match_labels: Vec<_> = paths
let match_labels: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact())
.map(|path| {

View file

@ -60,26 +60,6 @@ pub const GRAPH_CHUNK_SIZE: usize = 1000;
/// Default value for the `git.worktree_directory` setting.
pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
/// Determine the original (main) repository's working directory.
///
/// For linked worktrees, `common_dir` differs from `repository_dir` and
/// points to the main repo's `.git` directory, so we can derive the main
/// repo's working directory from it. For normal repos and submodules,
/// `common_dir` equals `repository_dir`, and the original repo is simply
/// `work_directory` itself.
pub fn original_repo_path(
work_directory: &Path,
common_dir: &Path,
repository_dir: &Path,
) -> PathBuf {
if common_dir != repository_dir {
original_repo_path_from_common_dir(common_dir)
.unwrap_or_else(|| work_directory.to_path_buf())
} else {
work_directory.to_path_buf()
}
}
/// Given the git common directory (from `commondir()`), derive the original
/// repository's working directory.
///

View file

@ -7917,14 +7917,17 @@ impl Repository {
}
/// If `path` is a git linked worktree checkout, resolves it to the main
/// repository's working directory path. Returns `None` if `path` is a normal
/// repository, not a git repo, or if resolution fails.
/// repository's identity path. For regular linked worktrees this is the main
/// repository's working directory; for linked worktrees backed by a bare repo
/// such as `.bare`, this is the parent project directory users think of as the
/// repository root. Returns `None` if `path` is a normal repository, not a git
/// repo, or if resolution fails.
///
/// Resolution works by:
/// 1. Reading the `.git` file to get the `gitdir:` pointer
/// 2. Following that to the worktree-specific git directory
/// 3. Reading the `commondir` file to find the shared `.git` directory
/// 4. Deriving the main repo's working directory from the common dir
/// 4. Deriving the main repo's identity path from the common dir
pub async fn resolve_git_worktree_to_main_repo(fs: &dyn Fs, path: &Path) -> Option<PathBuf> {
let dot_git = path.join(".git");
let metadata = fs.metadata(&dot_git).await.ok()??;
@ -7941,7 +7944,7 @@ pub async fn resolve_git_worktree_to_main_repo(fs: &dyn Fs, path: &Path) -> Opti
.canonicalize(&gitdir_abs.join(commondir_content.trim()))
.await
.ok()?;
git::repository::original_repo_path_from_common_dir(&common_dir)
Some(repo_identity_path(&common_dir).to_path_buf())
}
/// Validates that the resolved worktree directory is acceptable:

View file

@ -108,7 +108,7 @@ pub use prettier_store::PrettierStore;
use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
#[cfg(target_os = "windows")]
use remote::wsl_path_to_windows_path;
use remote::{RemoteClient, RemoteConnectionOptions};
use remote::{RemoteClient, RemoteConnectionOptions, same_remote_connection_identity};
use rpc::{
AnyProtoClient, ErrorCode,
proto::{LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID},
@ -6226,6 +6226,11 @@ impl ProjectGroupKey {
pub fn host(&self) -> Option<RemoteConnectionOptions> {
self.host.clone()
}
pub fn matches(&self, other: &ProjectGroupKey) -> bool {
self.paths == other.paths
&& same_remote_connection_identity(self.host.as_ref(), other.host.as_ref())
}
}
pub fn path_suffix(path: &Path, detail: usize) -> String {

View file

@ -1666,6 +1666,35 @@ mod resolve_worktree_tests {
assert_eq!(result, None);
}
#[gpui::test]
async fn test_resolve_git_worktree_bare_repo_identity_path(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/monty/.bare",
json!({
"worktrees": {
"feature-a": {
"commondir": "../../",
"HEAD": "ref: refs/heads/feature-a"
}
}
}),
)
.await;
fs.insert_tree(
"/monty/feature-a",
json!({
".git": "gitdir: /monty/.bare/worktrees/feature-a",
"src": { "main.rs": "" }
}),
)
.await;
let result =
resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/monty/feature-a")).await;
assert_eq!(result, Some(PathBuf::from("/monty")));
}
#[gpui::test]
async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());

View file

@ -47,7 +47,7 @@ use ui::{
use util::{ResultExt, paths::PathExt};
use workspace::{
HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
RecentWorkspace, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
notifications::DetachAndPromptErr, with_active_or_new_workspace,
};
use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
@ -102,13 +102,13 @@ pub async fn get_recent_projects(
let filtered: Vec<_> = workspaces
.into_iter()
.filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
.filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
.filter(|workspace| Some(workspace.workspace_id) != current_workspace_id)
.filter(|workspace| matches!(workspace.location, SerializedWorkspaceLocation::Local))
.collect();
let mut all_paths: Vec<PathBuf> = filtered
.iter()
.flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned())
.flat_map(|workspace| workspace.identity_paths.paths().iter().cloned())
.collect();
all_paths.sort();
all_paths.dedup();
@ -121,9 +121,9 @@ pub async fn get_recent_projects(
let entries: Vec<RecentProjectEntry> = filtered
.into_iter()
.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();
.map(|workspace| {
let paths: Vec<PathBuf> = workspace.paths.paths().to_vec();
let ordered_paths: Vec<&PathBuf> = workspace.identity_paths.ordered_paths().collect();
let name = ordered_paths
.iter()
@ -145,8 +145,8 @@ pub async fn get_recent_projects(
name: SharedString::from(name),
full_path: SharedString::from(full_path),
paths,
workspace_id,
timestamp,
workspace_id: workspace.workspace_id,
timestamp: workspace.timestamp,
}
})
.collect();
@ -614,7 +614,6 @@ impl RecentProjects {
.await
.log_err()
.unwrap_or_default();
let workspaces = workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
this.update_in(cx, move |this, window, cx| {
this.picker.update(cx, move |picker, cx| {
picker.delegate.set_workspaces(workspaces);
@ -773,11 +772,9 @@ impl RecentProjects {
if let Some(ProjectPickerEntry::RecentProject(hit)) =
picker.delegate.filtered_entries.get(ix)
{
if let Some((_, location, paths, _)) =
picker.delegate.workspaces.get(hit.candidate_id)
{
if matches!(location, SerializedWorkspaceLocation::Local) {
let paths_to_add = paths.paths().to_vec();
if let Some(workspace) = picker.delegate.workspaces.get(hit.candidate_id) {
if matches!(workspace.location, SerializedWorkspaceLocation::Local) {
let paths_to_add = workspace.paths.paths().to_vec();
picker
.delegate
.add_paths_to_project(paths_to_add, window, cx);
@ -812,12 +809,7 @@ pub struct RecentProjectsDelegate {
workspace: WeakEntity<Workspace>,
open_folders: Vec<OpenFolderEntry>,
window_project_groups: Vec<ProjectGroupKey>,
workspaces: Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
workspaces: Vec<RecentWorkspace>,
filtered_entries: Vec<ProjectPickerEntry>,
selected_index: usize,
render_paths: bool,
@ -860,20 +852,12 @@ impl RecentProjectsDelegate {
}
}
pub fn set_workspaces(
&mut self,
workspaces: Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
) {
pub fn set_workspaces(&mut self, workspaces: Vec<RecentWorkspace>) {
self.workspaces = workspaces;
let has_non_local_recent = !self
.workspaces
.iter()
.all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
.all(|workspace| matches!(workspace.location, SerializedWorkspaceLocation::Local));
self.has_any_non_local_projects =
self.project_connection_options.is_some() || has_non_local_recent;
}
@ -987,9 +971,10 @@ impl PickerDelegate for RecentProjectsDelegate {
.workspaces
.iter()
.enumerate()
.filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
.map(|(id, (_, _, paths, _))| {
let combined_string = paths
.filter(|(_, workspace)| self.is_valid_recent_candidate(workspace, cx))
.map(|(id, workspace)| {
let combined_string = workspace
.identity_paths
.ordered_paths()
.map(|path| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
@ -1063,8 +1048,8 @@ 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() {
if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
for (id, workspace) in self.workspaces.iter().enumerate() {
if self.is_valid_recent_candidate(workspace, cx) {
entries.push(ProjectPickerEntry::RecentProject(StringMatch {
candidate_id: id,
score: 0.0,
@ -1149,20 +1134,15 @@ impl PickerDelegate for RecentProjectsDelegate {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let Some((
candidate_workspace_id,
candidate_workspace_location,
candidate_workspace_paths,
_,
)) = self.workspaces.get(selected_match.candidate_id)
let Some(candidate_workspace) = self.workspaces.get(selected_match.candidate_id)
else {
return;
};
let replace_current_window = self.create_new_window == secondary;
let candidate_workspace_id = *candidate_workspace_id;
let candidate_workspace_location = candidate_workspace_location.clone();
let candidate_workspace_paths = candidate_workspace_paths.clone();
let candidate_workspace_id = candidate_workspace.workspace_id;
let candidate_workspace_location = candidate_workspace.location.clone();
let candidate_workspace_paths = candidate_workspace.paths.clone();
workspace.update(cx, |workspace, cx| {
if workspace.database_id() == Some(candidate_workspace_id) {
@ -1497,10 +1477,13 @@ impl PickerDelegate for RecentProjectsDelegate {
)
}
ProjectPickerEntry::RecentProject(hit) => {
let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
let workspace = self.workspaces.get(hit.candidate_id)?;
let location = &workspace.location;
let raw_paths = &workspace.paths;
let identity_paths = &workspace.identity_paths;
let is_local = matches!(location, SerializedWorkspaceLocation::Local);
let paths_to_add = paths.paths().to_vec();
let ordered_paths: Vec<_> = paths
let paths_to_add = raw_paths.paths().to_vec();
let ordered_paths: Vec<_> = identity_paths
.ordered_paths()
.map(|p| p.compact().to_string_lossy().to_string())
.collect();
@ -1517,7 +1500,7 @@ impl PickerDelegate for RecentProjectsDelegate {
};
let mut path_start_offset = 0;
let (match_labels, paths): (Vec<_>, Vec<_>) = paths
let (match_labels, paths): (Vec<_>, Vec<_>) = identity_paths
.ordered_paths()
.map(|p| p.compact())
.map(|path| {
@ -1891,8 +1874,11 @@ impl PickerDelegate for RecentProjectsDelegate {
Some(ProjectPickerEntry::RecentProject(hit)) => self
.workspaces
.get(hit.candidate_id)
.map(|(_, loc, ..)| {
matches!(loc, SerializedWorkspaceLocation::Local)
.map(|workspace| {
matches!(
workspace.location,
SerializedWorkspaceLocation::Local
)
})
.unwrap_or(false),
_ => false,
@ -2129,22 +2115,23 @@ 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 = *workspace_id;
let recent_workspace = self.workspaces[selected_match.candidate_id].clone();
let fs = self
.workspace
.upgrade()
.map(|ws| ws.read(cx).app_state().fs.clone());
let db = WorkspaceDb::global(cx);
cx.spawn_in(window, async move |this, cx| {
db.delete_workspace_by_id(workspace_id).await.log_err();
let Some(fs) = fs else { return };
let deleted_workspace_ids = db
.delete_recent_workspace_group(&recent_workspace)
.await
.log_err()
.unwrap_or_default();
let workspaces = db
.recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default();
let workspaces =
workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
this.update_in(cx, move |picker, window, cx| {
picker.delegate.set_workspaces(workspaces);
picker
@ -2155,8 +2142,11 @@ impl RecentProjectsDelegate {
// After deleting a project, we want to update the history manager to reflect the change.
// But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
if let Some(history_manager) = HistoryManager::global(cx) {
history_manager
.update(cx, |this, cx| this.delete_history(workspace_id, cx));
history_manager.update(cx, |this, cx| {
for workspace_id in &deleted_workspace_ids {
this.delete_history(*workspace_id, cx);
}
});
}
})
.ok();
@ -2209,10 +2199,10 @@ impl RecentProjectsDelegate {
false
}
fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
fn is_in_current_window_groups(&self, workspace: &RecentWorkspace) -> bool {
self.window_project_groups
.iter()
.any(|key| key.path_list() == paths)
.any(|key| key.matches(&workspace.project_group_key()))
}
fn is_open_folder(&self, paths: &PathList) -> bool {
@ -2233,13 +2223,12 @@ impl RecentProjectsDelegate {
fn is_valid_recent_candidate(
&self,
workspace_id: WorkspaceId,
paths: &PathList,
workspace: &RecentWorkspace,
cx: &mut Context<Picker<Self>>,
) -> bool {
!self.is_current_workspace(workspace_id, cx)
&& !self.is_in_current_window_groups(paths)
&& !self.is_open_folder(paths)
!self.is_current_workspace(workspace.workspace_id, cx)
&& !self.is_in_current_window_groups(workspace)
&& !self.is_open_folder(&workspace.paths)
}
}

View file

@ -1,6 +1,5 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
@ -16,8 +15,8 @@ use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}
use ui_input::ErasedEditor;
use util::{ResultExt, paths::PathExt};
use workspace::{
MultiWorkspace, OpenMode, OpenOptions, PathList, ProjectGroupKey, SerializedWorkspaceLocation,
Workspace, WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
MultiWorkspace, OpenMode, OpenOptions, ProjectGroupKey, RecentWorkspace,
SerializedWorkspaceLocation, Workspace, WorkspaceDb, notifications::DetachAndPromptErr,
};
use zed_actions::OpenRemote;
@ -74,8 +73,6 @@ impl SidebarRecentProjects {
.await
.log_err()
.unwrap_or_default();
let workspaces =
workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
this.update_in(cx, move |this, window, cx| {
this.picker.update(cx, move |picker, cx| {
picker.delegate.set_workspaces(workspaces);
@ -116,12 +113,7 @@ impl Render for SidebarRecentProjects {
pub struct SidebarRecentProjectsDelegate {
workspace: WeakEntity<Workspace>,
window_project_groups: Vec<ProjectGroupKey>,
workspaces: Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
workspaces: Vec<RecentWorkspace>,
filtered_workspaces: Vec<StringMatch>,
selected_index: usize,
has_any_non_local_projects: bool,
@ -129,18 +121,10 @@ pub struct SidebarRecentProjectsDelegate {
}
impl SidebarRecentProjectsDelegate {
pub fn set_workspaces(
&mut self,
workspaces: Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
) {
pub fn set_workspaces(&mut self, workspaces: Vec<RecentWorkspace>) {
self.has_any_non_local_projects = workspaces
.iter()
.any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local));
.any(|workspace| !matches!(workspace.location, SerializedWorkspaceLocation::Local));
self.workspaces = workspaces;
}
}
@ -206,15 +190,16 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
.workspaces
.iter()
.enumerate()
.filter(|(_, (id, _, paths, _))| {
Some(*id) != current_workspace_id
.filter(|(_, workspace)| {
Some(workspace.workspace_id) != current_workspace_id
&& !self
.window_project_groups
.iter()
.any(|key| key.path_list() == paths)
.any(|key| key.matches(&workspace.project_group_key()))
})
.map(|(id, (_, _, paths, _))| {
let combined_string = paths
.map(|(id, workspace)| {
let combined_string = workspace
.identity_paths
.ordered_paths()
.map(|path| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
@ -251,9 +236,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
return;
};
let Some((_, location, candidate_workspace_paths, _)) =
self.workspaces.get(hit.candidate_id)
else {
let Some(recent_workspace) = self.workspaces.get(hit.candidate_id) else {
return;
};
@ -261,10 +244,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
return;
};
match location {
match &recent_workspace.location {
SerializedWorkspaceLocation::Local => {
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
let paths = candidate_workspace_paths.paths().to_vec();
let paths = recent_workspace.paths.paths().to_vec();
cx.defer(move |cx| {
if let Some(task) = handle
.update(cx, |multi_workspace, window, cx| {
@ -290,7 +273,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
crate::RemoteSettings::get_global(cx)
.fill_connection_options_from_settings(connection);
};
let paths = candidate_workspace_paths.paths().to_vec();
let paths = recent_workspace.paths.paths().to_vec();
cx.spawn_in(window, async move |_, cx| {
open_remote_project(connection.clone(), paths, app_state, open_options, cx)
.await
@ -326,14 +309,15 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let hit = self.filtered_workspaces.get(ix)?;
let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
let workspace = self.workspaces.get(hit.candidate_id)?;
let ordered_paths: Vec<_> = paths
let ordered_paths: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact().to_string_lossy().to_string())
.collect();
let tooltip_path: SharedString = match &location {
let tooltip_path: SharedString = match &workspace.location {
SerializedWorkspaceLocation::Remote(options) => {
let host = options.display_name();
if ordered_paths.len() == 1 {
@ -346,7 +330,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
};
let mut path_start_offset = 0;
let match_labels: Vec<_> = paths
let match_labels: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact())
.map(|path| {
@ -357,7 +342,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
})
.collect();
let prefix = match &location {
let prefix = match &workspace.location {
SerializedWorkspaceLocation::Remote(options) => {
Some(SharedString::from(options.display_name()))
}
@ -371,7 +356,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
active: false,
};
let icon = icon_for_remote_connection(match location {
let icon = icon_for_remote_connection(match &workspace.location {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(options) => Some(options),
});

View file

@ -49,9 +49,12 @@ impl HistoryManager {
.unwrap_or_default()
.into_iter()
.rev()
.filter_map(|(id, location, paths, _timestamp)| {
if matches!(location, SerializedWorkspaceLocation::Local) {
Some(HistoryManagerEntry::new(id, &paths))
.filter_map(|workspace| {
if matches!(workspace.location, SerializedWorkspaceLocation::Local) {
Some(HistoryManagerEntry::new(
workspace.workspace_id,
&workspace.paths,
))
} else {
None
}

File diff suppressed because it is too large Load diff

View file

@ -130,6 +130,13 @@ pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
pub(crate) location: SerializedWorkspaceLocation,
pub(crate) paths: PathList,
/// The workspace's main worktree paths at the time this workspace was saved.
///
/// These paths are used for grouping, deduping, and display in recent-workspace
/// UIs. They are not authoritative for reopening the workspace, because they may
/// become stale if the repository layout changes after the save. Use `paths` when
/// reopening the workspace.
pub(crate) identity_paths: Option<PathList>,
pub(crate) center_group: SerializedPaneGroup,
pub(crate) window_bounds: Option<SerializedWindowBounds>,
pub(crate) centered_layout: bool,

View file

@ -1,11 +1,10 @@
use crate::{
NewFile, Open, OpenMode, PathList, SerializedWorkspaceLocation, ToggleWorkspaceSidebar,
Workspace, WorkspaceId,
NewFile, Open, OpenMode, PathList, RecentWorkspace, SerializedWorkspaceLocation,
ToggleWorkspaceSidebar, Workspace,
item::{Item, ItemEvent},
persistence::WorkspaceDb,
};
use agent_settings::AgentSettings;
use chrono::{DateTime, Utc};
use git::Clone as GitClone;
use gpui::{
Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
@ -242,14 +241,7 @@ pub struct WelcomePage {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
fallback_to_recent_projects: bool,
recent_workspaces: Option<
Vec<(
WorkspaceId,
SerializedWorkspaceLocation,
PathList,
DateTime<Utc>,
)>,
>,
recent_workspaces: Option<Vec<RecentWorkspace>>,
}
impl WelcomePage {
@ -310,14 +302,11 @@ impl WelcomePage {
cx: &mut Context<Self>,
) {
if let Some(recent_workspaces) = &self.recent_workspaces {
if let Some((_workspace_id, location, paths, _timestamp)) =
recent_workspaces.get(action.index)
{
let is_local = matches!(location, SerializedWorkspaceLocation::Local);
if let Some(workspace) = recent_workspaces.get(action.index) {
let is_local = matches!(workspace.location, SerializedWorkspaceLocation::Local);
if is_local {
let paths = paths.clone();
let paths = paths.paths().to_vec();
let paths = workspace.paths.paths().to_vec();
self.workspace
.update(cx, |workspace, cx| {
workspace
@ -433,8 +422,13 @@ impl Render for WelcomePage {
.flatten()
.take(5)
.enumerate()
.map(|(index, (_, loc, paths, _))| {
self.render_recent_project(index, first_section_entries + index, loc, paths)
.map(|(index, workspace)| {
self.render_recent_project(
index,
first_section_entries + index,
&workspace.location,
&workspace.identity_paths,
)
})
.collect::<Vec<_>>();

View file

@ -84,15 +84,15 @@ pub use pane_group::{
ActivePaneDecorator, HANDLE_HITBOX_SIZE, Member, PaneAxis, PaneGroup, PaneRenderContext,
SplitDirection,
};
use persistence::{SerializedWindowBounds, model::SerializedWorkspace};
pub use persistence::{
WorkspaceDb, delete_unloaded_items,
RecentWorkspace, WorkspaceDb, delete_unloaded_items,
model::{
DockData, DockStructure, ItemId, MultiWorkspaceState, SerializedMultiWorkspace,
SerializedProjectGroup, SerializedWorkspaceLocation, SessionWorkspace,
},
read_serialized_multi_workspaces, resolve_worktree_workspaces,
read_serialized_multi_workspaces,
};
use persistence::{SerializedWindowBounds, model::SerializedWorkspace};
use postage::stream::Stream;
use project::{
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
@ -6857,11 +6857,13 @@ impl Workspace {
let center_group = build_serialized_pane_group(&self.center.root, window, cx);
let docks = build_serialized_docks(self, window, cx);
let window_bounds = Some(SerializedWindowBounds(window.window_bounds()));
let identity_paths_hint = self.project_group_key(cx).path_list().clone();
let serialized_workspace = SerializedWorkspace {
id: database_id,
location,
paths,
identity_paths: Some(identity_paths_hint),
center_group,
window_bounds,
display: Default::default(),
@ -8947,7 +8949,7 @@ pub async fn last_opened_workspace_location(
.await
.log_err()
.flatten()
.map(|(id, location, paths, _timestamp)| (id, location, paths))
.map(|workspace| (workspace.workspace_id, workspace.location, workspace.paths))
}
pub async fn last_session_workspace_locations(
@ -9068,7 +9070,7 @@ pub async fn apply_restored_multiworkspace_state(
&& let Some(common_dir) =
project::discover_root_repo_common_dir(path, fs.as_ref()).await
{
let main_path = common_dir.parent().unwrap_or(&common_dir);
let main_path = project::repo_identity_path(&common_dir);
resolved_paths.push(main_path.to_path_buf());
} else {
resolved_paths.push(path.to_path_buf());