use crate::{
agent::AgentStatus,
config::{
AgentConfig, AgentLabelsConfig,
keys::{Command, FlattenedKeybindingRow},
},
constants::{WORKTREE_DIR_DEDUP_MAX_ATTEMPTS, WORKTREE_DIR_NAME, WORKTREE_NAME_SEPARATOR},
git::Repo,
pending_delete::PendingWorktreeDelete,
};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone)]
pub struct TextInput {
pub text: String,
pub cursor: usize,
}
#[derive(Clone, Copy)]
struct GraphemeSpan {
start: usize,
end: usize,
is_whitespace: bool,
}
impl TextInput {
pub fn new() -> Self {
Self {
text: String::new(),
cursor: 0,
}
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
fn grapheme_spans(&self) -> Vec<GraphemeSpan> {
self.text
.grapheme_indices(true)
.map(|(start, grapheme)| GraphemeSpan {
start,
end: start + grapheme.len(),
is_whitespace: grapheme.chars().all(char::is_whitespace),
})
.collect()
}
fn grapheme_boundaries(&self) -> Vec<usize> {
let mut boundaries: Vec<usize> = self.text.grapheme_indices(true).map(|(i, _)| i).collect();
boundaries.push(self.text.len());
boundaries
}
fn boundaries_from_spans(spans: &[GraphemeSpan], text_len: usize) -> Vec<usize> {
let mut boundaries = Vec::with_capacity(spans.len().saturating_add(1));
for span in spans {
boundaries.push(span.start);
}
boundaries.push(text_len);
boundaries
}
fn boundary_index_at_or_before(boundaries: &[usize], cursor: usize) -> usize {
match boundaries.binary_search(&cursor) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
}
}
fn clamp_cursor_to_boundary(&mut self, boundaries: &[usize]) -> usize {
let cursor = self.cursor.min(self.text.len());
let idx = Self::boundary_index_at_or_before(boundaries, cursor);
self.cursor = boundaries.get(idx).copied().unwrap_or(0);
idx
}
pub(crate) fn prev_word_boundary(&self, from: usize) -> usize {
let spans = self.grapheme_spans();
if spans.is_empty() {
return 0;
}
let boundaries = Self::boundaries_from_spans(&spans, self.text.len());
let cursor = from.min(self.text.len());
let mut grapheme_idx =
Self::boundary_index_at_or_before(&boundaries, cursor).saturating_sub(1);
while let Some(span) = spans.get(grapheme_idx) {
if !span.is_whitespace {
break;
}
if grapheme_idx == 0 {
return 0;
}
grapheme_idx -= 1;
}
while let Some(span) = spans.get(grapheme_idx) {
if span.is_whitespace {
return span.end;
}
if grapheme_idx == 0 {
return 0;
}
grapheme_idx -= 1;
}
0
}
pub(crate) fn next_word_boundary(&self, from: usize) -> usize {
let spans = self.grapheme_spans();
if spans.is_empty() {
return 0;
}
let boundaries = Self::boundaries_from_spans(&spans, self.text.len());
let cursor = from.min(self.text.len());
let mut grapheme_idx = Self::boundary_index_at_or_before(&boundaries, cursor);
while let Some(span) = spans.get(grapheme_idx) {
if !span.is_whitespace {
break;
}
grapheme_idx += 1;
}
while let Some(span) = spans.get(grapheme_idx) {
if span.is_whitespace {
return span.start;
}
grapheme_idx += 1;
}
self.text.len()
}
pub fn cursor_left(&mut self) {
let boundaries = self.grapheme_boundaries();
let idx = self.clamp_cursor_to_boundary(&boundaries);
if idx > 0 {
self.cursor = boundaries[idx - 1];
}
}
pub fn cursor_right(&mut self) {
let boundaries = self.grapheme_boundaries();
let idx = self.clamp_cursor_to_boundary(&boundaries);
if idx + 1 < boundaries.len() {
self.cursor = boundaries[idx + 1];
}
}
pub fn cursor_start(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.text.len();
}
pub fn cursor_word_left(&mut self) {
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
self.cursor = self.prev_word_boundary(self.cursor);
}
pub fn cursor_word_right(&mut self) {
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
self.cursor = self.next_word_boundary(self.cursor);
}
pub fn insert_char(&mut self, c: char) {
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
self.text.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn backspace(&mut self) -> bool {
let boundaries = self.grapheme_boundaries();
let idx = self.clamp_cursor_to_boundary(&boundaries);
if idx == 0 {
return false;
}
let prev = boundaries[idx - 1];
self.text.drain(prev..self.cursor);
self.cursor = prev;
true
}
pub fn delete_forward_char(&mut self) -> bool {
let boundaries = self.grapheme_boundaries();
let idx = self.clamp_cursor_to_boundary(&boundaries);
if idx + 1 >= boundaries.len() {
return false;
}
let end = boundaries[idx + 1];
self.text.drain(self.cursor..end);
true
}
pub fn delete_word(&mut self) {
if self.text.is_empty() || self.cursor == 0 {
return;
}
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
let new_cursor = self.prev_word_boundary(self.cursor);
self.text.drain(new_cursor..self.cursor);
self.cursor = new_cursor;
}
pub fn delete_word_forward(&mut self) {
if self.text.is_empty() || self.cursor >= self.text.len() {
return;
}
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
let end = self.next_word_boundary(self.cursor);
self.text.drain(self.cursor..end);
}
pub fn delete_to_start(&mut self) {
if self.cursor == 0 {
return;
}
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
self.text.drain(..self.cursor);
self.cursor = 0;
}
pub fn delete_to_end(&mut self) {
if self.cursor >= self.text.len() {
return;
}
let boundaries = self.grapheme_boundaries();
self.clamp_cursor_to_boundary(&boundaries);
self.text.truncate(self.cursor);
}
}
impl Default for TextInput {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SearchableList {
pub input: TextInput,
pub filtered: Vec<(usize, i64)>,
pub selected: Option<usize>,
pub scroll_offset: usize,
}
impl SearchableList {
pub fn new(item_count: usize) -> Self {
Self {
input: TextInput::new(),
filtered: (0..item_count).map(|i| (i, 0)).collect(),
selected: if item_count > 0 { Some(0) } else { None },
scroll_offset: 0,
}
}
pub fn reset(&mut self, item_count: usize) {
self.input.clear();
self.filtered = (0..item_count).map(|i| (i, 0)).collect();
self.selected = if item_count > 0 { Some(0) } else { None };
self.scroll_offset = 0;
}
pub fn search(&self) -> &str {
&self.input.text
}
pub fn cursor(&self) -> usize {
self.input.cursor
}
pub fn move_selection(&mut self, delta: i32) {
let len = self.filtered.len();
if len == 0 {
return;
}
let current = self.selected.unwrap_or(0);
if delta > 0 {
self.selected = Some(
current
.saturating_add(delta.unsigned_abs() as usize)
.min(len - 1),
);
} else {
self.selected = Some(current.saturating_sub(delta.unsigned_abs() as usize));
}
}
pub fn move_to_top(&mut self) {
if !self.filtered.is_empty() {
self.selected = Some(0);
}
}
pub fn move_to_bottom(&mut self) {
if !self.filtered.is_empty() {
self.selected = Some(self.filtered.len() - 1);
}
}
pub fn update_scroll_offset_for_selection(&mut self, viewport_rows: usize) {
let len = self.filtered.len();
if len == 0 {
self.scroll_offset = 0;
return;
}
let viewport_rows = viewport_rows.max(1);
let max_offset = len.saturating_sub(viewport_rows);
let selected = self.selected.unwrap_or(0).min(len - 1);
let anchor_top = usize::from(viewport_rows > 2);
let anchor_bottom = viewport_rows.saturating_sub(2);
let top_bound = self.scroll_offset.saturating_add(anchor_top);
let bottom_bound = self.scroll_offset.saturating_add(anchor_bottom);
if selected < top_bound {
self.scroll_offset = selected.saturating_sub(anchor_top);
} else if selected > bottom_bound {
self.scroll_offset = selected.saturating_sub(anchor_bottom);
}
self.scroll_offset = self.scroll_offset.min(max_offset);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct BranchEntry {
pub name: String,
pub worktree_path: Option<PathBuf>,
pub has_session: bool,
pub is_current: bool,
pub is_default: bool,
pub remote: Option<String>,
pub session_activity_ts: Option<u64>,
pub agent_status: Option<AgentStatus>,
}
impl BranchEntry {
pub fn build(
repo: &crate::git::Repo,
branch_names: &[String],
active_sessions: &[String],
) -> Vec<Self> {
Self::build_entries(
repo,
branch_names,
active_sessions,
None,
&HashMap::new(),
None,
)
}
pub fn build_sorted(
repo: &crate::git::Repo,
branch_names: &[String],
active_sessions: &[String],
) -> Vec<Self> {
let mut entries = Self::build(repo, branch_names, active_sessions);
Self::sort_entries(&mut entries);
entries
}
pub fn build_sorted_with_activity(
repo: &crate::git::Repo,
branch_names: &[String],
active_sessions: &[String],
default_branch: Option<&str>,
session_activity: &HashMap<String, u64>,
cwd: Option<&Path>,
) -> Vec<Self> {
let mut entries = Self::build_entries(
repo,
branch_names,
active_sessions,
default_branch,
session_activity,
cwd,
);
Self::sort_entries(&mut entries);
entries
}
fn build_entries(
repo: &crate::git::Repo,
branch_names: &[String],
active_sessions: &[String],
default_branch: Option<&str>,
session_activity: &HashMap<String, u64>,
cwd: Option<&Path>,
) -> Vec<Self> {
let wt_by_branch: HashMap<&str, &crate::git::Worktree> = repo
.worktrees
.iter()
.filter_map(|wt| wt.branch.as_deref().map(|b| (b, wt)))
.collect();
let current_branch = cwd
.and_then(|p| repo.worktrees.iter().find(|wt| wt.path == p))
.or_else(|| repo.worktrees.first())
.and_then(|wt| wt.branch.as_deref());
branch_names
.iter()
.map(|name| {
let worktree_path = wt_by_branch.get(name.as_str()).map(|wt| wt.path.clone());
let session_name = worktree_path.as_ref().map(|p| repo.tmux_session_name(p));
let has_session = session_name
.as_ref()
.is_some_and(|sn| active_sessions.contains(sn));
let is_current = current_branch == Some(name.as_str());
let is_default = default_branch == Some(name.as_str());
let session_activity_ts = session_name
.as_ref()
.and_then(|sn| session_activity.get(sn).copied());
Self {
name: name.clone(),
worktree_path,
has_session,
is_current,
is_default,
remote: None,
session_activity_ts,
agent_status: None,
}
})
.collect()
}
pub fn build_remote(
remote: &str,
remote_names: &[String],
local_names: &[String],
) -> Vec<Self> {
let local_set: std::collections::HashSet<&str> =
local_names.iter().map(String::as_str).collect();
remote_names
.iter()
.filter(|name| !local_set.contains(name.as_str()))
.map(|name| Self {
name: name.clone(),
worktree_path: None,
has_session: false,
is_current: false,
is_default: false,
remote: Some(remote.to_string()),
session_activity_ts: None,
agent_status: None,
})
.collect()
}
pub fn sort_entries(entries: &mut [Self]) {
entries.sort_by(|a, b| {
a.remote
.is_some()
.cmp(&b.remote.is_some())
.then(b.is_current.cmp(&a.is_current))
.then(b.is_default.cmp(&a.is_default))
.then(cmp_optional_recency(
a.session_activity_ts,
b.session_activity_ts,
))
.then(b.has_session.cmp(&a.has_session))
.then(agent_sort_priority(b.agent_status).cmp(&agent_sort_priority(a.agent_status)))
.then(b.worktree_path.is_some().cmp(&a.worktree_path.is_some()))
.then(a.name.cmp(&b.name))
});
}
}
fn agent_sort_priority(status: Option<crate::agent::AgentStatus>) -> u8 {
match status {
Some(s) => match s.state {
crate::AgentState::Waiting => 3,
crate::AgentState::Running => 2,
crate::AgentState::Idle | crate::AgentState::Unknown => 1,
},
None => 0,
}
}
fn cmp_optional_recency(a: Option<u64>, b: Option<u64>) -> std::cmp::Ordering {
match (a, b) {
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts),
(None, None) => std::cmp::Ordering::Equal,
}
}
#[allow(clippy::implicit_hasher)]
pub fn sort_repos(
repos: &mut [Repo],
current_repo_path: Option<&Path>,
session_activity: &HashMap<String, u64>,
) {
let current_repo_path = current_repo_path
.and_then(|path| std::fs::canonicalize(path).ok())
.or_else(|| current_repo_path.map(ToOwned::to_owned));
let mut canonical_by_path = HashMap::with_capacity(repos.len());
for repo in repos.iter() {
let canonical = std::fs::canonicalize(&repo.path).unwrap_or_else(|_| repo.path.clone());
canonical_by_path.insert(repo.path.clone(), canonical);
}
repos.sort_by(|a, b| {
let a_path = canonical_by_path.get(&a.path).unwrap_or(&a.path);
let b_path = canonical_by_path.get(&b.path).unwrap_or(&b.path);
let a_is_current = current_repo_path.as_ref().is_some_and(|p| a_path == p);
let b_is_current = current_repo_path.as_ref().is_some_and(|p| b_path == p);
b_is_current
.cmp(&a_is_current)
.then_with(|| {
let a_activity = repo_max_activity(a, session_activity);
let b_activity = repo_max_activity(b, session_activity);
cmp_optional_recency(a_activity, b_activity)
})
.then_with(|| a.name.cmp(&b.name))
});
}
fn repo_max_activity(repo: &Repo, session_activity: &HashMap<String, u64>) -> Option<u64> {
let main_session = std::iter::once(repo.tmux_session_name(&repo.path));
let wt_sessions = repo
.worktrees
.iter()
.map(|wt| repo.tmux_session_name(&wt.path));
main_session
.chain(wt_sessions)
.filter_map(|name| session_activity.get(&name).copied())
.max()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetupStep {
Welcome,
SearchDirs,
}
#[derive(Debug, Clone)]
pub struct SetupState {
pub input: TextInput,
pub completions: Vec<String>,
pub selected_completion: Option<usize>,
pub dirs: Vec<String>,
}
impl SetupState {
pub fn new() -> Self {
Self {
input: TextInput::new(),
completions: Vec::new(),
selected_completion: None,
dirs: Vec::new(),
}
}
}
impl Default for SetupState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
RepoSelect,
BranchSelect,
SelectBaseBranch,
Loading(String),
ConfirmWorktreeDelete {
branch_name: String,
has_session: bool,
},
Help {
previous: Box<Mode>,
},
Setup(SetupStep),
}
impl Mode {
pub fn effective(&self) -> &Mode {
match self {
Mode::Help { previous } => previous.effective(),
other => other,
}
}
pub fn footer_commands(&self) -> &'static [Command] {
match self {
Mode::RepoSelect => &[
Command::OpenRepo,
Command::EnterRepo,
Command::ShowHelp,
Command::Quit,
],
Mode::BranchSelect => &[
Command::GoBack,
Command::OpenBranch,
Command::DeleteWorktree,
Command::ShowHelp,
Command::Quit,
],
Mode::SelectBaseBranch => &[
Command::Cancel,
Command::Confirm,
Command::ShowHelp,
Command::Quit,
],
Mode::ConfirmWorktreeDelete { .. } => &[
Command::Confirm,
Command::Cancel,
Command::ShowHelp,
Command::Quit,
],
Mode::Setup(_) | Mode::Loading(_) | Mode::Help { .. } => &[],
}
}
pub fn supports_text_edit(&self) -> bool {
matches!(
self,
Mode::RepoSelect
| Mode::BranchSelect
| Mode::SelectBaseBranch
| Mode::Help { .. }
| Mode::Setup(SetupStep::SearchDirs)
)
}
pub(crate) fn supports_list_navigation(&self) -> bool {
matches!(
self,
Mode::RepoSelect
| Mode::BranchSelect
| Mode::SelectBaseBranch
| Mode::Help { .. }
| Mode::Setup(SetupStep::SearchDirs)
)
}
pub(crate) fn supports_modal_actions(&self) -> bool {
matches!(
self,
Mode::SelectBaseBranch | Mode::ConfirmWorktreeDelete { .. } | Mode::Setup(_)
)
}
pub(crate) fn supports_repo_select_actions(&self) -> bool {
matches!(self, Mode::RepoSelect)
}
pub(crate) fn supports_branch_select_actions(&self) -> bool {
matches!(self, Mode::BranchSelect)
}
}
#[derive(Debug, Clone)]
pub struct BaseBranchSelection {
pub new_name: String,
pub bases: Vec<String>,
pub list: SearchableList,
}
#[derive(Debug, Clone)]
pub struct HelpOverlayState {
pub list: SearchableList,
pub rows: Vec<FlattenedKeybindingRow>,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct AppState {
pub repos: Vec<Repo>,
pub repo_list: SearchableList,
pub loading_repos: bool,
pub selected_repo_idx: Option<usize>,
pub branches: Vec<BranchEntry>,
pub branch_list: SearchableList,
pub base_branch_selection: Option<BaseBranchSelection>,
pub help_overlay: Option<HelpOverlayState>,
pub setup: Option<SetupState>,
pub split_command: Option<String>,
pub mode: Mode,
pub loading_branches: bool,
pub fetching_remotes: bool,
pub error: Option<String>,
active_list_page_rows: usize,
pub pending_worktree_deletes: Vec<PendingWorktreeDelete>,
pub session_activity: HashMap<String, u64>,
pub agent_poller_cancel: Option<Arc<AtomicBool>>,
pub agent_enabled: bool,
pub agent_poll_interval: std::time::Duration,
pub agent_labels: AgentLabelsConfig,
pub current_repo_path: Option<PathBuf>,
pub cwd_worktree_path: Option<PathBuf>,
pub seen_repo_paths: HashSet<PathBuf>,
}
impl AppState {
fn base(mode: Mode) -> Self {
Self {
repos: Vec::new(),
repo_list: SearchableList::new(0),
loading_repos: false,
selected_repo_idx: None,
branches: Vec::new(),
branch_list: SearchableList::new(0),
base_branch_selection: None,
help_overlay: None,
setup: None,
split_command: None,
mode,
loading_branches: false,
fetching_remotes: false,
error: None,
active_list_page_rows: 10,
pending_worktree_deletes: Vec::new(),
session_activity: HashMap::new(),
agent_poller_cancel: None,
agent_enabled: true,
agent_poll_interval: std::time::Duration::from_millis(
AgentConfig::default().poll_interval_ms,
),
agent_labels: AgentLabelsConfig::default(),
current_repo_path: None,
cwd_worktree_path: None,
seen_repo_paths: HashSet::new(),
}
}
pub fn new(repos: Vec<Repo>, split_command: Option<String>) -> Self {
let repo_list = SearchableList::new(repos.len());
let seen_repo_paths: HashSet<PathBuf> = repos.iter().map(|r| r.path.clone()).collect();
Self {
repos,
repo_list,
split_command,
seen_repo_paths,
mode: Mode::RepoSelect,
..Self::base(Mode::RepoSelect)
}
}
pub fn new_loading(loading_message: &str, split_command: Option<String>) -> Self {
Self {
split_command,
..Self::base(Mode::Loading(loading_message.to_string()))
}
}
pub fn set_error(&mut self, msg: &str) {
self.error = Some(msg.split_whitespace().collect::<Vec<_>>().join(" "));
}
pub fn clear_error(&mut self) {
self.error = None;
}
pub fn new_setup() -> Self {
Self {
setup: Some(SetupState::new()),
..Self::base(Mode::Setup(SetupStep::Welcome))
}
}
pub fn cancel_agent_poller(&mut self) {
if let Some(token) = self.agent_poller_cancel.take() {
token.store(true, Ordering::Relaxed);
}
}
pub fn active_text_input(&mut self) -> Option<&mut TextInput> {
match self.mode {
Mode::Setup(SetupStep::SearchDirs) => self.setup.as_mut().map(|s| &mut s.input),
_ => self.active_list_mut().map(|list| &mut list.input),
}
}
pub fn active_list_mut(&mut self) -> Option<&mut SearchableList> {
match self.mode {
Mode::RepoSelect => Some(&mut self.repo_list),
Mode::BranchSelect => Some(&mut self.branch_list),
Mode::SelectBaseBranch => self.base_branch_selection.as_mut().map(|f| &mut f.list),
Mode::Help { .. } => self.active_help_list_mut(),
_ => None,
}
}
pub fn active_list(&self) -> Option<&SearchableList> {
match self.mode {
Mode::RepoSelect => Some(&self.repo_list),
Mode::BranchSelect => Some(&self.branch_list),
Mode::SelectBaseBranch => self.base_branch_selection.as_ref().map(|f| &f.list),
Mode::Help { .. } => self.active_help_list(),
_ => None,
}
}
pub fn active_help_list_mut(&mut self) -> Option<&mut SearchableList> {
self.help_overlay.as_mut().map(|overlay| &mut overlay.list)
}
pub fn active_help_list(&self) -> Option<&SearchableList> {
self.help_overlay.as_ref().map(|overlay| &overlay.list)
}
pub fn is_branch_pending_delete(&self, repo_path: &Path, branch_name: &str) -> bool {
self.pending_worktree_deletes
.iter()
.any(|pending| pending.repo_path == repo_path && pending.branch_name == branch_name)
}
pub fn set_active_list_page_rows(&mut self, rows: usize) {
self.active_list_page_rows = rows.max(1);
}
pub fn active_list_page_rows(&self) -> usize {
self.active_list_page_rows.max(1)
}
pub fn mark_pending_worktree_delete(&mut self, pending: PendingWorktreeDelete) {
self.pending_worktree_deletes.retain(|entry| {
!(entry.repo_path == pending.repo_path && entry.branch_name == pending.branch_name)
});
self.pending_worktree_deletes.push(pending);
}
pub fn clear_pending_worktree_delete_by_path(&mut self, worktree_path: &Path) -> bool {
let before = self.pending_worktree_deletes.len();
self.pending_worktree_deletes
.retain(|pending| pending.worktree_path != worktree_path);
before != self.pending_worktree_deletes.len()
}
pub fn clear_pending_worktree_delete_by_branch(
&mut self,
repo_path: &Path,
branch_name: &str,
) -> bool {
let before = self.pending_worktree_deletes.len();
self.pending_worktree_deletes.retain(|pending| {
!(pending.repo_path == repo_path && pending.branch_name == branch_name)
});
before != self.pending_worktree_deletes.len()
}
pub fn reconcile_pending_worktree_deletes(&mut self) -> bool {
let active_worktree_paths: HashSet<&Path> = self
.repos
.iter()
.flat_map(|repo| repo.worktrees.iter().map(|wt| wt.path.as_path()))
.collect();
let before = self.pending_worktree_deletes.len();
self.pending_worktree_deletes.retain(|pending| {
!pending.is_expired() && active_worktree_paths.contains(pending.worktree_path.as_path())
});
before != self.pending_worktree_deletes.len()
}
}
pub fn worktree_dir(repo: &Repo, branch: &str) -> anyhow::Result<PathBuf> {
let parent = repo.path.parent().unwrap_or(&repo.path);
let worktree_root = parent.join(WORKTREE_DIR_NAME);
let safe_branch = branch.replace('/', "-");
let base = format!("{}{WORKTREE_NAME_SEPARATOR}{safe_branch}", repo.name);
let candidate = worktree_root.join(&base);
if !candidate.exists() {
return Ok(candidate);
}
for i in 2..WORKTREE_DIR_DEDUP_MAX_ATTEMPTS {
let candidate = worktree_root.join(format!("{base}-{i}"));
if !candidate.exists() {
return Ok(candidate);
}
}
anyhow::bail!(
"Could not find an available worktree directory name after {WORKTREE_DIR_DEDUP_MAX_ATTEMPTS} attempts"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::{Repo, Worktree};
use crate::pending_delete::PendingWorktreeDelete;
use std::fs;
use tempfile::tempdir;
fn make_repo(dir: &std::path::Path, name: &str) -> Repo {
Repo {
name: name.to_string(),
session_name: name.to_string(),
path: dir.join(name),
worktrees: vec![],
}
}
#[test]
fn test_cursor_grapheme_combining_mark() {
let mut list = SearchableList::new(0);
list.input.text = "e\u{0301}".to_string();
list.input.cursor_end();
list.input.cursor_left();
assert_eq!(list.input.cursor, 0);
list.input.cursor_right();
assert_eq!(list.input.cursor, list.input.text.len());
list.input.cursor_end();
assert!(list.input.backspace());
assert_eq!(list.input.text, "");
assert_eq!(list.input.cursor, 0);
}
#[test]
fn test_cursor_grapheme_zwj_sequence() {
let emoji = "👩💻";
let mut list = SearchableList::new(0);
list.input.text = format!("{emoji}a");
list.input.cursor_start();
list.input.cursor_right();
assert_eq!(list.input.cursor, emoji.len());
list.input.cursor_right();
assert_eq!(list.input.cursor, list.input.text.len());
}
#[test]
fn test_cursor_clamps_inside_grapheme() {
let mut list = SearchableList::new(0);
list.input.text = "café".to_string();
list.input.cursor = 4;
list.input.cursor_left();
assert_eq!(list.input.cursor, 2);
list.input.cursor = 4;
list.input.cursor_right();
assert_eq!(list.input.cursor, 5);
}
#[test]
fn test_delete_forward_grapheme() {
let emoji = "👩💻";
let mut list = SearchableList::new(0);
list.input.text = format!("{emoji}a");
list.input.cursor = 0;
assert!(list.input.delete_forward_char());
assert_eq!(list.input.text, "a");
assert_eq!(list.input.cursor, 0);
}
#[test]
fn test_word_boundaries_unicode_whitespace() {
let text = "alpha\u{00A0}\u{00A0}beta";
let mut list = SearchableList::new(0);
list.input.text = text.to_string();
let beta_idx = text.find('b').unwrap();
let alpha_end = text.find('\u{00A0}').unwrap();
list.input.cursor_end();
list.input.cursor_word_left();
assert_eq!(list.input.cursor, beta_idx);
list.input.cursor_start();
list.input.cursor_word_right();
assert_eq!(list.input.cursor, alpha_end);
}
#[test]
fn test_delete_word_respects_whitespace() {
let text = "alpha beta";
let mut list = SearchableList::new(0);
list.input.text = text.to_string();
list.input.cursor_end();
list.input.delete_word();
assert_eq!(list.input.text, "alpha ");
assert_eq!(list.input.cursor, "alpha ".len());
}
#[test]
fn test_delete_word_forward_respects_whitespace() {
let text = "alpha beta";
let mut list = SearchableList::new(0);
list.input.text = text.to_string();
list.input.cursor_start();
list.input.delete_word_forward();
assert_eq!(list.input.text, " beta");
assert_eq!(list.input.cursor, 0);
}
#[test]
fn test_cursor_word_from_whitespace() {
let text = "alpha beta";
let mut list = SearchableList::new(0);
list.input.text = text.to_string();
list.input.cursor = 6;
list.input.cursor_word_left();
assert_eq!(list.input.cursor, 0);
list.input.cursor = 5;
list.input.cursor_word_right();
assert_eq!(list.input.cursor, text.len());
}
#[test]
fn test_delete_word_forward_from_whitespace() {
let text = "alpha beta";
let mut list = SearchableList::new(0);
list.input.text = text.to_string();
list.input.cursor = 5;
list.input.delete_word_forward();
assert_eq!(list.input.text, "alpha");
assert_eq!(list.input.cursor, 5);
}
#[test]
fn test_delete_to_start_clamps_cursor() {
let mut list = SearchableList::new(0);
list.input.text = "café".to_string();
list.input.cursor = 4;
list.input.delete_to_start();
assert_eq!(list.input.text, "é");
assert_eq!(list.input.cursor, 0);
}
#[test]
fn test_delete_to_end_clamps_cursor() {
let mut list = SearchableList::new(0);
list.input.text = "café".to_string();
list.input.cursor = 4;
list.input.delete_to_end();
assert_eq!(list.input.text, "caf");
assert_eq!(list.input.cursor, 3);
}
#[cfg(unix)]
#[test]
fn test_sort_repos_prefers_current_with_symlinked_paths() {
use std::os::unix::fs::symlink;
let tmp = tempdir().unwrap();
let repo_dir = tmp.path().join("repo");
let other_dir = tmp.path().join("other");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&other_dir).unwrap();
let link_dir = tmp.path().join("repo-link");
symlink(&repo_dir, &link_dir).unwrap();
let mut repos = vec![
Repo {
name: "repo-link".to_string(),
session_name: "repo-link".to_string(),
path: link_dir.clone(),
worktrees: vec![],
},
Repo {
name: "other".to_string(),
session_name: "other".to_string(),
path: other_dir.clone(),
worktrees: vec![],
},
];
sort_repos(&mut repos, Some(&repo_dir), &HashMap::new());
assert_eq!(repos[0].path, link_dir);
}
#[test]
fn test_worktree_dir_basic() {
let tmp = tempdir().unwrap();
let repo = make_repo(tmp.path(), "myrepo");
let result = worktree_dir(&repo, "main").unwrap();
assert_eq!(
result,
tmp.path()
.join(WORKTREE_DIR_NAME)
.join(format!("myrepo{WORKTREE_NAME_SEPARATOR}main"))
);
}
#[test]
fn test_worktree_dir_slash_in_branch() {
let tmp = tempdir().unwrap();
let repo = make_repo(tmp.path(), "repo");
let result = worktree_dir(&repo, "feat/awesome").unwrap();
assert_eq!(
result,
tmp.path()
.join(WORKTREE_DIR_NAME)
.join(format!("repo{WORKTREE_NAME_SEPARATOR}feat-awesome"))
);
}
#[test]
fn test_worktree_dir_dedup() {
let tmp = tempdir().unwrap();
let repo = make_repo(tmp.path(), "repo");
let first = tmp
.path()
.join(WORKTREE_DIR_NAME)
.join(format!("repo{WORKTREE_NAME_SEPARATOR}main"));
fs::create_dir_all(&first).unwrap();
let result = worktree_dir(&repo, "main").unwrap();
assert_eq!(
result,
tmp.path()
.join(WORKTREE_DIR_NAME)
.join(format!("repo{WORKTREE_NAME_SEPARATOR}main-2"))
);
}
#[test]
fn test_worktree_dir_bounded_error() {
let tmp = tempdir().unwrap();
let repo = make_repo(tmp.path(), "repo");
let wt_root = tmp.path().join(WORKTREE_DIR_NAME);
let base = format!("repo{WORKTREE_NAME_SEPARATOR}main");
fs::create_dir_all(wt_root.join(&base)).unwrap();
for i in 2..WORKTREE_DIR_DEDUP_MAX_ATTEMPTS {
fs::create_dir_all(wt_root.join(format!("{base}-{i}"))).unwrap();
}
let result = worktree_dir(&repo, "main");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains(&format!("{WORKTREE_DIR_DEDUP_MAX_ATTEMPTS} attempts"))
);
}
#[test]
fn test_worktree_dir_in_kiosk_worktrees_subdir() {
let tmp = tempdir().unwrap();
let repo = make_repo(tmp.path(), "myrepo");
let result = worktree_dir(&repo, "dev").unwrap();
assert!(result.to_string_lossy().contains(WORKTREE_DIR_NAME));
}
#[test]
fn test_build_sorted_basic() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo-dev"),
branch: Some("dev".to_string()),
is_main: false,
},
],
};
let branches = vec!["main".into(), "dev".into(), "feature".into()];
let sessions = vec!["myrepo-dev".to_string()];
let entries = BranchEntry::build_sorted(&repo, &branches, &sessions);
assert_eq!(entries[0].name, "main");
assert!(entries[0].is_current);
assert!(entries[0].worktree_path.is_some());
assert_eq!(entries[1].name, "dev");
assert!(entries[1].has_session);
assert!(entries[1].worktree_path.is_some());
assert_eq!(entries[2].name, "feature");
assert!(!entries[2].has_session);
assert!(entries[2].worktree_path.is_none());
}
#[test]
fn test_build_remote_deduplication() {
let remote = vec!["main".into(), "dev".into(), "remote-only".into()];
let local = vec!["main".into(), "dev".into()];
let entries = BranchEntry::build_remote("origin", &remote, &local);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "remote-only");
assert!(entries[0].remote.is_some());
}
#[test]
fn test_build_remote_empty_when_all_local() {
let remote = vec!["main".into(), "dev".into()];
let local = vec!["main".into(), "dev".into()];
let entries = BranchEntry::build_remote("origin", &remote, &local);
assert!(entries.is_empty());
}
#[test]
fn test_sort_remote_after_local() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
}],
};
let local_names = vec!["main".into(), "dev".into()];
let mut entries = BranchEntry::build_sorted(&repo, &local_names, &[]);
let remote_names = vec!["feature-a".into(), "feature-b".into()];
let remote = BranchEntry::build_remote("origin", &remote_names, &local_names);
entries.extend(remote);
BranchEntry::sort_entries(&mut entries);
assert!(entries[0].remote.is_none()); assert!(entries[1].remote.is_none()); assert!(entries[2].remote.is_some()); assert!(entries[3].remote.is_some()); }
#[test]
fn test_pending_delete_mark_and_clear() {
let mut state = AppState::new(vec![make_repo(std::path::Path::new("/tmp"), "repo")], None);
let repo_path = PathBuf::from("/tmp/repo");
let worktree_path = PathBuf::from("/tmp/repo-dev");
let pending =
PendingWorktreeDelete::new(repo_path.clone(), "dev".to_string(), worktree_path.clone());
state.mark_pending_worktree_delete(pending);
assert!(state.is_branch_pending_delete(&repo_path, "dev"));
assert!(state.clear_pending_worktree_delete_by_path(&worktree_path));
assert!(!state.is_branch_pending_delete(&repo_path, "dev"));
}
#[test]
fn test_scroll_anchor_behavior_down_then_up() {
let mut list = SearchableList::new(100);
let viewport_rows = 20;
for _ in 0..25 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let selected = list.selected.unwrap_or(0);
assert_eq!(selected - list.scroll_offset, 18);
for _ in 0..200 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let selected = list.selected.unwrap_or(0);
assert_eq!(selected, 99);
assert_eq!(selected - list.scroll_offset, 19);
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
let selected = list.selected.unwrap_or(0);
assert_eq!(selected, 98);
assert_eq!(selected - list.scroll_offset, 18);
for _ in 0..17 {
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let selected = list.selected.unwrap_or(0);
assert_eq!(selected, 81);
assert_eq!(selected - list.scroll_offset, 1);
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
let selected = list.selected.unwrap_or(0);
assert_eq!(selected, 80);
assert_eq!(selected - list.scroll_offset, 1);
}
#[test]
fn test_scroll_down_starts_before_last_viewport_row() {
let mut list = SearchableList::new(100);
let viewport_rows = 20;
for _ in 0..18 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
assert_eq!(list.selected, Some(18));
assert_eq!(list.scroll_offset, 0);
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
assert_eq!(list.selected, Some(19));
assert_eq!(list.scroll_offset, 1);
}
#[test]
fn test_scroll_up_from_bottom_keeps_offset_until_top_anchor_hit() {
let mut list = SearchableList::new(100);
let viewport_rows = 20;
for _ in 0..200 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let offset_at_bottom = list.scroll_offset;
assert_eq!(list.selected, Some(99));
for expected_selected in (81..=98).rev() {
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
assert_eq!(list.selected, Some(expected_selected));
assert_eq!(list.scroll_offset, offset_at_bottom);
}
}
#[test]
fn test_scroll_reversing_direction_near_bottom_does_not_move_offset() {
let mut list = SearchableList::new(100);
let viewport_rows = 20;
for _ in 0..200 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let offset_before = list.scroll_offset;
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
let offset_after_up = list.scroll_offset;
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
let offset_after_down = list.scroll_offset;
assert_eq!(offset_before, offset_after_up);
assert_eq!(offset_after_up, offset_after_down);
}
#[test]
fn test_first_up_from_bottom_does_not_change_offset_across_viewports() {
for viewport_rows in 3..=40 {
let mut list = SearchableList::new(35);
for _ in 0..200 {
list.move_selection(1);
list.update_scroll_offset_for_selection(viewport_rows);
}
let offset_before = list.scroll_offset;
list.move_selection(-1);
list.update_scroll_offset_for_selection(viewport_rows);
assert_eq!(
list.scroll_offset, offset_before,
"Offset changed for viewport_rows={viewport_rows}"
);
}
}
#[test]
fn test_prev_word_boundary_edges() {
let mut list = SearchableList::new(0);
list.input.text = "alpha beta".to_string();
assert_eq!(list.input.prev_word_boundary(0), 0);
assert_eq!(list.input.prev_word_boundary(list.input.text.len()), 8);
assert_eq!(list.input.prev_word_boundary(7), 0);
assert_eq!(list.input.prev_word_boundary(usize::MAX), 8);
}
#[test]
fn test_next_word_boundary_edges() {
let mut list = SearchableList::new(0);
list.input.text = "alpha beta".to_string();
assert_eq!(list.input.next_word_boundary(0), 5);
assert_eq!(list.input.next_word_boundary(5), 12);
assert_eq!(
list.input.next_word_boundary(list.input.text.len()),
list.input.text.len()
);
assert_eq!(
list.input.next_word_boundary(usize::MAX),
list.input.text.len()
);
}
#[test]
fn test_word_boundary_empty_and_spaces_only() {
let empty = SearchableList::new(0);
assert_eq!(empty.input.prev_word_boundary(3), 0);
assert_eq!(empty.input.next_word_boundary(3), 0);
let mut spaces = SearchableList::new(0);
spaces.input.text = " ".to_string();
assert_eq!(spaces.input.prev_word_boundary(3), 0);
assert_eq!(spaces.input.next_word_boundary(0), 3);
}
#[test]
fn test_branch_sort_order_with_activity() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--dev"),
branch: Some("dev".to_string()),
is_main: false,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--hotfix"),
branch: Some("hotfix".to_string()),
is_main: false,
},
],
};
let branches = vec![
"main".into(),
"dev".into(),
"hotfix".into(),
"feature".into(),
];
let sessions = vec!["myrepo--dev".to_string(), "myrepo--hotfix".to_string()];
let mut activity = HashMap::new();
activity.insert("myrepo--dev".to_string(), 100);
activity.insert("myrepo--hotfix".to_string(), 200);
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&sessions,
Some("main"),
&activity,
None,
);
assert_eq!(entries[0].name, "main"); assert!(entries[0].is_current);
assert!(entries[0].is_default);
assert_eq!(entries[1].name, "hotfix"); assert_eq!(entries[2].name, "dev"); assert_eq!(entries[3].name, "feature"); }
#[test]
fn test_branch_sort_default_after_current() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("dev".to_string()),
is_main: true,
}],
};
let branches = vec!["main".into(), "dev".into(), "feature".into()];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
Some("main"),
&HashMap::new(),
None,
);
assert_eq!(entries[0].name, "dev"); assert_eq!(entries[1].name, "main"); assert_eq!(entries[2].name, "feature");
}
#[test]
fn test_sort_repos_ordering() {
let mut repos = vec![
Repo {
name: "zebra".to_string(),
session_name: "zebra".to_string(),
path: PathBuf::from("/tmp/zebra"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/zebra"),
branch: Some("main".to_string()),
is_main: true,
}],
},
Repo {
name: "alpha".to_string(),
session_name: "alpha".to_string(),
path: PathBuf::from("/tmp/alpha"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/alpha"),
branch: Some("main".to_string()),
is_main: true,
}],
},
Repo {
name: "current".to_string(),
session_name: "current".to_string(),
path: PathBuf::from("/tmp/current"),
worktrees: vec![],
},
];
let mut activity = HashMap::new();
activity.insert("zebra".to_string(), 500);
sort_repos(&mut repos, Some(Path::new("/tmp/current")), &activity);
assert_eq!(repos[0].name, "current"); assert_eq!(repos[1].name, "zebra"); assert_eq!(repos[2].name, "alpha"); }
#[test]
fn test_reconcile_pending_deletes_removes_missing_worktree() {
let repo = Repo {
name: "repo".to_string(),
session_name: "repo".to_string(),
path: PathBuf::from("/tmp/repo"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/repo"),
branch: Some("main".to_string()),
is_main: true,
}],
};
let mut state = AppState::new(vec![repo], None);
state.mark_pending_worktree_delete(PendingWorktreeDelete::new(
PathBuf::from("/tmp/repo"),
"dev".to_string(),
PathBuf::from("/tmp/repo-dev"),
));
assert!(state.reconcile_pending_worktree_deletes());
assert!(state.pending_worktree_deletes.is_empty());
}
#[test]
fn test_sort_repos_no_current_repo() {
let mut repos = vec![
Repo {
name: "zebra".to_string(),
session_name: "zebra".to_string(),
path: PathBuf::from("/tmp/zebra"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/zebra"),
branch: Some("main".to_string()),
is_main: true,
}],
},
Repo {
name: "alpha".to_string(),
session_name: "alpha".to_string(),
path: PathBuf::from("/tmp/alpha"),
worktrees: vec![],
},
Repo {
name: "mango".to_string(),
session_name: "mango".to_string(),
path: PathBuf::from("/tmp/mango"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/mango"),
branch: Some("main".to_string()),
is_main: true,
}],
},
];
let mut activity = HashMap::new();
activity.insert("mango".to_string(), 300);
activity.insert("zebra".to_string(), 100);
sort_repos(&mut repos, None, &activity);
assert_eq!(repos[0].name, "mango"); assert_eq!(repos[1].name, "zebra"); assert_eq!(repos[2].name, "alpha"); }
#[test]
fn test_sort_repos_multiple_worktree_sessions() {
let mut repos = vec![
Repo {
name: "repo-a".to_string(),
session_name: "repo-a".to_string(),
path: PathBuf::from("/tmp/repo-a"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/repo-a"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/repo-a--feat"),
branch: Some("feat".to_string()),
is_main: false,
},
],
},
Repo {
name: "repo-b".to_string(),
session_name: "repo-b".to_string(),
path: PathBuf::from("/tmp/repo-b"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/repo-b"),
branch: Some("main".to_string()),
is_main: true,
}],
},
];
let mut activity = HashMap::new();
activity.insert("repo-a".to_string(), 50);
activity.insert("repo-a--feat".to_string(), 500);
activity.insert("repo-b".to_string(), 200);
sort_repos(&mut repos, None, &activity);
assert_eq!(repos[0].name, "repo-a");
assert_eq!(repos[1].name, "repo-b");
}
#[test]
fn test_sort_repos_empty() {
let mut repos: Vec<Repo> = vec![];
sort_repos(&mut repos, None, &HashMap::new());
assert!(repos.is_empty());
}
#[test]
fn test_branch_sort_current_is_also_default() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
}],
};
let branches = vec!["main".into(), "dev".into(), "feature".into()];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
Some("main"),
&HashMap::new(),
None,
);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].name, "main");
assert!(entries[0].is_current);
assert!(entries[0].is_default);
assert_eq!(
entries.iter().filter(|e| e.name == "main").count(),
1,
"main should appear exactly once"
);
}
#[test]
fn test_branch_sort_session_without_activity_ts() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--dev"),
branch: Some("dev".to_string()),
is_main: false,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--hotfix"),
branch: Some("hotfix".to_string()),
is_main: false,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--no-ts"),
branch: Some("no-ts".to_string()),
is_main: false,
},
],
};
let branches = vec![
"main".into(),
"dev".into(),
"hotfix".into(),
"no-ts".into(),
"plain".into(),
];
let sessions = vec![
"myrepo--dev".to_string(),
"myrepo--hotfix".to_string(),
"myrepo--no-ts".to_string(),
];
let mut activity = HashMap::new();
activity.insert("myrepo--dev".to_string(), 100);
activity.insert("myrepo--hotfix".to_string(), 200);
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&sessions,
Some("main"),
&activity,
None,
);
assert_eq!(entries[0].name, "main"); assert_eq!(entries[1].name, "hotfix"); assert_eq!(entries[2].name, "dev"); let no_ts_pos = entries.iter().position(|e| e.name == "no-ts").unwrap();
let plain_pos = entries.iter().position(|e| e.name == "plain").unwrap();
assert!(
no_ts_pos < plain_pos,
"no-ts (has worktree) should sort before plain (no worktree)"
);
}
#[test]
fn test_branch_sort_no_default_no_current() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo--alpha"),
branch: Some("alpha".to_string()),
is_main: false,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--beta"),
branch: Some("beta".to_string()),
is_main: false,
},
],
};
let branches = vec![
"alpha".into(),
"beta".into(),
"gamma".into(),
"delta".into(),
];
let sessions = vec!["myrepo--alpha".to_string()];
let mut activity = HashMap::new();
activity.insert("myrepo--alpha".to_string(), 999);
let entries = BranchEntry::build_sorted_with_activity(
&repo, &branches, &sessions, None, &activity, None,
);
assert_eq!(entries[0].name, "alpha");
assert_eq!(entries[1].name, "beta");
assert_eq!(entries[2].name, "delta");
assert_eq!(entries[3].name, "gamma");
}
#[test]
fn test_branch_sort_worktrees_before_plain() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--wt-branch"),
branch: Some("wt-branch".to_string()),
is_main: false,
},
],
};
let branches = vec![
"main".into(),
"aaa-plain".into(),
"wt-branch".into(),
"zzz-plain".into(),
];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
None,
&HashMap::new(),
None,
);
assert_eq!(entries[0].name, "main"); assert_eq!(entries[1].name, "wt-branch"); assert_eq!(entries[2].name, "aaa-plain");
assert_eq!(entries[3].name, "zzz-plain");
}
#[test]
fn test_branch_sort_agent_waiting_before_running() {
use crate::agent::{AgentKind, AgentState, AgentStatus};
let mut entries = vec![
BranchEntry {
name: "feat-running".to_string(),
worktree_path: Some(PathBuf::from("/tmp/r")),
has_session: true,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: Some(100),
agent_status: Some(AgentStatus {
kind: AgentKind::ClaudeCode,
state: AgentState::Running,
}),
},
BranchEntry {
name: "feat-waiting".to_string(),
worktree_path: Some(PathBuf::from("/tmp/w")),
has_session: true,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: Some(100),
agent_status: Some(AgentStatus {
kind: AgentKind::Codex,
state: AgentState::Waiting,
}),
},
BranchEntry {
name: "feat-idle".to_string(),
worktree_path: Some(PathBuf::from("/tmp/i")),
has_session: true,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: Some(100),
agent_status: Some(AgentStatus {
kind: AgentKind::ClaudeCode,
state: AgentState::Idle,
}),
},
];
BranchEntry::sort_entries(&mut entries);
assert_eq!(entries[0].name, "feat-waiting", "Waiting should sort first");
assert_eq!(
entries[1].name, "feat-running",
"Running should sort second"
);
assert_eq!(entries[2].name, "feat-idle", "Idle should sort last");
}
#[test]
fn test_branch_sort_remote_always_last() {
let mut entries = vec![
BranchEntry {
name: "aaa-remote".to_string(),
worktree_path: None,
has_session: false,
is_current: false,
is_default: false,
remote: Some("origin".to_string()),
session_activity_ts: None,
agent_status: None,
},
BranchEntry {
name: "zzz-local".to_string(),
worktree_path: None,
has_session: false,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: None,
agent_status: None,
},
BranchEntry {
name: "mmm-local".to_string(),
worktree_path: None,
has_session: false,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: None,
agent_status: None,
},
];
BranchEntry::sort_entries(&mut entries);
assert_eq!(entries[0].name, "mmm-local");
assert!(entries[0].remote.is_none());
assert_eq!(entries[1].name, "zzz-local");
assert!(entries[1].remote.is_none());
assert_eq!(entries[2].name, "aaa-remote");
assert!(entries[2].remote.is_some());
}
#[test]
fn test_cwd_worktree_determines_current_branch() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--feature"),
branch: Some("feature".to_string()),
is_main: false,
},
],
};
let branches = vec!["main".into(), "feature".into(), "dev".into()];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
Some("main"),
&HashMap::new(),
Some(Path::new("/tmp/myrepo--feature")),
);
assert_eq!(entries[0].name, "feature"); assert!(entries[0].is_current);
assert_eq!(entries[1].name, "main"); assert!(entries[1].is_default);
assert!(!entries[1].is_current);
assert_eq!(entries[2].name, "dev");
}
#[test]
fn test_cwd_main_repo_marks_main_worktree_current() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--feature"),
branch: Some("feature".to_string()),
is_main: false,
},
],
};
let branches = vec!["main".into(), "feature".into()];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
Some("main"),
&HashMap::new(),
Some(Path::new("/tmp/myrepo")),
);
assert_eq!(entries[0].name, "main"); assert!(entries[0].is_current);
assert_eq!(entries[1].name, "feature");
assert!(!entries[1].is_current);
}
#[test]
fn test_cwd_unrelated_falls_back_to_main_worktree() {
let repo = Repo {
name: "myrepo".to_string(),
session_name: "myrepo".to_string(),
path: PathBuf::from("/tmp/myrepo"),
worktrees: vec![
Worktree {
path: PathBuf::from("/tmp/myrepo"),
branch: Some("main".to_string()),
is_main: true,
},
Worktree {
path: PathBuf::from("/tmp/myrepo--feature"),
branch: Some("feature".to_string()),
is_main: false,
},
],
};
let branches = vec!["main".into(), "feature".into()];
let entries = BranchEntry::build_sorted_with_activity(
&repo,
&branches,
&[],
Some("main"),
&HashMap::new(),
Some(Path::new("/tmp/unrelated-dir")),
);
assert_eq!(entries[0].name, "main"); assert!(entries[0].is_current);
}
#[test]
fn test_build_remote_has_correct_defaults() {
let remote = vec!["feat-x".into(), "feat-y".into()];
let local: Vec<String> = vec![];
let entries = BranchEntry::build_remote("origin", &remote, &local);
assert_eq!(entries.len(), 2);
for entry in &entries {
assert!(!entry.is_default, "remote entries should not be default");
assert!(
entry.session_activity_ts.is_none(),
"remote entries should have no activity ts"
);
assert!(
entry.remote.is_some(),
"remote entries should be marked remote"
);
assert!(!entry.has_session);
assert!(!entry.is_current);
assert!(entry.worktree_path.is_none());
}
}
#[test]
fn test_active_list_points_to_help_overlay_in_help_mode() {
let mut state = AppState::new(vec![make_repo(std::path::Path::new("/tmp"), "repo")], None);
state.help_overlay = Some(HelpOverlayState {
list: SearchableList::new(3),
rows: Vec::new(),
});
state.mode = Mode::Help {
previous: Box::new(Mode::RepoSelect),
};
assert!(state.active_list().is_some());
assert_eq!(state.active_list().and_then(|list| list.selected), Some(0));
if let Some(list) = state.active_list_mut() {
list.move_selection(1);
}
assert_eq!(
state
.help_overlay
.as_ref()
.and_then(|overlay| overlay.list.selected),
Some(1)
);
}
#[test]
fn test_branch_entry_serde_round_trip() {
let entry = BranchEntry {
name: "feat/test".to_string(),
worktree_path: Some(PathBuf::from("/tmp/repo-feat-test")),
has_session: true,
is_current: false,
is_default: false,
remote: None,
session_activity_ts: Some(12345),
agent_status: None,
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: BranchEntry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, entry);
}
#[test]
fn test_setup_state_new() {
let setup = SetupState::new();
assert!(setup.input.text.is_empty());
assert_eq!(setup.input.cursor, 0);
assert!(setup.completions.is_empty());
assert!(setup.selected_completion.is_none());
assert!(setup.dirs.is_empty());
}
#[test]
fn test_app_state_new_setup() {
let state = AppState::new_setup();
assert!(state.setup.is_some());
assert_eq!(state.mode, Mode::Setup(SetupStep::Welcome));
assert!(state.repos.is_empty());
}
#[test]
fn test_setup_step_supports_text_edit() {
assert!(Mode::Setup(SetupStep::SearchDirs).supports_text_edit());
assert!(!Mode::Setup(SetupStep::Welcome).supports_text_edit());
}
#[test]
fn test_setup_step_supports_modal() {
assert!(Mode::Setup(SetupStep::Welcome).supports_modal_actions());
assert!(Mode::Setup(SetupStep::SearchDirs).supports_modal_actions());
}
#[test]
fn test_set_error_collapses_newlines_to_spaces() {
let mut state = AppState::new(Vec::new(), None);
state.set_error("line one\nline two\nline three");
assert_eq!(state.error.as_deref(), Some("line one line two line three"));
}
#[test]
fn test_set_error_collapses_carriage_return_newlines() {
let mut state = AppState::new(Vec::new(), None);
state.set_error("first\r\nsecond\r\nthird");
assert_eq!(state.error.as_deref(), Some("first second third"));
}
#[test]
fn test_set_error_collapses_multiple_whitespace() {
let mut state = AppState::new(Vec::new(), None);
state.set_error("spaced out\n\n\ntext");
assert_eq!(state.error.as_deref(), Some("spaced out text"));
}
#[test]
fn test_clear_error() {
let mut state = AppState::new(Vec::new(), None);
state.set_error("something failed");
assert!(state.error.is_some());
state.clear_error();
assert!(state.error.is_none());
}
#[test]
fn test_mode_effective_plain() {
assert_eq!(*Mode::BranchSelect.effective(), Mode::BranchSelect);
assert_eq!(*Mode::RepoSelect.effective(), Mode::RepoSelect);
}
#[test]
fn test_mode_effective_sees_through_help() {
let mode = Mode::Help {
previous: Box::new(Mode::BranchSelect),
};
assert_eq!(*mode.effective(), Mode::BranchSelect);
}
#[test]
fn test_mode_effective_nested_help() {
let mode = Mode::Help {
previous: Box::new(Mode::Help {
previous: Box::new(Mode::RepoSelect),
}),
};
assert_eq!(*mode.effective(), Mode::RepoSelect);
}
#[test]
fn test_agent_enabled_defaults_to_true() {
let state = AppState::new(vec![], None);
assert!(state.agent_enabled);
}
#[test]
fn test_agent_poll_interval_defaults_to_config_default() {
let state = AppState::new(vec![], None);
assert_eq!(
state.agent_poll_interval,
std::time::Duration::from_millis(500)
);
}
#[test]
fn test_agent_poll_interval_can_be_overridden() {
let mut state = AppState::new(vec![], None);
state.agent_poll_interval = std::time::Duration::from_millis(5000);
assert_eq!(
state.agent_poll_interval,
std::time::Duration::from_millis(5000)
);
}
#[test]
fn test_agent_enabled_can_be_disabled() {
let mut state = AppState::new(vec![], None);
state.agent_enabled = false;
assert!(!state.agent_enabled);
}
#[test]
fn test_agent_labels_stored_in_state() {
let mut state = AppState::new(vec![], None);
assert_eq!(state.agent_labels.running, "[RUNNING]");
assert_eq!(state.agent_labels.waiting, "[WAITING]");
state.agent_labels = AgentLabelsConfig {
running: "GO".to_string(),
waiting: "PEND".to_string(),
idle: "OFF".to_string(),
unknown: "N/A".to_string(),
};
assert_eq!(state.agent_labels.running, "GO");
assert_eq!(state.agent_labels.waiting, "PEND");
}
}