Skip to main content

artifactize_core/monitor/
catalog.rs

1//! Repository/worktree selection and cached Git discovery; no artifact config is read here.
2#[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    /// Status glyphs and counts in the urgency order of the header, Runs and the tree:
40    /// `!1 โœ—2 โ—1 ?1`.
41    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    /// A row standing for this repository's worktrees without Runs; Space shows or hides them.
62    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    /// Repositories whose worktrees without Runs are shown.
73    expanded: BTreeSet<Repository>,
74    /// The selected scope stays listed even without Runs.
75    pub selected: Option<Scope>,
76}
77impl Catalog {
78    /// Rediscover Git metadata without losing the selected scope or worktree folds.
79    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        // Initial selection only: a known non-Git workspace can contain cwd. Choose the
111        // closest ancestor by components, never a string prefix or inferred Git grouping.
112        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    /// Old-path discovery stays cached between resets, including database-only refreshes.
132    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            // The repository row is the whole repository; there is no separate `ALL` row.
204            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    /// Navigation/fold changes reuse the last accepted database facts, not a catalog query.
268    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    /// Show or hide a repository's worktrees without Runs; applied on the next update.
274    pub fn toggle(&mut self, repository: &Repository) {
275        if !self.expanded.remove(repository) {
276            self.expanded.insert(repository.clone());
277        }
278    }
279    /// The name the breadcrumb shows for `scope`: its row's label, else its folder name.
280    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}