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 path list doesn't match `group_path_list`.
183    pub fn group_owns_worktree(
184        &self,
185        group: &ProjectGroup,
186        group_path_list: &PathList,
187        worktree_path: &Path,
188    ) -> bool {
189        let worktree_arc: Arc<Path> = Arc::from(worktree_path);
190        if group.covered_paths.contains(&worktree_arc) {
191            return false;
192        }
193        let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
194        canonical == *group_path_list
195    }
196
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_group_names
208            .iter()
209            .zip(self.project_groups.iter())
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use std::sync::Arc;
216
217    use super::*;
218    use fs::FakeFs;
219    use gpui::TestAppContext;
220    use settings::SettingsStore;
221
222    fn init_test(cx: &mut TestAppContext) {
223        cx.update(|cx| {
224            let settings_store = SettingsStore::test(cx);
225            cx.set_global(settings_store);
226            theme::init(theme::LoadThemes::JustBase, cx);
227        });
228    }
229
230    async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
231        let fs = FakeFs::new(cx.executor());
232        fs.insert_tree(
233            "/project",
234            serde_json::json!({
235                ".git": {
236                    "worktrees": {
237                        "feature-a": {
238                            "commondir": "../../",
239                            "HEAD": "ref: refs/heads/feature-a",
240                        },
241                    },
242                },
243                "src": {},
244            }),
245        )
246        .await;
247        fs.insert_tree(
248            "/wt/feature-a",
249            serde_json::json!({
250                ".git": "gitdir: /project/.git/worktrees/feature-a",
251                "src": {},
252            }),
253        )
254        .await;
255        fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
256            state.worktrees.push(git::repository::Worktree {
257                path: std::path::PathBuf::from("/wt/feature-a"),
258                ref_name: Some("refs/heads/feature-a".into()),
259                sha: "abc".into(),
260            });
261        })
262        .expect("git state should be set");
263        fs
264    }
265
266    #[gpui::test]
267    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
268        init_test(cx);
269        let fs = create_fs_with_main_and_worktree(cx).await;
270        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
271
272        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
273        project
274            .update(cx, |project, cx| project.git_scans_complete(cx))
275            .await;
276
277        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
278            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
279        });
280
281        multi_workspace.read_with(cx, |mw, cx| {
282            let mut canonicalizer = ProjectGroupBuilder::new();
283            for workspace in mw.workspaces() {
284                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
285            }
286
287            // The main repo path should canonicalize to itself.
288            assert_eq!(
289                canonicalizer.canonicalize_path(Path::new("/project")),
290                Path::new("/project"),
291            );
292
293            // An unknown path returns None.
294            assert_eq!(
295                canonicalizer.canonicalize_path(Path::new("/something/else")),
296                Path::new("/something/else"),
297            );
298        });
299    }
300
301    #[gpui::test]
302    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
303        init_test(cx);
304        let fs = create_fs_with_main_and_worktree(cx).await;
305        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
306
307        // Open the worktree checkout as its own project.
308        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
309        project
310            .update(cx, |project, cx| project.git_scans_complete(cx))
311            .await;
312
313        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
314            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
315        });
316
317        multi_workspace.read_with(cx, |mw, cx| {
318            let mut canonicalizer = ProjectGroupBuilder::new();
319            for workspace in mw.workspaces() {
320                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
321            }
322
323            // The worktree checkout path should canonicalize to the main repo.
324            assert_eq!(
325                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
326                Path::new("/project"),
327            );
328        });
329    }
330}