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 fn identity(&mut self, path: &Path) -> Identity {
80 self.identities
81 .entry(path.to_path_buf())
82 .or_insert_with(|| repository::identify(path))
83 .clone()
84 }
85 fn trees(&mut self, common_dir: &Path) -> Vec<Worktree> {
86 self.trees
87 .entry(common_dir.to_path_buf())
88 .or_insert_with(|| repository::worktrees(common_dir))
89 .clone()
90 }
91 pub fn initial(&mut self, path: Option<&Path>) -> Scope {
92 path.map_or(Scope::All, |path| {
93 self.initial_path(path, &self.paths.clone())
94 })
95 }
96 fn initial_path(&mut self, path: &Path, paths: &BTreeMap<Scope, BTreeSet<PathBuf>>) -> Scope {
97 if let Some((scope, _)) = paths.iter().find(|(scope, workspaces)| {
98 matches!(scope, Scope::Worktree(_, tree) if crate::platform::paths_equal(tree, path) || workspaces.iter().any(|workspace| crate::platform::paths_equal(workspace, path)))
99 }) {
100 return scope.clone();
101 }
102 let identity = self.identity(path);
103 if let (Some(common), Some(tree)) = (identity.common_dir, identity.worktree_path) {
104 return Scope::Worktree(Repository::Git(common), tree);
105 }
106 paths
109 .iter()
110 .filter_map(|(scope, workspaces)| match scope {
111 Scope::Worktree(Repository::Workspace(_), _) => workspaces
112 .iter()
113 .filter(|workspace| crate::platform::is_within(path, workspace))
114 .map(|workspace| (workspace.components().count(), scope))
115 .max_by_key(|(depth, _)| *depth),
116 _ => None,
117 })
118 .max_by_key(|(depth, _)| *depth)
119 .map(|(_, scope)| scope.clone())
120 .unwrap_or_else(|| {
121 Scope::Worktree(
122 Repository::Workspace(path.to_path_buf()),
123 path.to_path_buf(),
124 )
125 })
126 }
127 pub fn update(&mut self, runs: &[CatalogRun], initial: Option<&Path>) {
129 self.facts = std::sync::Arc::new(runs.to_vec());
130 self.initial_path = initial.map(Path::to_path_buf);
131 let mut badges: BTreeMap<Scope, Badge> = BTreeMap::new();
132 let mut paths: BTreeMap<Scope, BTreeSet<PathBuf>> = BTreeMap::new();
133 let mut branches: BTreeMap<Scope, Option<String>> = BTreeMap::new();
134 for run in runs {
135 let identity =
136 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
137 run.repository.clone()
138 } else {
139 self.identity(&run.repo_path)
140 };
141 let repository = identity.common_dir.clone().map_or_else(
142 || Repository::Workspace(run.repo_path.clone()),
143 Repository::Git,
144 );
145 let tree = identity
146 .worktree_path
147 .unwrap_or_else(|| run.repo_path.clone());
148 let scope = Scope::Worktree(repository.clone(), tree);
149 branches.insert(scope.clone(), identity.branch);
150 for scope in [Scope::All, Scope::Repository(repository), scope] {
151 paths
152 .entry(scope.clone())
153 .or_default()
154 .insert(run.repo_path.clone());
155 badges.entry(scope).or_default().add(run);
156 }
157 }
158 if let Some(path) = initial {
159 let scope = self.initial_path(path, &paths);
160 if let Scope::Worktree(repository, _) = &scope {
161 paths
162 .entry(Scope::Repository(repository.clone()))
163 .or_default();
164 paths.entry(scope).or_default();
165 }
166 }
167 let repositories: Vec<_> = paths
168 .keys()
169 .filter_map(|scope| match scope {
170 Scope::Repository(repo) => Some(repo.clone()),
171 _ => None,
172 })
173 .collect();
174 for repository in &repositories {
175 if let Repository::Git(common) = repository {
176 for tree in self.trees(common) {
177 let scope = Scope::Worktree(repository.clone(), tree.path);
178 paths.entry(scope.clone()).or_default();
179 branches.entry(scope).or_insert(tree.branch);
180 }
181 }
182 }
183 let mut rows = vec![CatalogRow {
184 scope: Scope::All,
185 label: "ALL".into(),
186 badge: badges.get(&Scope::All).cloned().unwrap_or_default(),
187 depth: 0,
188 fold: None,
189 }];
190 for repository in repositories {
191 let root = match &repository {
192 Repository::Git(path) => path.parent().unwrap_or(path),
193 Repository::Workspace(path) => path.as_path(),
194 };
195 let label = root.file_name().map_or_else(
196 || crate::platform::path_text(root),
197 |name| name.to_string_lossy().into_owned(),
198 );
199 let scope = Scope::Repository(repository.clone());
201 rows.push(CatalogRow {
202 scope: scope.clone(),
203 label,
204 badge: badges.get(&scope).cloned().unwrap_or_default(),
205 depth: 0,
206 fold: None,
207 });
208 let mut folded = 0;
209 let expanded = self.expanded.contains(&repository);
210 for (scope, workspaces) in paths.iter().filter(
211 |(scope, _)| matches!(scope, Scope::Worktree(repo, _) if repo == &repository),
212 ) {
213 let Scope::Worktree(_, tree) = scope else {
214 unreachable!("worktree filter")
215 };
216 if workspaces.is_empty() && !expanded && self.selected.as_ref() != Some(scope) {
217 folded += 1;
218 continue;
219 }
220 let branch = branches.get(scope).and_then(Option::as_deref);
221 let name = tree.file_name().map_or_else(
222 || crate::platform::path_text(tree),
223 |name| name.to_string_lossy().into_owned(),
224 );
225 let label = match (branch, &repository) {
226 (Some(branch), _) if branch == name => branch.to_owned(),
227 (Some(branch), _) => format!("{branch} ยท {name}"),
228 (None, Repository::Git(_)) => format!("{name} (detached)"),
229 (None, Repository::Workspace(_)) => format!("{name} (non-Git)"),
230 };
231 rows.push(CatalogRow {
232 scope: scope.clone(),
233 label,
234 badge: badges.get(scope).cloned().unwrap_or_default(),
235 depth: 1,
236 fold: None,
237 });
238 }
239 let empty = paths
240 .iter()
241 .filter(|(scope, workspaces)| {
242 matches!(scope, Scope::Worktree(repo, _) if repo == &repository)
243 && workspaces.is_empty()
244 })
245 .count();
246 if folded > 0 || expanded && empty > 0 {
247 rows.push(CatalogRow {
248 scope: scope.clone(),
249 label: if expanded {
250 format!("โ hide {empty} without Runs (Space)")
251 } else {
252 format!("+{folded} without Runs (Space)")
253 },
254 badge: Badge::default(),
255 depth: 1,
256 fold: Some(repository.clone()),
257 });
258 }
259 }
260 self.rows = rows;
261 self.paths = paths;
262 }
263 pub fn rebuild(&mut self) {
265 let facts = self.facts.clone();
266 let initial = self.initial_path.clone();
267 self.update(&facts, initial.as_deref());
268 }
269 pub fn toggle(&mut self, repository: &Repository) {
271 if !self.expanded.remove(repository) {
272 self.expanded.insert(repository.clone());
273 }
274 }
275 pub fn label(&self, scope: &Scope) -> String {
277 if *scope == Scope::All {
278 return "all repositories".into();
279 }
280 self.rows
281 .iter()
282 .find(|row| row.fold.is_none() && row.scope == *scope)
283 .map_or_else(
284 || match scope {
285 Scope::Worktree(_, path) => path.file_name().map_or_else(
286 || crate::platform::path_text(path),
287 |name| name.to_string_lossy().into(),
288 ),
289 _ => "repository".into(),
290 },
291 |row| row.label.clone(),
292 )
293 }
294 pub fn paths(&self, scope: &Scope) -> Option<Vec<PathBuf>> {
295 match scope {
296 Scope::All => None,
297 _ => Some(
298 self.paths
299 .get(scope)
300 .map_or_else(Vec::new, |paths| paths.iter().cloned().collect()),
301 ),
302 }
303 }
304}