mod current;
mod loader;
mod query;
mod starred;
mod waiting;
#[cfg(test)]
mod tests;
pub use current::{Current, CurrentArtifact, CurrentEdge, CurrentEval, Plan, scan};
pub use loader::{Input, Loader, Snapshot};
pub use query::{DEFAULT, Query, Sort, complete, show};
pub use starred::{star, starred};
pub use waiting::{Waiting, waiting};
use crate::{
repository::{self, Identity, Worktree},
store::CatalogRun,
types::{RunId, RunStatus, Timestamp},
};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct BranchName(String);
impl BranchName {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for BranchName {
type Error = String;
fn try_from(name: String) -> Result<Self, String> {
let refused = name.starts_with('-')
|| name.ends_with('.')
|| name == "@"
|| name.contains("..")
|| name.contains("@{")
|| name
.split('/')
.any(|part| part.is_empty() || part.starts_with('.') || part.ends_with(".lock"))
|| name.chars().any(|c| {
c.is_control() || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\')
});
if refused {
Err(format!("not a branch name: {name:?}"))
} else {
Ok(Self(name))
}
}
}
impl From<BranchName> for String {
fn from(name: BranchName) -> Self {
name.0
}
}
impl std::fmt::Display for BranchName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Head {
Branch(BranchName),
Detached(#[serde(with = "crate::platform::path_serde")] PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Place {
Git {
#[serde(with = "crate::platform::path_serde")]
common: PathBuf,
head: Head,
},
Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chip {
pub id: RunId,
pub status: RunStatus,
pub created_at: Timestamp,
pub completed_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
pub place: Place,
pub project: String,
pub name: String,
pub workspace: PathBuf,
pub runs: Vec<Chip>,
}
impl Branch {
pub fn running(&self) -> Option<&Chip> {
self.runs
.last()
.filter(|chip| chip.status == RunStatus::Running)
}
pub fn latest(&self) -> Option<Timestamp> {
self.runs.last().map(|chip| chip.created_at)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Label {
pub project: String,
pub branch: String,
}
pub type Labels = BTreeMap<RunId, Label>;
#[derive(Default)]
pub struct Discovery {
identities: BTreeMap<PathBuf, Identity>,
trees: BTreeMap<PathBuf, Vec<Worktree>>,
}
impl Discovery {
pub fn rescan(&mut self) {
self.trees.clear();
}
fn identify(&mut self, path: &Path) -> Identity {
self.identities
.entry(path.to_path_buf())
.or_insert_with(|| repository::identify(path))
.clone()
}
fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
self.trees
.entry(common.to_path_buf())
.or_insert_with(|| {
repository::worktrees(common)
.into_iter()
.map(|tree| Worktree {
path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
branch: tree.branch,
})
.collect()
})
.clone()
}
}
fn head(branch: Option<String>, tree: &Path) -> Head {
branch
.and_then(|name| BranchName::try_from(name).ok())
.map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
}
fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
match (&identity.common_dir, &identity.worktree_path) {
(Some(common), Some(tree)) => {
let head = head(identity.branch.clone(), tree);
let relative = repo
.strip_prefix(tree)
.map(Path::to_path_buf)
.unwrap_or_default();
(
Place::Git {
common: common.clone(),
head,
},
relative,
)
}
_ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
}
}
fn folder_name(path: &Path) -> String {
path.file_name().map_or_else(
|| crate::platform::path_text(path),
|name| name.to_string_lossy().into_owned(),
)
}
fn project_name(place: &Place) -> String {
match place {
Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
folder_name(common.parent().unwrap_or(common))
}
Place::Git { common, .. } => {
let name = folder_name(common);
name.strip_suffix(".git")
.map_or(name.clone(), str::to_owned)
}
Place::Folder(path) => folder_name(path),
}
}
fn branch_name(place: &Place) -> String {
match place {
Place::Git {
head: Head::Branch(branch),
..
} => branch.to_string(),
Place::Git {
head: Head::Detached(tree),
..
} => folder_name(tree),
Place::Folder(_) => String::new(),
}
}
fn label(place: &Place) -> Label {
Label {
project: project_name(place),
branch: branch_name(place),
}
}
pub struct Listing {
pub branches: Vec<Branch>,
pub labels: Labels,
pub launch: Option<Place>,
}
type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
let mut labels = Labels::new();
for run in runs {
let identity =
if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
run.repository.clone()
} else {
discovery.identify(&run.repo_path)
};
let (place, relative) = place_of(&identity, &run.repo_path);
labels.insert(run.id.clone(), label(&place));
let (chips, newest) = grouped.entry(place).or_default();
chips.push(Chip {
id: run.id.clone(),
status: run.status,
created_at: run.created_at,
completed_at: run.completed_at,
});
if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
*newest = Some((run.created_at, relative));
}
}
let launched = launch.map(|path| {
let (place, _) = place_of(&discovery.identify(path), path);
(place, path.to_path_buf())
});
let commons: BTreeSet<PathBuf> = grouped
.keys()
.chain(launched.iter().map(|(place, _)| place))
.filter_map(|place| match place {
Place::Git { common, .. } => Some(common.clone()),
Place::Folder(_) => None,
})
.collect();
let mut live: Vec<(Place, PathBuf)> = commons
.into_iter()
.flat_map(|common| {
discovery.worktrees(&common).into_iter().map(move |tree| {
let head = head(tree.branch, &tree.path);
(
Place::Git {
common: common.clone(),
head,
},
tree.path,
)
})
})
.collect();
let folders: BTreeSet<&PathBuf> = grouped
.keys()
.chain(launched.iter().map(|(place, _)| place))
.filter_map(|place| match place {
Place::Folder(path) => Some(path),
Place::Git { .. } => None,
})
.collect();
let started = |path: &PathBuf| {
launched
.as_ref()
.is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
};
live.extend(
folders
.into_iter()
.filter(|path| {
path.is_dir()
&& (started(path)
|| crate::workspace::has_artifact_marker(path).unwrap_or(false))
})
.map(|path| (Place::Folder(path.clone()), path.clone())),
);
let mut branches: Vec<Branch> = live
.into_iter()
.map(|(place, tree)| {
let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
let workspace = match &launched {
Some((launch, path)) if *launch == place => path.clone(),
_ => newest
.map(|(_, relative)| tree.join(relative))
.filter(|path| path.is_dir())
.unwrap_or(tree),
};
let Label { project, branch } = label(&place);
Branch {
place,
project,
name: branch,
workspace,
runs,
}
})
.collect();
query::order(&mut branches, Sort::Recent);
let launch = launched
.map(|(place, _)| place)
.filter(|place| branches.iter().any(|branch| &branch.place == place));
Listing {
branches,
labels,
launch,
}
}