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 ui::SharedString;
19use workspace::{MultiWorkspace, PathList, Workspace};
20
21/// Identifies a project group by a set of paths the workspaces in this group
22/// have.
23///
24/// Paths are mapped to their main worktree path first so we can group
25/// workspaces by main repos.
26#[derive(PartialEq, Eq, Hash, Clone)]
27pub struct ProjectGroupName {
28 path_list: PathList,
29}
30
31impl ProjectGroupName {
32 pub fn display_name(&self) -> SharedString {
33 let mut names = Vec::with_capacity(self.path_list.paths().len());
34 for abs_path in self.path_list.paths() {
35 if let Some(name) = abs_path.file_name() {
36 names.push(name.to_string_lossy().to_string());
37 }
38 }
39 if names.is_empty() {
40 // TODO: Can we do something better in this case?
41 "Empty Workspace".into()
42 } else {
43 names.join(", ").into()
44 }
45 }
46
47 pub fn path_list(&self) -> &PathList {
48 &self.path_list
49 }
50}
51
52#[derive(Default)]
53pub struct ProjectGroup {
54 pub workspaces: Vec<Entity<Workspace>>,
55 /// Root paths of all open workspaces in this group. Used to skip
56 /// redundant thread-store queries for linked worktrees that already
57 /// have an open workspace.
58 covered_paths: HashSet<Arc<Path>>,
59}
60
61impl ProjectGroup {
62 fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
63 if !self.workspaces.contains(workspace) {
64 self.workspaces.push(workspace.clone());
65 }
66 for path in workspace.read(cx).root_paths(cx) {
67 self.covered_paths.insert(path);
68 }
69 }
70
71 pub fn first_workspace(&self) -> &Entity<Workspace> {
72 self.workspaces
73 .first()
74 .expect("groups always have at least one workspace")
75 }
76
77 pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
78 self.workspaces
79 .iter()
80 .find(|ws| {
81 !crate::root_repository_snapshots(ws, cx)
82 .any(|snapshot| snapshot.is_linked_worktree())
83 })
84 .unwrap_or_else(|| self.first_workspace())
85 }
86}
87
88pub struct ProjectGroupBuilder {
89 /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
90 directory_mappings: HashMap<PathBuf, PathBuf>,
91 project_groups: VecMap<ProjectGroupName, ProjectGroup>,
92}
93
94impl ProjectGroupBuilder {
95 fn new() -> Self {
96 Self {
97 directory_mappings: HashMap::default(),
98 project_groups: VecMap::new(),
99 }
100 }
101
102 pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
103 let mut builder = Self::new();
104 // First pass: collect all directory mappings from every workspace
105 // so we know how to canonicalize any path (including linked
106 // worktree paths discovered by the main repo's workspace).
107 for workspace in mw.workspaces() {
108 builder.add_workspace_mappings(workspace.read(cx), cx);
109 }
110
111 // Second pass: group each workspace using canonical paths derived
112 // from the full set of mappings.
113 for workspace in mw.workspaces() {
114 let group_name = builder.canonical_workspace_paths(workspace, cx);
115 builder
116 .project_group_entry(&group_name)
117 .add_workspace(workspace, cx);
118 }
119 builder
120 }
121
122 fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup {
123 self.project_groups.entry_ref(name).or_insert_default()
124 }
125
126 fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
127 let old = self
128 .directory_mappings
129 .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
130 if let Some(old) = old {
131 debug_assert_eq!(
132 &old, original_repo,
133 "all worktrees should map to the same main worktree"
134 );
135 }
136 }
137
138 pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
139 for repo in workspace.project().read(cx).repositories(cx).values() {
140 let snapshot = repo.read(cx).snapshot();
141
142 self.add_mapping(
143 &snapshot.work_directory_abs_path,
144 &snapshot.original_repo_abs_path,
145 );
146
147 for worktree in snapshot.linked_worktrees.iter() {
148 self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
149 }
150 }
151 }
152
153 /// Derives the canonical group name for a workspace by canonicalizing
154 /// each of its root paths using the builder's directory mappings.
155 fn canonical_workspace_paths(
156 &self,
157 workspace: &Entity<Workspace>,
158 cx: &App,
159 ) -> ProjectGroupName {
160 let root_paths = workspace.read(cx).root_paths(cx);
161 let paths: Vec<_> = root_paths
162 .iter()
163 .map(|p| self.canonicalize_path(p).to_path_buf())
164 .collect();
165 ProjectGroupName {
166 path_list: PathList::new(&paths),
167 }
168 }
169
170 pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
171 self.directory_mappings
172 .get(path)
173 .map(AsRef::as_ref)
174 .unwrap_or(path)
175 }
176
177 /// Whether the given group should load threads for a linked worktree
178 /// at `worktree_path`. Returns `false` if the worktree already has an
179 /// open workspace in the group (its threads are loaded via the
180 /// workspace loop) or if the worktree's canonical path list doesn't
181 /// match `group_path_list`.
182 pub fn group_owns_worktree(
183 &self,
184 group: &ProjectGroup,
185 group_path_list: &PathList,
186 worktree_path: &Path,
187 ) -> bool {
188 if group.covered_paths.contains(worktree_path) {
189 return false;
190 }
191 let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
192 canonical == *group_path_list
193 }
194
195 /// Canonicalizes every path in a [`PathList`] using the builder's
196 /// directory mappings.
197 fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
198 let paths: Vec<_> = path_list
199 .paths()
200 .iter()
201 .map(|p| self.canonicalize_path(p).to_path_buf())
202 .collect();
203 PathList::new(&paths)
204 }
205
206 pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupName, &ProjectGroup)> {
207 self.project_groups.iter()
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use std::sync::Arc;
214
215 use super::*;
216 use fs::FakeFs;
217 use gpui::TestAppContext;
218 use settings::SettingsStore;
219
220 fn init_test(cx: &mut TestAppContext) {
221 cx.update(|cx| {
222 let settings_store = SettingsStore::test(cx);
223 cx.set_global(settings_store);
224 theme_settings::init(theme::LoadThemes::JustBase, cx);
225 });
226 }
227
228 async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
229 let fs = FakeFs::new(cx.executor());
230 fs.insert_tree(
231 "/project",
232 serde_json::json!({
233 ".git": {
234 "worktrees": {
235 "feature-a": {
236 "commondir": "../../",
237 "HEAD": "ref: refs/heads/feature-a",
238 },
239 },
240 },
241 "src": {},
242 }),
243 )
244 .await;
245 fs.insert_tree(
246 "/wt/feature-a",
247 serde_json::json!({
248 ".git": "gitdir: /project/.git/worktrees/feature-a",
249 "src": {},
250 }),
251 )
252 .await;
253 fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
254 state.worktrees.push(git::repository::Worktree {
255 path: std::path::PathBuf::from("/wt/feature-a"),
256 ref_name: Some("refs/heads/feature-a".into()),
257 sha: "abc".into(),
258 is_main: false,
259 });
260 })
261 .expect("git state should be set");
262 fs
263 }
264
265 #[gpui::test]
266 async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
267 init_test(cx);
268 let fs = create_fs_with_main_and_worktree(cx).await;
269 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
270
271 let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
272 project
273 .update(cx, |project, cx| project.git_scans_complete(cx))
274 .await;
275
276 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
277 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
278 });
279
280 multi_workspace.read_with(cx, |mw, cx| {
281 let mut canonicalizer = ProjectGroupBuilder::new();
282 for workspace in mw.workspaces() {
283 canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
284 }
285
286 // The main repo path should canonicalize to itself.
287 assert_eq!(
288 canonicalizer.canonicalize_path(Path::new("/project")),
289 Path::new("/project"),
290 );
291
292 // An unknown path returns None.
293 assert_eq!(
294 canonicalizer.canonicalize_path(Path::new("/something/else")),
295 Path::new("/something/else"),
296 );
297 });
298 }
299
300 #[gpui::test]
301 async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
302 init_test(cx);
303 let fs = create_fs_with_main_and_worktree(cx).await;
304 cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
305
306 // Open the worktree checkout as its own project.
307 let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
308 project
309 .update(cx, |project, cx| project.git_scans_complete(cx))
310 .await;
311
312 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
313 workspace::MultiWorkspace::test_new(project.clone(), window, cx)
314 });
315
316 multi_workspace.read_with(cx, |mw, cx| {
317 let mut canonicalizer = ProjectGroupBuilder::new();
318 for workspace in mw.workspaces() {
319 canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
320 }
321
322 // The worktree checkout path should canonicalize to the main repo.
323 assert_eq!(
324 canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
325 Path::new("/project"),
326 );
327 });
328 }
329}