use crate::worktree::Worktree;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub name: String,
pub path: String,
pub worktrees: Vec<Worktree>,
pub open: bool,
pub registered: bool,
pub is_global: bool,
}
impl Repo {
pub fn store(&self, worktree: Option<&Worktree>) -> String {
if self.is_global {
return self.path.clone();
}
store_dir(worktree.map_or(self.path.as_str(), |w| w.path.as_str()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Row {
Heading(&'static str),
Hint(&'static str),
Repo { index: usize },
Worktree { repo: usize, index: usize },
}
impl Row {
pub fn selectable(&self) -> bool {
matches!(self, Row::Repo { .. } | Row::Worktree { .. })
}
}
pub fn rows(repos: &[Repo], here: Option<usize>) -> Vec<Row> {
let here = here.filter(|i| !repos[*i].registered);
let saved: Vec<usize> = (0..repos.len()).filter(|i| repos[*i].registered).collect();
let mut out = Vec::new();
if here.is_none() && saved.is_empty() {
return out;
}
let push_repo = |out: &mut Vec<Row>, i: usize| {
out.push(Row::Repo { index: i });
if repos[i].open {
for (j, _) in repos[i].worktrees.iter().enumerate() {
out.push(Row::Worktree { repo: i, index: j });
}
}
};
if let Some(i) = here {
out.push(Row::Heading("here"));
push_repo(&mut out, i);
}
out.push(Row::Heading("saved"));
if saved.is_empty() {
out.push(Row::Hint("nothing saved yet"));
}
for i in saved {
push_repo(&mut out, i);
}
out
}
pub fn store_dir(worktree_path: &str) -> String {
format!("{}/.dex", worktree_path.trim_end_matches('/'))
}
pub fn has_store(worktree_path: &str) -> bool {
std::path::Path::new(&store_dir(worktree_path)).is_dir()
}
#[cfg(test)]
mod tests {
use super::*;
fn wt(path: &str, branch: &str, main: bool) -> Worktree {
Worktree {
path: path.to_string(),
branch: branch.to_string(),
is_main: main,
is_locked: false,
is_detached: false,
}
}
fn repo(name: &str, open: bool) -> Repo {
Repo {
name: name.to_string(),
path: format!("/x/{name}"),
worktrees: vec![
wt(&format!("/x/{name}"), "main", true),
wt(&format!("/x/{name}-feat"), "feat", false),
],
open,
registered: true,
is_global: false,
}
}
#[test]
fn here_and_saved_are_labelled_when_both_have_something_in_them() {
let mut rs = vec![repo("a", true), repo("b", true)];
rs[1].registered = false;
let r = rows(&rs, Some(1));
assert_eq!(r[0], Row::Heading("here"));
assert_eq!(r[1], Row::Repo { index: 1 }, "the current repo leads");
assert_eq!(r[4], Row::Heading("saved"));
assert_eq!(r[5], Row::Repo { index: 0 });
}
#[test]
fn saving_the_repo_you_are_in_moves_it_from_here_to_saved() {
let mut rs = vec![repo("a", true)];
rs[0].registered = false;
let before = rows(&rs, Some(0));
assert_eq!(before[0], Row::Heading("here"));
assert_eq!(before[1], Row::Repo { index: 0 });
assert!(
before.contains(&Row::Hint("nothing saved yet")),
"the destination has to be on screen before the press: {before:?}"
);
rs[0].registered = true;
let after = rows(&rs, Some(0));
assert!(
!after.contains(&Row::Heading("here")),
"`here` should have emptied and gone: {after:?}"
);
assert_eq!(after[0], Row::Heading("saved"));
assert_eq!(after[1], Row::Repo { index: 0 });
assert_ne!(before, after, "registering must change what is drawn");
}
#[test]
fn saved_keeps_its_heading_alone_but_here_does_not_appear_empty() {
let rs = vec![repo("a", true)];
let r = rows(&rs, Some(0));
assert_eq!(r[0], Row::Heading("saved"));
assert!(!r.contains(&Row::Heading("here")));
}
#[test]
fn an_empty_sidebar_draws_nothing_at_all() {
assert_eq!(rows(&[], None), vec![]);
let mut rs = vec![repo("a", true)];
rs[0].registered = false;
assert_eq!(rows(&rs, None), vec![], "an unsaved repo you are not in");
}
#[test]
fn a_repo_that_is_both_current_and_saved_is_not_listed_twice() {
let rs = vec![repo("a", true), repo("b", true)];
let r = rows(&rs, Some(0));
let firsts: Vec<_> = r.iter().filter(|x| matches!(x, Row::Repo { .. })).collect();
assert_eq!(firsts.len(), 2, "each repo exactly once: {r:?}");
}
#[test]
fn an_unsaved_repo_you_are_not_in_is_not_shown() {
let mut rs = vec![repo("a", true), repo("b", true)];
rs[1].registered = false;
let r = rows(&rs, Some(0));
assert!(
!r.contains(&Row::Repo { index: 1 }),
"an unsaved repo appeared without being current: {r:?}"
);
}
#[test]
fn an_open_repo_lists_its_worktrees_beneath_it() {
let rs = vec![repo("one", true)];
assert_eq!(
rows(&rs, None),
vec![
Row::Heading("saved"),
Row::Repo { index: 0 },
Row::Worktree { repo: 0, index: 0 },
Row::Worktree { repo: 0, index: 1 },
]
);
}
#[test]
fn a_closed_repo_hides_its_worktrees() {
let rs = vec![repo("one", false)];
assert_eq!(
rows(&rs, None),
vec![Row::Heading("saved"), Row::Repo { index: 0 }]
);
}
#[test]
fn repos_keep_their_order_and_do_not_interleave() {
let rs = vec![repo("a", true), repo("b", false), repo("c", true)];
let r = rows(&rs, None);
assert_eq!(r[1], Row::Repo { index: 0 });
assert_eq!(r[4], Row::Repo { index: 1 });
assert_eq!(r[5], Row::Repo { index: 2 });
}
#[test]
fn a_store_is_the_dex_directory_under_the_worktree() {
assert_eq!(store_dir("/x/one"), "/x/one/.dex");
assert_eq!(store_dir("/x/one/"), "/x/one/.dex");
}
#[test]
fn a_repos_rows_resolve_to_the_dex_directory_under_each_worktree() {
let r = repo("one", true);
assert_eq!(r.store(None), "/x/one/.dex");
assert_eq!(r.store(Some(&r.worktrees[1])), "/x/one-feat/.dex");
}
#[test]
fn the_global_store_is_its_own_path_not_a_dex_directory_beneath_it() {
let g = Repo {
name: "global".into(),
path: "/home/u/.config/dex/local".into(),
worktrees: vec![],
open: true,
registered: false,
is_global: true,
};
assert_eq!(g.store(None), "/home/u/.config/dex/local");
let r = rows(&[g], Some(0));
assert!(
!r.iter().any(|x| matches!(x, Row::Worktree { .. })),
"the global store has no worktrees: {r:?}"
);
}
}