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 gpui::{App, Entity};
 13use project::ProjectGroupKey;
 14use std::{
 15    path::{Path, PathBuf},
 16    sync::Arc,
 17};
 18use workspace::{MultiWorkspace, PathList, 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_name = workspace.read(cx).project_group_key(cx);
 83            builder
 84                .project_group_entry(&group_name)
 85                .add_workspace(workspace, cx);
 86        }
 87        builder
 88    }
 89
 90    fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
 91        self.project_groups.entry_ref(name).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
162#[cfg(test)]
163mod tests {
164    use std::sync::Arc;
165
166    use super::*;
167    use fs::FakeFs;
168    use gpui::TestAppContext;
169    use settings::SettingsStore;
170
171    fn init_test(cx: &mut TestAppContext) {
172        cx.update(|cx| {
173            let settings_store = SettingsStore::test(cx);
174            cx.set_global(settings_store);
175            theme_settings::init(theme::LoadThemes::JustBase, cx);
176        });
177    }
178
179    async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
180        let fs = FakeFs::new(cx.executor());
181        fs.insert_tree(
182            "/project",
183            serde_json::json!({
184                ".git": {
185                    "worktrees": {
186                        "feature-a": {
187                            "commondir": "../../",
188                            "HEAD": "ref: refs/heads/feature-a",
189                        },
190                    },
191                },
192                "src": {},
193            }),
194        )
195        .await;
196        fs.insert_tree(
197            "/wt/feature-a",
198            serde_json::json!({
199                ".git": "gitdir: /project/.git/worktrees/feature-a",
200                "src": {},
201            }),
202        )
203        .await;
204        fs.add_linked_worktree_for_repo(
205            std::path::Path::new("/project/.git"),
206            false,
207            git::repository::Worktree {
208                path: std::path::PathBuf::from("/wt/feature-a"),
209                ref_name: Some("refs/heads/feature-a".into()),
210                sha: "abc".into(),
211                is_main: false,
212            },
213        )
214        .await;
215        fs
216    }
217
218    #[gpui::test]
219    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
220        init_test(cx);
221        let fs = create_fs_with_main_and_worktree(cx).await;
222        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
223
224        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
225        project
226            .update(cx, |project, cx| project.git_scans_complete(cx))
227            .await;
228
229        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
230            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
231        });
232
233        multi_workspace.read_with(cx, |mw, cx| {
234            let mut canonicalizer = ProjectGroupBuilder::new();
235            for workspace in mw.workspaces() {
236                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
237            }
238
239            // The main repo path should canonicalize to itself.
240            assert_eq!(
241                canonicalizer.canonicalize_path(Path::new("/project")),
242                Path::new("/project"),
243            );
244
245            // An unknown path returns None.
246            assert_eq!(
247                canonicalizer.canonicalize_path(Path::new("/something/else")),
248                Path::new("/something/else"),
249            );
250        });
251    }
252
253    #[gpui::test]
254    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
255        init_test(cx);
256        let fs = create_fs_with_main_and_worktree(cx).await;
257        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
258
259        // Open the worktree checkout as its own project.
260        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
261        project
262            .update(cx, |project, cx| project.git_scans_complete(cx))
263            .await;
264
265        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
266            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
267        });
268
269        multi_workspace.read_with(cx, |mw, cx| {
270            let mut canonicalizer = ProjectGroupBuilder::new();
271            for workspace in mw.workspaces() {
272                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
273            }
274
275            // The worktree checkout path should canonicalize to the main repo.
276            assert_eq!(
277                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
278                Path::new("/project"),
279            );
280        });
281    }
282}