1mod current;
7mod loader;
8mod query;
9mod starred;
10mod waiting;
11
12#[cfg(test)]
13mod tests;
14
15pub use current::{Current, CurrentArtifact, CurrentEdge, CurrentEval, Plan, scan};
16pub use loader::{Input, Loader, Opened, Snapshot};
17pub use query::{DEFAULT, Query, Sort, complete, show};
18pub use starred::{star, starred};
19pub use waiting::{Waiting, waiting};
20
21use crate::{
22 repository::{self, Identity, Worktree},
23 store::CatalogRun,
24 types::{RunId, RunStatus, Timestamp},
25};
26use serde::{Deserialize, Serialize};
27use std::{
28 collections::{BTreeMap, BTreeSet},
29 path::{Path, PathBuf},
30};
31
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(try_from = "String", into = "String")]
37pub struct BranchName(String);
38
39impl BranchName {
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl TryFrom<String> for BranchName {
46 type Error = String;
47
48 fn try_from(name: String) -> Result<Self, String> {
49 let refused = name.starts_with('-')
52 || name.ends_with('.')
53 || name == "@"
54 || name.contains("..")
55 || name.contains("@{")
56 || name
57 .split('/')
58 .any(|part| part.is_empty() || part.starts_with('.') || part.ends_with(".lock"))
59 || name.chars().any(|c| {
60 c.is_control() || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\')
61 });
62 if refused {
63 Err(format!("not a branch name: {name:?}"))
64 } else {
65 Ok(Self(name))
66 }
67 }
68}
69
70impl From<BranchName> for String {
71 fn from(name: BranchName) -> Self {
72 name.0
73 }
74}
75
76impl std::fmt::Display for BranchName {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str(&self.0)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub enum Head {
86 Branch(BranchName),
87 Detached(#[serde(with = "crate::platform::path_serde")] PathBuf),
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub enum Place {
96 Git {
97 #[serde(with = "crate::platform::path_serde")]
98 common: PathBuf,
99 head: Head,
100 },
101 Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Chip {
107 pub id: RunId,
108 pub status: RunStatus,
109 pub created_at: Timestamp,
110 pub completed_at: Option<Timestamp>,
111 pub comment: Option<crate::types::RunComment>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Branch {
118 pub place: Place,
119 pub project: String,
121 pub name: String,
123 pub workspace: PathBuf,
126 pub runs: Vec<Chip>,
128}
129
130impl Branch {
131 pub fn running(&self) -> Option<&Chip> {
133 self.runs.last().filter(|chip| chip.status.is_open())
134 }
135
136 pub fn latest(&self) -> Option<Timestamp> {
138 self.runs.last().map(|chip| chip.created_at)
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct Label {
146 pub project: String,
147 pub branch: String,
148}
149
150pub type Labels = BTreeMap<RunId, Label>;
151
152#[derive(Default)]
155pub struct Discovery {
156 identities: BTreeMap<PathBuf, Identity>,
157 trees: BTreeMap<PathBuf, Vec<Worktree>>,
158}
159
160impl Discovery {
161 pub fn rescan(&mut self) {
163 self.trees.clear();
164 }
165
166 fn identify(&mut self, path: &Path) -> Identity {
167 self.identities
168 .entry(path.to_path_buf())
169 .or_insert_with(|| repository::identify(path))
170 .clone()
171 }
172
173 fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
174 self.trees
175 .entry(common.to_path_buf())
176 .or_insert_with(|| {
177 repository::worktrees(common)
178 .into_iter()
179 .map(|tree| Worktree {
180 path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
182 branch: tree.branch,
183 })
184 .collect()
185 })
186 .clone()
187 }
188}
189
190fn head(branch: Option<String>, tree: &Path) -> Head {
194 branch
195 .and_then(|name| BranchName::try_from(name).ok())
196 .map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
197}
198
199fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
200 match (&identity.common_dir, &identity.worktree_path) {
201 (Some(common), Some(tree)) => {
202 let head = head(identity.branch.clone(), tree);
203 let relative = repo
204 .strip_prefix(tree)
205 .map(Path::to_path_buf)
206 .unwrap_or_default();
207 (
208 Place::Git {
209 common: common.clone(),
210 head,
211 },
212 relative,
213 )
214 }
215 _ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
216 }
217}
218
219fn folder_name(path: &Path) -> String {
220 path.file_name().map_or_else(
221 || crate::platform::path_text(path),
222 |name| name.to_string_lossy().into_owned(),
223 )
224}
225
226fn project_name(place: &Place) -> String {
229 match place {
230 Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
231 folder_name(common.parent().unwrap_or(common))
232 }
233 Place::Git { common, .. } => {
234 let name = folder_name(common);
235 name.strip_suffix(".git")
236 .map_or(name.clone(), str::to_owned)
237 }
238 Place::Folder(path) => folder_name(path),
239 }
240}
241
242fn branch_name(place: &Place) -> String {
243 match place {
244 Place::Git {
245 head: Head::Branch(branch),
246 ..
247 } => branch.to_string(),
248 Place::Git {
249 head: Head::Detached(tree),
250 ..
251 } => folder_name(tree),
252 Place::Folder(_) => String::new(),
253 }
254}
255
256fn label(place: &Place) -> Label {
257 Label {
258 project: project_name(place),
259 branch: branch_name(place),
260 }
261}
262
263pub struct Listing {
265 pub branches: Vec<Branch>,
267 pub labels: Labels,
268 pub launch: Option<Place>,
270}
271
272type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
274
275pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
278 let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
279 let mut labels = Labels::new();
280 for run in runs {
281 let identity =
282 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
283 run.repository.clone()
284 } else {
285 discovery.identify(&run.repo_path)
286 };
287 let (place, relative) = place_of(&identity, &run.repo_path);
288 labels.insert(run.id.clone(), label(&place));
289 let (chips, newest) = grouped.entry(place).or_default();
290 chips.push(Chip {
291 id: run.id.clone(),
292 status: run.status,
293 created_at: run.created_at,
294 completed_at: run.completed_at,
295 comment: run.comment.clone(),
296 });
297 if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
298 *newest = Some((run.created_at, relative));
299 }
300 }
301 let launched = launch.map(|path| {
302 let (place, _) = place_of(&discovery.identify(path), path);
303 (place, path.to_path_buf())
304 });
305 let commons: BTreeSet<PathBuf> = grouped
306 .keys()
307 .chain(launched.iter().map(|(place, _)| place))
308 .filter_map(|place| match place {
309 Place::Git { common, .. } => Some(common.clone()),
310 Place::Folder(_) => None,
311 })
312 .collect();
313 let mut live: Vec<(Place, PathBuf)> = commons
314 .into_iter()
315 .flat_map(|common| {
316 discovery.worktrees(&common).into_iter().map(move |tree| {
317 let head = head(tree.branch, &tree.path);
318 (
319 Place::Git {
320 common: common.clone(),
321 head,
322 },
323 tree.path,
324 )
325 })
326 })
327 .collect();
328 let folders: BTreeSet<&PathBuf> = grouped
329 .keys()
330 .chain(launched.iter().map(|(place, _)| place))
331 .filter_map(|place| match place {
332 Place::Folder(path) => Some(path),
333 Place::Git { .. } => None,
334 })
335 .collect();
336 let started = |path: &PathBuf| {
340 launched
341 .as_ref()
342 .is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
343 };
344 live.extend(
345 folders
346 .into_iter()
347 .filter(|path| {
348 path.is_dir()
349 && (started(path)
350 || crate::workspace::has_artifact_marker(path).unwrap_or(false))
351 })
352 .map(|path| (Place::Folder(path.clone()), path.clone())),
353 );
354 let mut branches: Vec<Branch> = live
355 .into_iter()
356 .map(|(place, tree)| {
357 let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
358 runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
359 let workspace = match &launched {
360 Some((launch, path)) if *launch == place => path.clone(),
361 _ => newest
362 .map(|(_, relative)| tree.join(relative))
363 .filter(|path| path.is_dir())
364 .unwrap_or(tree),
365 };
366 let Label { project, branch } = label(&place);
367 Branch {
368 place,
369 project,
370 name: branch,
371 workspace,
372 runs,
373 }
374 })
375 .collect();
376 query::order(&mut branches, Sort::Recent);
377 let launch = launched
378 .map(|(place, _)| place)
379 .filter(|place| branches.iter().any(|branch| &branch.place == place));
380 Listing {
381 branches,
382 labels,
383 launch,
384 }
385}