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        for workspace in mw.workspaces() {
 80            let group_key = workspace.read(cx).project_group_key(cx);
 81            builder
 82                .project_group_key(&group_key)
 83                .add_workspace(&workspace, cx);
 84        }
 85        builder
 86    }
 87
 88    fn project_group_key(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
 89        self.project_groups.entry_ref(name).or_insert_default()
 90    }
 91
 92    fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
 93        let old = self
 94            .directory_mappings
 95            .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
 96        if let Some(old) = old {
 97            debug_assert_eq!(
 98                &old, original_repo,
 99                "all worktrees should map to the same main worktree"
100            );
101        }
102    }
103
104    pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
105        for repo in workspace.project().read(cx).repositories(cx).values() {
106            let snapshot = repo.read(cx).snapshot();
107
108            self.add_mapping(
109                &snapshot.work_directory_abs_path,
110                &snapshot.original_repo_abs_path,
111            );
112
113            for worktree in snapshot.linked_worktrees.iter() {
114                self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
115            }
116        }
117    }
118
119    pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
120        self.directory_mappings
121            .get(path)
122            .map(AsRef::as_ref)
123            .unwrap_or(path)
124    }
125
126    /// Whether the given group should load threads for a linked worktree
127    /// at `worktree_path`. Returns `false` if the worktree already has an
128    /// open workspace in the group (its threads are loaded via the
129    /// workspace loop) or if the worktree's canonical path list doesn't
130    /// match `group_path_list`.
131    pub fn group_owns_worktree(
132        &self,
133        group: &ProjectGroup,
134        group_path_list: &PathList,
135        worktree_path: &Path,
136    ) -> bool {
137        if group.covered_paths.contains(worktree_path) {
138            return false;
139        }
140        let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
141        canonical == *group_path_list
142    }
143
144    /// Canonicalizes every path in a [`PathList`] using the builder's
145    /// directory mappings.
146    fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
147        let paths: Vec<_> = path_list
148            .paths()
149            .iter()
150            .map(|p| self.canonicalize_path(p).to_path_buf())
151            .collect();
152        PathList::new(&paths)
153    }
154
155    pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
156        self.project_groups.iter()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use std::sync::Arc;
163
164    use super::*;
165    use fs::FakeFs;
166    use gpui::TestAppContext;
167    use settings::SettingsStore;
168
169    fn init_test(cx: &mut TestAppContext) {
170        cx.update(|cx| {
171            let settings_store = SettingsStore::test(cx);
172            cx.set_global(settings_store);
173            theme_settings::init(theme::LoadThemes::JustBase, cx);
174        });
175    }
176
177    async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
178        let fs = FakeFs::new(cx.executor());
179        fs.insert_tree(
180            "/project",
181            serde_json::json!({
182                ".git": {
183                    "worktrees": {
184                        "feature-a": {
185                            "commondir": "../../",
186                            "HEAD": "ref: refs/heads/feature-a",
187                        },
188                    },
189                },
190                "src": {},
191            }),
192        )
193        .await;
194        fs.insert_tree(
195            "/wt/feature-a",
196            serde_json::json!({
197                ".git": "gitdir: /project/.git/worktrees/feature-a",
198                "src": {},
199            }),
200        )
201        .await;
202        fs.add_linked_worktree_for_repo(
203            std::path::Path::new("/project/.git"),
204            false,
205            git::repository::Worktree {
206                path: std::path::PathBuf::from("/wt/feature-a"),
207                ref_name: Some("refs/heads/feature-a".into()),
208                sha: "abc".into(),
209                is_main: false,
210            },
211        )
212        .await;
213        fs
214    }
215
216    #[gpui::test]
217    async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
218        init_test(cx);
219        let fs = create_fs_with_main_and_worktree(cx).await;
220        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
221
222        let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
223        project
224            .update(cx, |project, cx| project.git_scans_complete(cx))
225            .await;
226
227        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
228            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
229        });
230
231        multi_workspace.read_with(cx, |mw, cx| {
232            let mut canonicalizer = ProjectGroupBuilder::new();
233            for workspace in mw.workspaces() {
234                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
235            }
236
237            // The main repo path should canonicalize to itself.
238            assert_eq!(
239                canonicalizer.canonicalize_path(Path::new("/project")),
240                Path::new("/project"),
241            );
242
243            // An unknown path returns None.
244            assert_eq!(
245                canonicalizer.canonicalize_path(Path::new("/something/else")),
246                Path::new("/something/else"),
247            );
248        });
249    }
250
251    #[gpui::test]
252    async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
253        init_test(cx);
254        let fs = create_fs_with_main_and_worktree(cx).await;
255        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
256
257        // Open the worktree checkout as its own project.
258        let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
259        project
260            .update(cx, |project, cx| project.git_scans_complete(cx))
261            .await;
262
263        let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
264            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
265        });
266
267        multi_workspace.read_with(cx, |mw, cx| {
268            let mut canonicalizer = ProjectGroupBuilder::new();
269            for workspace in mw.workspaces() {
270                canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
271            }
272
273            // The worktree checkout path should canonicalize to the main repo.
274            assert_eq!(
275                canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
276                Path::new("/project"),
277            );
278        });
279    }
280}