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