1//! The sidebar groups threads by a canonical path list.
2//!
3//! Threads have a path list associated with them, but this is the absolute path
4//! of whatever worktrees they were associated with. In the sidebar, we want to
5//! group all threads by their main worktree, and then we add a worktree chip to
6//! the sidebar entry when that thread is in another worktree.
7//!
8//! This module is provides the functions and structures necessary to do this
9//! lookup and mapping.
10
11use collections::{HashMap, HashSet, vecmap::VecMap};
12use std::{
13 path::{Path, PathBuf},
14 sync::Arc,
15};
16
17use gpui::{App, Entity};
18use workspace::{MultiWorkspace, PathList, ProjectGroupKey, Workspace};
19
20#[derive(Default)]
21pub struct ProjectGroup {
22 pub workspaces: Vec<Entity<Workspace>>,
23 /// Root paths of all open workspaces in this group. Used to skip
24 /// redundant thread-store queries for linked worktrees that already
25 /// have an open workspace.
26 covered_paths: HashSet<Arc<Path>>,
27}
28
29impl ProjectGroup {
30 fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
31 if !self.workspaces.contains(workspace) {
32 self.workspaces.push(workspace.clone());
33 }
34 for path in workspace.read(cx).root_paths(cx) {
35 self.covered_paths.insert(path);
36 }
37 }
38
39 pub fn first_workspace(&self) -> &Entity<Workspace> {
40 self.workspaces
41 .first()
42 .expect("groups always have at least one workspace")
43 }
44
45 pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
46 self.workspaces
47 .iter()
48 .find(|ws| {
49 !crate::root_repository_snapshots(ws, cx)
50 .any(|snapshot| snapshot.is_linked_worktree())
51 })
52 .unwrap_or_else(|| self.first_workspace())
53 }
54}
55
56pub struct ProjectGroupBuilder {
57 /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
58 directory_mappings: HashMap<PathBuf, PathBuf>,
59 project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
60}
61
62impl ProjectGroupBuilder {
63 fn new() -> Self {
64 Self {
65 directory_mappings: HashMap::default(),
66 project_groups: VecMap::new(),
67 }
68 }
69
70 pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
71 let mut builder = Self::new();
72 // First pass: collect all directory mappings from every workspace
73 // so we know how to canonicalize any path (including linked
74 // worktree paths discovered by the main repo's workspace).
75 for workspace in mw.workspaces() {
76 builder.add_workspace_mappings(workspace.read(cx), cx);
77 }
78
79 // Second pass: group each workspace using canonical paths derived
80 // from the full set of mappings.
81 for workspace in mw.workspaces() {
82 let group_key = workspace.read(cx).project_group_key(cx);
83 builder
84 .project_group_entry(&group_key)
85 .add_workspace(workspace, cx);
86 }
87 builder
88 }
89
90 fn project_group_entry(&mut self, key: &ProjectGroupKey) -> &mut ProjectGroup {
91 self.project_groups.entry_ref(key).or_insert_default()
92 }
93
94 fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
95 let old = self
96 .directory_mappings
97 .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
98 if let Some(old) = old {
99 debug_assert_eq!(
100 &old, original_repo,
101 "all worktrees should map to the same main worktree"
102 );
103 }
104 }
105
106 pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
107 for repo in workspace.project().read(cx).repositories(cx).values() {
108 let snapshot = repo.read(cx).snapshot();
109
110 self.add_mapping(
111 &snapshot.work_directory_abs_path,
112 &snapshot.original_repo_abs_path,
113 );
114
115 for worktree in snapshot.linked_worktrees.iter() {
116 self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
117 }
118 }
119 }
120
121 pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
122 self.directory_mappings
123 .get(path)
124 .map(AsRef::as_ref)
125 .unwrap_or(path)
126 }
127
128 /// Whether the given group should load threads for a linked worktree
129 /// at `worktree_path`. Returns `false` if the worktree already has an
130 /// open workspace in the group (its threads are loaded via the
131 /// workspace loop) or if the worktree's canonical path list doesn't
132 /// match `group_path_list`.
133 pub fn group_owns_worktree(
134 &self,
135 group: &ProjectGroup,
136 group_path_list: &PathList,
137 worktree_path: &Path,
138 ) -> bool {
139 if group.covered_paths.contains(worktree_path) {
140 return false;
141 }
142 let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
143 canonical == *group_path_list
144 }
145
146 /// Canonicalizes every path in a [`PathList`] using the builder's
147 /// directory mappings.
148 fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
149 let paths: Vec<_> = path_list
150 .paths()
151 .iter()
152 .map(|p| self.canonicalize_path(p).to_path_buf())
153 .collect();
154 PathList::new(&paths)
155 }
156
157 pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
158 self.project_groups.iter()
159 }
160
161 pub fn ensure_group(&mut self, key: &ProjectGroupKey) {
162 self.project_groups.entry_ref(key).or_insert_default();
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use std::sync::Arc;
169
170 use super::*;
171 use fs::FakeFs;
172 use gpui::TestAppContext;
173 use settings::SettingsStore;
174
175 fn init_test(cx: &mut TestAppContext) {
176 cx.update(|cx| {
177 let settings_store = SettingsStore::test(cx);
178 cx.set_global(settings_store);
179 theme_settings::init(theme::LoadThemes::JustBase, cx);
180 });
181 }
182
183 async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
184 let fs = FakeFs::new(cx.executor());
185 fs.insert_tree(
186 "/project",
187 serde_json::json!({
188 ".git": {
189 "worktrees": {
190 "feature-a": {
191 "commondir": "../../",
192 "HEAD": "ref: refs/heads/feature-a",
193 },
194 },
195 },
196 "src": {},
197 }),
198 )
199 .await;
200 fs.insert_tree(
201 "/wt/feature-a",
202 serde_json::json!({
203 ".git": "gitdir: /project/.git/worktrees/feature-a",
204 "src": {},
205 }),
206 )
207 .await;
208 fs.add_linked_worktree_for_repo(
209 std::path::Path::new("/project/.git"),
210 false,
211 git::repository::Worktree {
212 path: std::path::PathBuf::from("/wt/feature-a"),
213 ref_name: Some("refs/heads/feature-a".into()),
214 sha: "abc".into(),
215 is_main: false,
216 },
217 )
218 .await;
219 fs
220 }
221
222 #[gpui::test]
223 async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
224 init_test(cx);
225 let fs = create_fs_with_main_and_worktree(cx).await;
226 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
227
228 let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
229 project
230 .update(cx, |project, cx| project.git_scans_complete(cx))
231 .await;
232
233 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
234 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
235 });
236
237 multi_workspace.read_with(cx, |mw, cx| {
238 let mut canonicalizer = ProjectGroupBuilder::new();
239 for workspace in mw.workspaces() {
240 canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
241 }
242
243 // The main repo path should canonicalize to itself.
244 assert_eq!(
245 canonicalizer.canonicalize_path(Path::new("/project")),
246 Path::new("/project"),
247 );
248
249 // An unknown path returns None.
250 assert_eq!(
251 canonicalizer.canonicalize_path(Path::new("/something/else")),
252 Path::new("/something/else"),
253 );
254 });
255 }
256
257 #[gpui::test]
258 async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
259 init_test(cx);
260 let fs = create_fs_with_main_and_worktree(cx).await;
261 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
262
263 // Open the worktree checkout as its own project.
264 let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
265 project
266 .update(cx, |project, cx| project.git_scans_complete(cx))
267 .await;
268
269 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
270 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
271 });
272
273 multi_workspace.read_with(cx, |mw, cx| {
274 let mut canonicalizer = ProjectGroupBuilder::new();
275 for workspace in mw.workspaces() {
276 canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
277 }
278
279 // The worktree checkout path should canonicalize to the main repo.
280 assert_eq!(
281 canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
282 Path::new("/project"),
283 );
284 });
285 }
286}