project_group_builder.rs

  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 std::{
 12    collections::{HashMap, HashSet},
 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
 78pub struct ProjectGroupBuilder {
 79    /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
 80    directory_mappings: HashMap<PathBuf, PathBuf>,
 81    project_group_names: Vec<ProjectGroupName>,
 82    project_groups: Vec<ProjectGroup>,
 83}
 84
 85impl ProjectGroupBuilder {
 86    fn new() -> Self {
 87        Self {
 88            directory_mappings: HashMap::new(),
 89            project_group_names: Vec::new(),
 90            project_groups: Vec::new(),
 91        }
 92    }
 93
 94    pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
 95        let mut builder = Self::new();
 96
 97        // First pass: collect all directory mappings from every workspace
 98        // so we know how to canonicalize any path (including linked
 99        // worktree paths discovered by the main repo's workspace).
100        for workspace in mw.workspaces() {
101            builder.add_workspace_mappings(workspace.read(cx), cx);
102        }
103
104        // Second pass: group each workspace using canonical paths derived
105        // from the full set of mappings.
106        for workspace in mw.workspaces() {
107            let group_name = builder.canonical_workspace_paths(workspace, cx);
108            builder
109                .project_group_entry(&group_name)
110                .add_workspace(workspace, cx);
111        }
112        builder
113    }
114
115    fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup {
116        match self.project_group_names.iter().position(|n| n == name) {
117            Some(idx) => &mut self.project_groups[idx],
118            None => {
119                let idx = self.project_group_names.len();
120                self.project_group_names.push(name.clone());
121                self.project_groups.push(ProjectGroup::default());
122                &mut self.project_groups[idx]
123            }
124        }
125    }
126
127    fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
128        let old = self
129            .directory_mappings
130            .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
131        if let Some(old) = old {
132            debug_assert_eq!(
133                &old, original_repo,
134                "all worktrees should map to the same main worktree"
135            );
136        }
137    }
138
139    pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
140        for repo in workspace.project().read(cx).repositories(cx).values() {
141            let snapshot = repo.read(cx).snapshot();
142
143            self.add_mapping(
144                &snapshot.work_directory_abs_path,
145                &snapshot.original_repo_abs_path,
146            );
147
148            for worktree in snapshot.linked_worktrees.iter() {
149                self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
150            }
151        }
152    }
153
154    /// Derives the canonical group name for a workspace by canonicalizing
155    /// each of its root paths using the builder's directory mappings.
156    fn canonical_workspace_paths(
157        &self,
158        workspace: &Entity<Workspace>,
159        cx: &App,
160    ) -> ProjectGroupName {
161        let paths: Vec<_> = workspace
162            .read(cx)
163            .root_paths(cx)
164            .iter()
165            .map(|p| self.canonicalize_path(p).to_path_buf())
166            .collect();
167        ProjectGroupName {
168            path_list: PathList::new(&paths),
169        }
170    }
171
172    pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
173        self.directory_mappings
174            .get(path)
175            .map(AsRef::as_ref)
176            .unwrap_or(path)
177    }
178
179    /// Whether the given group should load threads for a linked worktree at
180    /// `worktree_path`. Returns `false` if the worktree already has an open
181    /// workspace in the group (its threads are loaded via the workspace loop)
182    /// or if the worktree's canonical repo path isn't one of the group's roots.
183    ///
184    /// When multiple groups contain the same main repo, all of them will
185    /// return `true`. Callers must deduplicate across groups (e.g. using
186    /// `current_session_ids`) so that only the first group claims the thread.
187    pub fn group_owns_worktree(
188        &self,
189        group: &ProjectGroup,
190        group_path_list: &PathList,
191        worktree_path: &Path,
192    ) -> bool {
193        let worktree_arc: Arc<Path> = Arc::from(worktree_path);
194        if group.covered_paths.contains(&worktree_arc) {
195            return false;
196        }
197        let canonical = self.canonicalize_path(worktree_path);
198        group_path_list
199            .paths()
200            .iter()
201            .any(|p| Path::new(p) == canonical)
202    }
203
204    pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupName, &ProjectGroup)> {
205        self.project_group_names
206            .iter()
207            .zip(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::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            });
259        })
260        .expect("git state should be set");
261        fs
262    }
263
264    #[gpui::test]
265    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
266        init_test(cx);
267        let fs = create_fs_with_main_and_worktree(cx).await;
268        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
269
270        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
271        project
272            .update(cx, |project, cx| project.git_scans_complete(cx))
273            .await;
274
275        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
276            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
277        });
278
279        multi_workspace.read_with(cx, |mw, cx| {
280            let mut canonicalizer = ProjectGroupBuilder::new();
281            for workspace in mw.workspaces() {
282                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
283            }
284
285            // The main repo path should canonicalize to itself.
286            assert_eq!(
287                canonicalizer.canonicalize_path(Path::new("/project")),
288                Path::new("/project"),
289            );
290
291            // An unknown path returns None.
292            assert_eq!(
293                canonicalizer.canonicalize_path(Path::new("/something/else")),
294                Path::new("/something/else"),
295            );
296        });
297    }
298
299    #[gpui::test]
300    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
301        init_test(cx);
302        let fs = create_fs_with_main_and_worktree(cx).await;
303        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
304
305        // Open the worktree checkout as its own project.
306        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
307        project
308            .update(cx, |project, cx| project.git_scans_complete(cx))
309            .await;
310
311        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
312            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
313        });
314
315        multi_workspace.read_with(cx, |mw, cx| {
316            let mut canonicalizer = ProjectGroupBuilder::new();
317            for workspace in mw.workspaces() {
318                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
319            }
320
321            // The worktree checkout path should canonicalize to the main repo.
322            assert_eq!(
323                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
324                Path::new("/project"),
325            );
326        });
327    }
328}