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 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.add_linked_worktree_for_repo(
254            std::path::Path::new("/project/.git"),
255            false,
256            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                is_main: false,
261            },
262        )
263        .await;
264        fs
265    }
266
267    #[gpui::test]
268    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
269        init_test(cx);
270        let fs = create_fs_with_main_and_worktree(cx).await;
271        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
272
273        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
274        project
275            .update(cx, |project, cx| project.git_scans_complete(cx))
276            .await;
277
278        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
279            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
280        });
281
282        multi_workspace.read_with(cx, |mw, cx| {
283            let mut canonicalizer = ProjectGroupBuilder::new();
284            for workspace in mw.workspaces() {
285                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
286            }
287
288            // The main repo path should canonicalize to itself.
289            assert_eq!(
290                canonicalizer.canonicalize_path(Path::new("/project")),
291                Path::new("/project"),
292            );
293
294            // An unknown path returns None.
295            assert_eq!(
296                canonicalizer.canonicalize_path(Path::new("/something/else")),
297                Path::new("/something/else"),
298            );
299        });
300    }
301
302    #[gpui::test]
303    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
304        init_test(cx);
305        let fs = create_fs_with_main_and_worktree(cx).await;
306        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
307
308        // Open the worktree checkout as its own project.
309        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
310        project
311            .update(cx, |project, cx| project.git_scans_complete(cx))
312            .await;
313
314        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
315            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
316        });
317
318        multi_workspace.read_with(cx, |mw, cx| {
319            let mut canonicalizer = ProjectGroupBuilder::new();
320            for workspace in mw.workspaces() {
321                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
322            }
323
324            // The worktree checkout path should canonicalize to the main repo.
325            assert_eq!(
326                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
327                Path::new("/project"),
328            );
329        });
330    }
331}