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    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    /// Status glyphs and counts in the urgency order of the header, Runs and the tree:
41    /// `!1 โœ—2 โ—1 ?1`.
42    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    /// A row standing for this repository's worktrees without Runs; Space shows or hides them.
63    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    /// Repositories whose worktrees without Runs are shown.
74    expanded: BTreeSet<Repository>,
75    /// The selected scope stays listed even without Runs.
76    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        // Initial selection only: a known non-Git workspace can contain cwd. Choose the
107        // closest ancestor by components, never a string prefix or inferred Git grouping.
108        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    /// Cached old-path discovery runs once per workspace, not once per refresh.
128    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            // The repository row is the whole repository; there is no separate `ALL` row.
200            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    /// Navigation/fold changes reuse the last accepted database facts, not a catalog query.
264    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    /// Show or hide a repository's worktrees without Runs; applied on the next update.
270    pub fn toggle(&mut self, repository: &Repository) {
271        if !self.expanded.remove(repository) {
272            self.expanded.insert(repository.clone());
273        }
274    }
275    /// The name the breadcrumb shows for `scope`: its row's label, else its folder name.
276    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}