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 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}