1#[cfg(test)]
3mod tests;
4use super::model::Status;
5use crate::{
6 repository::{self, Identity, Worktree},
7 store::{CatalogRun, Signoff},
8};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 path::{Path, PathBuf},
12};
13
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Repository {
16 Git(PathBuf),
17 Workspace(PathBuf),
18}
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
20pub enum Scope {
21 All,
22 Repository(Repository),
23 Worktree(Repository, PathBuf),
24}
25#[derive(Debug, Clone, Default)]
26pub struct Badge {
27 pub waiting: BTreeSet<Signoff>,
28 pub red: u64,
29 pub error: u64,
30 pub running: u64,
31}
32impl Badge {
33 fn add(&mut self, run: &CatalogRun) {
34 self.waiting.extend(run.waiting.iter().cloned());
35 self.red += run.red;
36 self.error += run.error;
37 self.running += u64::from(run.status.is_open());
38 }
39 pub fn parts(&self) -> Vec<(Status, u64)> {
42 let mut parts: Vec<_> = [
43 (Status::WaitingHuman, self.waiting.len() as u64),
44 (Status::Red, self.red),
45 (Status::Error, self.error),
46 (Status::Running, self.running),
47 ]
48 .into_iter()
49 .filter(|(_, count)| *count > 0)
50 .collect();
51 parts.sort_by_key(|(status, _)| *status);
52 parts
53 }
54}
55#[derive(Debug, Clone)]
56pub struct CatalogRow {
57 pub scope: Scope,
58 pub label: String,
59 pub badge: Badge,
60 pub depth: usize,
61 pub fold: Option<Repository>,
63}
64#[derive(Clone, Default)]
65pub struct Catalog {
66 facts: std::sync::Arc<Vec<CatalogRun>>,
67 initial_path: Option<PathBuf>,
68 identities: BTreeMap<PathBuf, Identity>,
69 trees: BTreeMap<PathBuf, Vec<Worktree>>,
70 pub rows: Vec<CatalogRow>,
71 paths: BTreeMap<Scope, BTreeSet<PathBuf>>,
72 expanded: BTreeSet<Repository>,
74 pub selected: Option<Scope>,
76}
77impl Catalog {
78 pub(super) fn reset_git(&mut self) {
80 self.identities.clear();
81 self.trees.clear();
82 }
83 fn identity(&mut self, path: &Path) -> Identity {
84 self.identities
85 .entry(path.to_path_buf())
86 .or_insert_with(|| repository::identify(path))
87 .clone()
88 }
89 fn trees(&mut self, common_dir: &Path) -> Vec<Worktree> {
90 self.trees
91 .entry(common_dir.to_path_buf())
92 .or_insert_with(|| repository::worktrees(common_dir))
93 .clone()
94 }
95 pub fn initial(&mut self, path: Option<&Path>) -> Scope {
96 path.map_or(Scope::All, |path| {
97 self.initial_path(path, &self.paths.clone())
98 })
99 }
100 fn initial_path(&mut self, path: &Path, paths: &BTreeMap<Scope, BTreeSet<PathBuf>>) -> Scope {
101 if let Some((scope, _)) = paths.iter().find(|(scope, workspaces)| {
102 matches!(scope, Scope::Worktree(_, tree) if crate::platform::paths_equal(tree, path) || workspaces.iter().any(|workspace| crate::platform::paths_equal(workspace, path)))
103 }) {
104 return scope.clone();
105 }
106 let identity = self.identity(path);
107 if let (Some(common), Some(tree)) = (identity.common_dir, identity.worktree_path) {
108 return Scope::Worktree(Repository::Git(common), tree);
109 }
110 paths
113 .iter()
114 .filter_map(|(scope, workspaces)| match scope {
115 Scope::Worktree(Repository::Workspace(_), _) => workspaces
116 .iter()
117 .filter(|workspace| crate::platform::is_within(path, workspace))
118 .map(|workspace| (workspace.components().count(), scope))
119 .max_by_key(|(depth, _)| *depth),
120 _ => None,
121 })
122 .max_by_key(|(depth, _)| *depth)
123 .map(|(_, scope)| scope.clone())
124 .unwrap_or_else(|| {
125 Scope::Worktree(
126 Repository::Workspace(path.to_path_buf()),
127 path.to_path_buf(),
128 )
129 })
130 }
131 pub fn update(&mut self, runs: &[CatalogRun], initial: Option<&Path>) {
133 self.facts = std::sync::Arc::new(runs.to_vec());
134 self.initial_path = initial.map(Path::to_path_buf);
135 let mut badges: BTreeMap<Scope, Badge> = BTreeMap::new();
136 let mut paths: BTreeMap<Scope, BTreeSet<PathBuf>> = BTreeMap::new();
137 let mut branches: BTreeMap<Scope, Option<String>> = BTreeMap::new();
138 for run in runs {
139 let identity =
140 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
141 run.repository.clone()
142 } else {
143 self.identity(&run.repo_path)
144 };
145 let repository = identity.common_dir.clone().map_or_else(
146 || Repository::Workspace(run.repo_path.clone()),
147 Repository::Git,
148 );
149 let tree = identity
150 .worktree_path
151 .unwrap_or_else(|| run.repo_path.clone());
152 let scope = Scope::Worktree(repository.clone(), tree);
153 branches.insert(scope.clone(), identity.branch);
154 for scope in [Scope::All, Scope::Repository(repository), scope] {
155 paths
156 .entry(scope.clone())
157 .or_default()
158 .insert(run.repo_path.clone());
159 badges.entry(scope).or_default().add(run);
160 }
161 }
162 if let Some(path) = initial {
163 let scope = self.initial_path(path, &paths);
164 if let Scope::Worktree(repository, _) = &scope {
165 paths
166 .entry(Scope::Repository(repository.clone()))
167 .or_default();
168 paths.entry(scope).or_default();
169 }
170 }
171 let repositories: Vec<_> = paths
172 .keys()
173 .filter_map(|scope| match scope {
174 Scope::Repository(repo) => Some(repo.clone()),
175 _ => None,
176 })
177 .collect();
178 for repository in &repositories {
179 if let Repository::Git(common) = repository {
180 for tree in self.trees(common) {
181 let scope = Scope::Worktree(repository.clone(), tree.path);
182 paths.entry(scope.clone()).or_default();
183 branches.entry(scope).or_insert(tree.branch);
184 }
185 }
186 }
187 let mut rows = vec![CatalogRow {
188 scope: Scope::All,
189 label: "ALL".into(),
190 badge: badges.get(&Scope::All).cloned().unwrap_or_default(),
191 depth: 0,
192 fold: None,
193 }];
194 for repository in repositories {
195 let root = match &repository {
196 Repository::Git(path) => path.parent().unwrap_or(path),
197 Repository::Workspace(path) => path.as_path(),
198 };
199 let label = root.file_name().map_or_else(
200 || crate::platform::path_text(root),
201 |name| name.to_string_lossy().into_owned(),
202 );
203 let scope = Scope::Repository(repository.clone());
205 rows.push(CatalogRow {
206 scope: scope.clone(),
207 label,
208 badge: badges.get(&scope).cloned().unwrap_or_default(),
209 depth: 0,
210 fold: None,
211 });
212 let mut folded = 0;
213 let expanded = self.expanded.contains(&repository);
214 for (scope, workspaces) in paths.iter().filter(
215 |(scope, _)| matches!(scope, Scope::Worktree(repo, _) if repo == &repository),
216 ) {
217 let Scope::Worktree(_, tree) = scope else {
218 unreachable!("worktree filter")
219 };
220 if workspaces.is_empty() && !expanded && self.selected.as_ref() != Some(scope) {
221 folded += 1;
222 continue;
223 }
224 let branch = branches.get(scope).and_then(Option::as_deref);
225 let name = tree.file_name().map_or_else(
226 || crate::platform::path_text(tree),
227 |name| name.to_string_lossy().into_owned(),
228 );
229 let label = match (branch, &repository) {
230 (Some(branch), _) if branch == name => branch.to_owned(),
231 (Some(branch), _) => format!("{branch} ยท {name}"),
232 (None, Repository::Git(_)) => format!("{name} (detached)"),
233 (None, Repository::Workspace(_)) => format!("{name} (non-Git)"),
234 };
235 rows.push(CatalogRow {
236 scope: scope.clone(),
237 label,
238 badge: badges.get(scope).cloned().unwrap_or_default(),
239 depth: 1,
240 fold: None,
241 });
242 }
243 let empty = paths
244 .iter()
245 .filter(|(scope, workspaces)| {
246 matches!(scope, Scope::Worktree(repo, _) if repo == &repository)
247 && workspaces.is_empty()
248 })
249 .count();
250 if folded > 0 || expanded && empty > 0 {
251 rows.push(CatalogRow {
252 scope: scope.clone(),
253 label: if expanded {
254 format!("โ hide {empty} without Runs (Space)")
255 } else {
256 format!("+{folded} without Runs (Space)")
257 },
258 badge: Badge::default(),
259 depth: 1,
260 fold: Some(repository.clone()),
261 });
262 }
263 }
264 self.rows = rows;
265 self.paths = paths;
266 }
267 pub fn rebuild(&mut self) {
269 let facts = self.facts.clone();
270 let initial = self.initial_path.clone();
271 self.update(&facts, initial.as_deref());
272 }
273 pub fn toggle(&mut self, repository: &Repository) {
275 if !self.expanded.remove(repository) {
276 self.expanded.insert(repository.clone());
277 }
278 }
279 pub fn label(&self, scope: &Scope) -> String {
281 if *scope == Scope::All {
282 return "all repositories".into();
283 }
284 self.rows
285 .iter()
286 .find(|row| row.fold.is_none() && row.scope == *scope)
287 .map_or_else(
288 || match scope {
289 Scope::Worktree(_, path) => path.file_name().map_or_else(
290 || crate::platform::path_text(path),
291 |name| name.to_string_lossy().into(),
292 ),
293 _ => "repository".into(),
294 },
295 |row| row.label.clone(),
296 )
297 }
298 pub fn paths(&self, scope: &Scope) -> Option<Vec<PathBuf>> {
299 match scope {
300 Scope::All => None,
301 _ => Some(
302 self.paths
303 .get(scope)
304 .map_or_else(Vec::new, |paths| paths.iter().cloned().collect()),
305 ),
306 }
307 }
308}