Skip to main content

kiosk_core/
state.rs

1use crate::{
2    agent::AgentStatus,
3    config::{
4        AgentConfig, AgentLabelsConfig,
5        keys::{Command, FlattenedKeybindingRow},
6    },
7    constants::{WORKTREE_DIR_DEDUP_MAX_ATTEMPTS, WORKTREE_DIR_NAME, WORKTREE_NAME_SEPARATOR},
8    git::Repo,
9    pending_delete::PendingWorktreeDelete,
10};
11use serde::{Deserialize, Serialize};
12use std::{
13    collections::{HashMap, HashSet},
14    path::{Path, PathBuf},
15    sync::{
16        Arc,
17        atomic::{AtomicBool, Ordering},
18    },
19};
20use unicode_segmentation::UnicodeSegmentation;
21
22/// Reusable text input with cursor, shared by `SearchableList` and `SetupState`.
23#[derive(Debug, Clone)]
24pub struct TextInput {
25    pub text: String,
26    pub cursor: usize,
27}
28
29#[derive(Clone, Copy)]
30struct GraphemeSpan {
31    start: usize,
32    end: usize,
33    is_whitespace: bool,
34}
35
36impl TextInput {
37    pub fn new() -> Self {
38        Self {
39            text: String::new(),
40            cursor: 0,
41        }
42    }
43
44    pub fn clear(&mut self) {
45        self.text.clear();
46        self.cursor = 0;
47    }
48
49    fn grapheme_spans(&self) -> Vec<GraphemeSpan> {
50        self.text
51            .grapheme_indices(true)
52            .map(|(start, grapheme)| GraphemeSpan {
53                start,
54                end: start + grapheme.len(),
55                is_whitespace: grapheme.chars().all(char::is_whitespace),
56            })
57            .collect()
58    }
59
60    fn grapheme_boundaries(&self) -> Vec<usize> {
61        let mut boundaries: Vec<usize> = self.text.grapheme_indices(true).map(|(i, _)| i).collect();
62        boundaries.push(self.text.len());
63        boundaries
64    }
65
66    fn boundaries_from_spans(spans: &[GraphemeSpan], text_len: usize) -> Vec<usize> {
67        let mut boundaries = Vec::with_capacity(spans.len().saturating_add(1));
68        for span in spans {
69            boundaries.push(span.start);
70        }
71        boundaries.push(text_len);
72        boundaries
73    }
74
75    fn boundary_index_at_or_before(boundaries: &[usize], cursor: usize) -> usize {
76        match boundaries.binary_search(&cursor) {
77            Ok(idx) => idx,
78            Err(idx) => idx.saturating_sub(1),
79        }
80    }
81
82    fn clamp_cursor_to_boundary(&mut self, boundaries: &[usize]) -> usize {
83        let cursor = self.cursor.min(self.text.len());
84        let idx = Self::boundary_index_at_or_before(boundaries, cursor);
85        self.cursor = boundaries.get(idx).copied().unwrap_or(0);
86        idx
87    }
88
89    pub(crate) fn prev_word_boundary(&self, from: usize) -> usize {
90        let spans = self.grapheme_spans();
91        if spans.is_empty() {
92            return 0;
93        }
94        let boundaries = Self::boundaries_from_spans(&spans, self.text.len());
95        let cursor = from.min(self.text.len());
96        let mut grapheme_idx =
97            Self::boundary_index_at_or_before(&boundaries, cursor).saturating_sub(1);
98
99        while let Some(span) = spans.get(grapheme_idx) {
100            if !span.is_whitespace {
101                break;
102            }
103            if grapheme_idx == 0 {
104                return 0;
105            }
106            grapheme_idx -= 1;
107        }
108
109        while let Some(span) = spans.get(grapheme_idx) {
110            if span.is_whitespace {
111                return span.end;
112            }
113            if grapheme_idx == 0 {
114                return 0;
115            }
116            grapheme_idx -= 1;
117        }
118
119        0
120    }
121
122    pub(crate) fn next_word_boundary(&self, from: usize) -> usize {
123        let spans = self.grapheme_spans();
124        if spans.is_empty() {
125            return 0;
126        }
127        let boundaries = Self::boundaries_from_spans(&spans, self.text.len());
128        let cursor = from.min(self.text.len());
129        let mut grapheme_idx = Self::boundary_index_at_or_before(&boundaries, cursor);
130
131        while let Some(span) = spans.get(grapheme_idx) {
132            if !span.is_whitespace {
133                break;
134            }
135            grapheme_idx += 1;
136        }
137
138        while let Some(span) = spans.get(grapheme_idx) {
139            if span.is_whitespace {
140                return span.start;
141            }
142            grapheme_idx += 1;
143        }
144
145        self.text.len()
146    }
147
148    /// Move cursor left by one grapheme cluster (UTF-8 safe)
149    pub fn cursor_left(&mut self) {
150        let boundaries = self.grapheme_boundaries();
151        let idx = self.clamp_cursor_to_boundary(&boundaries);
152        if idx > 0 {
153            self.cursor = boundaries[idx - 1];
154        }
155    }
156
157    /// Move cursor right by one grapheme cluster (UTF-8 safe)
158    pub fn cursor_right(&mut self) {
159        let boundaries = self.grapheme_boundaries();
160        let idx = self.clamp_cursor_to_boundary(&boundaries);
161        if idx + 1 < boundaries.len() {
162            self.cursor = boundaries[idx + 1];
163        }
164    }
165
166    pub fn cursor_start(&mut self) {
167        self.cursor = 0;
168    }
169
170    pub fn cursor_end(&mut self) {
171        self.cursor = self.text.len();
172    }
173
174    pub fn cursor_word_left(&mut self) {
175        let boundaries = self.grapheme_boundaries();
176        self.clamp_cursor_to_boundary(&boundaries);
177        self.cursor = self.prev_word_boundary(self.cursor);
178    }
179
180    pub fn cursor_word_right(&mut self) {
181        let boundaries = self.grapheme_boundaries();
182        self.clamp_cursor_to_boundary(&boundaries);
183        self.cursor = self.next_word_boundary(self.cursor);
184    }
185
186    /// Insert a character at the current cursor position
187    pub fn insert_char(&mut self, c: char) {
188        let boundaries = self.grapheme_boundaries();
189        self.clamp_cursor_to_boundary(&boundaries);
190        self.text.insert(self.cursor, c);
191        self.cursor += c.len_utf8();
192    }
193
194    /// Remove the grapheme cluster before the cursor (UTF-8 safe)
195    pub fn backspace(&mut self) -> bool {
196        let boundaries = self.grapheme_boundaries();
197        let idx = self.clamp_cursor_to_boundary(&boundaries);
198        if idx == 0 {
199            return false;
200        }
201        let prev = boundaries[idx - 1];
202        self.text.drain(prev..self.cursor);
203        self.cursor = prev;
204        true
205    }
206
207    /// Remove the grapheme cluster at cursor position (UTF-8 safe)
208    pub fn delete_forward_char(&mut self) -> bool {
209        let boundaries = self.grapheme_boundaries();
210        let idx = self.clamp_cursor_to_boundary(&boundaries);
211        if idx + 1 >= boundaries.len() {
212            return false;
213        }
214        let end = boundaries[idx + 1];
215        self.text.drain(self.cursor..end);
216        true
217    }
218
219    /// Delete word backwards from cursor position
220    pub fn delete_word(&mut self) {
221        if self.text.is_empty() || self.cursor == 0 {
222            return;
223        }
224        let boundaries = self.grapheme_boundaries();
225        self.clamp_cursor_to_boundary(&boundaries);
226        let new_cursor = self.prev_word_boundary(self.cursor);
227
228        self.text.drain(new_cursor..self.cursor);
229        self.cursor = new_cursor;
230    }
231
232    /// Delete word forwards from cursor position
233    pub fn delete_word_forward(&mut self) {
234        if self.text.is_empty() || self.cursor >= self.text.len() {
235            return;
236        }
237        let boundaries = self.grapheme_boundaries();
238        self.clamp_cursor_to_boundary(&boundaries);
239        let end = self.next_word_boundary(self.cursor);
240        self.text.drain(self.cursor..end);
241    }
242
243    pub fn delete_to_start(&mut self) {
244        if self.cursor == 0 {
245            return;
246        }
247        let boundaries = self.grapheme_boundaries();
248        self.clamp_cursor_to_boundary(&boundaries);
249        self.text.drain(..self.cursor);
250        self.cursor = 0;
251    }
252
253    pub fn delete_to_end(&mut self) {
254        if self.cursor >= self.text.len() {
255            return;
256        }
257        let boundaries = self.grapheme_boundaries();
258        self.clamp_cursor_to_boundary(&boundaries);
259        self.text.truncate(self.cursor);
260    }
261}
262
263impl Default for TextInput {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// Shared state for any searchable, filterable list.
270/// Eliminates the mode-dispatch triplication for search/cursor/movement.
271#[derive(Debug, Clone)]
272pub struct SearchableList {
273    pub input: TextInput,
274    /// Index-score pairs, sorted by score descending
275    pub filtered: Vec<(usize, i64)>,
276    pub selected: Option<usize>,
277    pub scroll_offset: usize,
278}
279
280impl SearchableList {
281    pub fn new(item_count: usize) -> Self {
282        Self {
283            input: TextInput::new(),
284            filtered: (0..item_count).map(|i| (i, 0)).collect(),
285            selected: if item_count > 0 { Some(0) } else { None },
286            scroll_offset: 0,
287        }
288    }
289
290    pub fn reset(&mut self, item_count: usize) {
291        self.input.clear();
292        self.filtered = (0..item_count).map(|i| (i, 0)).collect();
293        self.selected = if item_count > 0 { Some(0) } else { None };
294        self.scroll_offset = 0;
295    }
296
297    // ── Convenience accessors for backward compatibility ──
298
299    /// Access the search text
300    pub fn search(&self) -> &str {
301        &self.input.text
302    }
303
304    /// Access the cursor position
305    pub fn cursor(&self) -> usize {
306        self.input.cursor
307    }
308
309    /// Move selection by delta, clamping to bounds
310    pub fn move_selection(&mut self, delta: i32) {
311        let len = self.filtered.len();
312        if len == 0 {
313            return;
314        }
315        let current = self.selected.unwrap_or(0);
316        if delta > 0 {
317            self.selected = Some(
318                current
319                    .saturating_add(delta.unsigned_abs() as usize)
320                    .min(len - 1),
321            );
322        } else {
323            self.selected = Some(current.saturating_sub(delta.unsigned_abs() as usize));
324        }
325    }
326
327    pub fn move_to_top(&mut self) {
328        if !self.filtered.is_empty() {
329            self.selected = Some(0);
330        }
331    }
332
333    pub fn move_to_bottom(&mut self) {
334        if !self.filtered.is_empty() {
335            self.selected = Some(self.filtered.len() - 1);
336        }
337    }
338
339    pub fn update_scroll_offset_for_selection(&mut self, viewport_rows: usize) {
340        let len = self.filtered.len();
341        if len == 0 {
342            self.scroll_offset = 0;
343            return;
344        }
345
346        let viewport_rows = viewport_rows.max(1);
347        let max_offset = len.saturating_sub(viewport_rows);
348        let selected = self.selected.unwrap_or(0).min(len - 1);
349        let anchor_top = usize::from(viewport_rows > 2);
350        let anchor_bottom = viewport_rows.saturating_sub(2);
351
352        let top_bound = self.scroll_offset.saturating_add(anchor_top);
353        let bottom_bound = self.scroll_offset.saturating_add(anchor_bottom);
354
355        if selected < top_bound {
356            self.scroll_offset = selected.saturating_sub(anchor_top);
357        } else if selected > bottom_bound {
358            self.scroll_offset = selected.saturating_sub(anchor_bottom);
359        }
360
361        self.scroll_offset = self.scroll_offset.min(max_offset);
362    }
363}
364
365/// Rich branch entry with worktree and session metadata
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
367#[allow(clippy::struct_excessive_bools)]
368pub struct BranchEntry {
369    pub name: String,
370    /// If a worktree already exists for this branch
371    pub worktree_path: Option<PathBuf>,
372    pub has_session: bool,
373    pub is_current: bool,
374    /// Whether this is the default branch (main/master)
375    pub is_default: bool,
376    /// The remote this branch comes from, if it is a remote-only branch.
377    pub remote: Option<String>,
378    /// Last activity timestamp for the session (if any)
379    pub session_activity_ts: Option<u64>,
380    /// Status of any AI agent running in the session
381    pub agent_status: Option<AgentStatus>,
382}
383
384impl BranchEntry {
385    /// Build branch entries from a repo's branches, worktrees, and active tmux sessions
386    /// (unsorted).
387    pub fn build(
388        repo: &crate::git::Repo,
389        branch_names: &[String],
390        active_sessions: &[String],
391    ) -> Vec<Self> {
392        Self::build_entries(
393            repo,
394            branch_names,
395            active_sessions,
396            None,
397            &HashMap::new(),
398            None,
399        )
400    }
401
402    /// Build sorted branch entries from a repo's branches, worktrees, and active tmux sessions.
403    ///
404    /// Sorted by: sessions first, then worktrees, then alphabetical.
405    pub fn build_sorted(
406        repo: &crate::git::Repo,
407        branch_names: &[String],
408        active_sessions: &[String],
409    ) -> Vec<Self> {
410        let mut entries = Self::build(repo, branch_names, active_sessions);
411        Self::sort_entries(&mut entries);
412        entries
413    }
414
415    /// Build sorted branch entries with activity timestamps and default branch info.
416    ///
417    /// `cwd` is the user's current working directory (resolved to a repo/worktree root).
418    /// When it matches a worktree path, that worktree's branch is marked as current.
419    /// Falls back to the main worktree's branch when `cwd` is `None` or doesn't match.
420    pub fn build_sorted_with_activity(
421        repo: &crate::git::Repo,
422        branch_names: &[String],
423        active_sessions: &[String],
424        default_branch: Option<&str>,
425        session_activity: &HashMap<String, u64>,
426        cwd: Option<&Path>,
427    ) -> Vec<Self> {
428        let mut entries = Self::build_entries(
429            repo,
430            branch_names,
431            active_sessions,
432            default_branch,
433            session_activity,
434            cwd,
435        );
436        Self::sort_entries(&mut entries);
437        entries
438    }
439
440    fn build_entries(
441        repo: &crate::git::Repo,
442        branch_names: &[String],
443        active_sessions: &[String],
444        default_branch: Option<&str>,
445        session_activity: &HashMap<String, u64>,
446        cwd: Option<&Path>,
447    ) -> Vec<Self> {
448        let wt_by_branch: HashMap<&str, &crate::git::Worktree> = repo
449            .worktrees
450            .iter()
451            .filter_map(|wt| wt.branch.as_deref().map(|b| (b, wt)))
452            .collect();
453
454        let current_branch = cwd
455            .and_then(|p| repo.worktrees.iter().find(|wt| wt.path == p))
456            .or_else(|| repo.worktrees.first())
457            .and_then(|wt| wt.branch.as_deref());
458
459        branch_names
460            .iter()
461            .map(|name| {
462                let worktree_path = wt_by_branch.get(name.as_str()).map(|wt| wt.path.clone());
463                let session_name = worktree_path.as_ref().map(|p| repo.tmux_session_name(p));
464                let has_session = session_name
465                    .as_ref()
466                    .is_some_and(|sn| active_sessions.contains(sn));
467                let is_current = current_branch == Some(name.as_str());
468                let is_default = default_branch == Some(name.as_str());
469                let session_activity_ts = session_name
470                    .as_ref()
471                    .and_then(|sn| session_activity.get(sn).copied());
472
473                Self {
474                    name: name.clone(),
475                    worktree_path,
476                    has_session,
477                    is_current,
478                    is_default,
479                    remote: None,
480                    session_activity_ts,
481                    agent_status: None,
482                }
483            })
484            .collect()
485    }
486
487    /// Build remote-only branch entries, skipping branches that already exist locally.
488    pub fn build_remote(
489        remote: &str,
490        remote_names: &[String],
491        local_names: &[String],
492    ) -> Vec<Self> {
493        let local_set: std::collections::HashSet<&str> =
494            local_names.iter().map(String::as_str).collect();
495
496        remote_names
497            .iter()
498            .filter(|name| !local_set.contains(name.as_str()))
499            .map(|name| Self {
500                name: name.clone(),
501                worktree_path: None,
502                has_session: false,
503                is_current: false,
504                is_default: false,
505                remote: Some(remote.to_string()),
506                session_activity_ts: None,
507                agent_status: None,
508            })
509            .collect()
510    }
511
512    pub fn sort_entries(entries: &mut [Self]) {
513        entries.sort_by(|a, b| {
514            // Remote branches always sort after local
515            a.remote
516                .is_some()
517                .cmp(&b.remote.is_some())
518                // Current branch first
519                .then(b.is_current.cmp(&a.is_current))
520                // Default branch second
521                .then(b.is_default.cmp(&a.is_default))
522                // Branches with sessions, ordered by recency (most recent first)
523                .then(cmp_optional_recency(
524                    a.session_activity_ts,
525                    b.session_activity_ts,
526                ))
527                // Branches with sessions (even without activity timestamps) before those without
528                .then(b.has_session.cmp(&a.has_session))
529                // Agent needing attention sorts first (Waiting > Running > Idle/Unknown > None)
530                .then(agent_sort_priority(b.agent_status).cmp(&agent_sort_priority(a.agent_status)))
531                // Branches with worktrees before those without
532                .then(b.worktree_path.is_some().cmp(&a.worktree_path.is_some()))
533                .then(a.name.cmp(&b.name))
534        });
535    }
536}
537
538/// Sort priority for agent status: higher = sorts first.
539/// Waiting (needs user input) is most urgent, then Running, then the rest.
540fn agent_sort_priority(status: Option<crate::agent::AgentStatus>) -> u8 {
541    match status {
542        Some(s) => match s.state {
543            crate::AgentState::Waiting => 3,
544            crate::AgentState::Running => 2,
545            crate::AgentState::Idle | crate::AgentState::Unknown => 1,
546        },
547        None => 0,
548    }
549}
550
551/// Compare two optional timestamps for recency-based sorting (most recent first).
552/// `Some` sorts before `None`; when both are `Some`, the higher timestamp sorts first.
553fn cmp_optional_recency(a: Option<u64>, b: Option<u64>) -> std::cmp::Ordering {
554    match (a, b) {
555        (Some(_), None) => std::cmp::Ordering::Less,
556        (None, Some(_)) => std::cmp::Ordering::Greater,
557        (Some(a_ts), Some(b_ts)) => b_ts.cmp(&a_ts),
558        (None, None) => std::cmp::Ordering::Equal,
559    }
560}
561
562/// Sort repos by: current repo first, then repos with sessions by recency, then alphabetically.
563#[allow(clippy::implicit_hasher)]
564pub fn sort_repos(
565    repos: &mut [Repo],
566    current_repo_path: Option<&Path>,
567    session_activity: &HashMap<String, u64>,
568) {
569    let current_repo_path = current_repo_path
570        .and_then(|path| std::fs::canonicalize(path).ok())
571        .or_else(|| current_repo_path.map(ToOwned::to_owned));
572    let mut canonical_by_path = HashMap::with_capacity(repos.len());
573    for repo in repos.iter() {
574        let canonical = std::fs::canonicalize(&repo.path).unwrap_or_else(|_| repo.path.clone());
575        canonical_by_path.insert(repo.path.clone(), canonical);
576    }
577    repos.sort_by(|a, b| {
578        let a_path = canonical_by_path.get(&a.path).unwrap_or(&a.path);
579        let b_path = canonical_by_path.get(&b.path).unwrap_or(&b.path);
580        let a_is_current = current_repo_path.as_ref().is_some_and(|p| a_path == p);
581        let b_is_current = current_repo_path.as_ref().is_some_and(|p| b_path == p);
582
583        // Current repo first
584        b_is_current
585            .cmp(&a_is_current)
586            .then_with(|| {
587                let a_activity = repo_max_activity(a, session_activity);
588                let b_activity = repo_max_activity(b, session_activity);
589                cmp_optional_recency(a_activity, b_activity)
590            })
591            .then_with(|| a.name.cmp(&b.name))
592    });
593}
594
595/// Get the most recent session activity for a repo (across all its worktrees).
596fn repo_max_activity(repo: &Repo, session_activity: &HashMap<String, u64>) -> Option<u64> {
597    let main_session = std::iter::once(repo.tmux_session_name(&repo.path));
598    let wt_sessions = repo
599        .worktrees
600        .iter()
601        .map(|wt| repo.tmux_session_name(&wt.path));
602    main_session
603        .chain(wt_sessions)
604        .filter_map(|name| session_activity.get(&name).copied())
605        .max()
606}
607
608/// Setup wizard step
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub enum SetupStep {
611    /// Brief welcome screen
612    Welcome,
613    /// Directory entry with autocomplete
614    SearchDirs,
615}
616
617/// Setup wizard state
618#[derive(Debug, Clone)]
619pub struct SetupState {
620    /// Current text input with cursor
621    pub input: TextInput,
622    /// Current filesystem completions
623    pub completions: Vec<String>,
624    /// Which completion is highlighted
625    pub selected_completion: Option<usize>,
626    /// Directories added so far
627    pub dirs: Vec<String>,
628}
629
630impl SetupState {
631    pub fn new() -> Self {
632        Self {
633            input: TextInput::new(),
634            completions: Vec::new(),
635            selected_completion: None,
636            dirs: Vec::new(),
637        }
638    }
639}
640
641impl Default for SetupState {
642    fn default() -> Self {
643        Self::new()
644    }
645}
646
647/// What mode the app is in
648#[derive(Debug, Clone, PartialEq, Eq)]
649pub enum Mode {
650    RepoSelect,
651    BranchSelect,
652    SelectBaseBranch,
653    /// Blocking loading state — shows spinner, no input except Ctrl+C
654    Loading(String),
655    /// Confirmation dialog for worktree deletion
656    ConfirmWorktreeDelete {
657        branch_name: String,
658        has_session: bool,
659    },
660    /// Help overlay showing key bindings
661    Help {
662        previous: Box<Mode>,
663    },
664    /// Setup wizard for first-time config
665    Setup(SetupStep),
666}
667
668impl Mode {
669    /// The effective mode, looking through overlays like Help.
670    pub fn effective(&self) -> &Mode {
671        match self {
672            Mode::Help { previous } => previous.effective(),
673            other => other,
674        }
675    }
676
677    /// Commands to show in the footer bar, in display order.
678    pub fn footer_commands(&self) -> &'static [Command] {
679        match self {
680            Mode::RepoSelect => &[
681                Command::OpenRepo,
682                Command::EnterRepo,
683                Command::ShowHelp,
684                Command::Quit,
685            ],
686            Mode::BranchSelect => &[
687                Command::GoBack,
688                Command::OpenBranch,
689                Command::DeleteWorktree,
690                Command::ShowHelp,
691                Command::Quit,
692            ],
693            Mode::SelectBaseBranch => &[
694                Command::Cancel,
695                Command::Confirm,
696                Command::ShowHelp,
697                Command::Quit,
698            ],
699            Mode::ConfirmWorktreeDelete { .. } => &[
700                Command::Confirm,
701                Command::Cancel,
702                Command::ShowHelp,
703                Command::Quit,
704            ],
705            Mode::Setup(_) | Mode::Loading(_) | Mode::Help { .. } => &[],
706        }
707    }
708
709    pub fn supports_text_edit(&self) -> bool {
710        matches!(
711            self,
712            Mode::RepoSelect
713                | Mode::BranchSelect
714                | Mode::SelectBaseBranch
715                | Mode::Help { .. }
716                | Mode::Setup(SetupStep::SearchDirs)
717        )
718    }
719
720    pub(crate) fn supports_list_navigation(&self) -> bool {
721        matches!(
722            self,
723            Mode::RepoSelect
724                | Mode::BranchSelect
725                | Mode::SelectBaseBranch
726                | Mode::Help { .. }
727                | Mode::Setup(SetupStep::SearchDirs)
728        )
729    }
730
731    pub(crate) fn supports_modal_actions(&self) -> bool {
732        matches!(
733            self,
734            Mode::SelectBaseBranch | Mode::ConfirmWorktreeDelete { .. } | Mode::Setup(_)
735        )
736    }
737
738    pub(crate) fn supports_repo_select_actions(&self) -> bool {
739        matches!(self, Mode::RepoSelect)
740    }
741
742    pub(crate) fn supports_branch_select_actions(&self) -> bool {
743        matches!(self, Mode::BranchSelect)
744    }
745}
746
747/// The new-branch flow state
748#[derive(Debug, Clone)]
749pub struct BaseBranchSelection {
750    /// The new branch name (what the user typed)
751    pub new_name: String,
752    /// Base branches to pick from
753    pub bases: Vec<String>,
754    pub list: SearchableList,
755}
756
757#[derive(Debug, Clone)]
758pub struct HelpOverlayState {
759    pub list: SearchableList,
760    pub rows: Vec<FlattenedKeybindingRow>,
761}
762
763/// Central application state. Components read from this, actions modify it.
764#[derive(Debug, Clone)]
765#[allow(clippy::struct_excessive_bools)]
766pub struct AppState {
767    pub repos: Vec<Repo>,
768    pub repo_list: SearchableList,
769    pub loading_repos: bool,
770
771    pub selected_repo_idx: Option<usize>,
772    pub branches: Vec<BranchEntry>,
773    pub branch_list: SearchableList,
774
775    pub base_branch_selection: Option<BaseBranchSelection>,
776    pub help_overlay: Option<HelpOverlayState>,
777    pub setup: Option<SetupState>,
778
779    pub split_command: Option<String>,
780    pub mode: Mode,
781    pub loading_branches: bool,
782    pub fetching_remotes: bool,
783    pub error: Option<String>,
784    active_list_page_rows: usize,
785    pub pending_worktree_deletes: Vec<PendingWorktreeDelete>,
786    pub session_activity: HashMap<String, u64>,
787    /// Cancel token for the active agent status poller thread.
788    /// Setting this flag stops the current poller; clearing it (via `cancel_agent_poller`)
789    /// prepares for a new one.
790    pub agent_poller_cancel: Option<Arc<AtomicBool>>,
791    /// Whether agent status detection is enabled (configurable via `[agent] enabled`).
792    pub agent_enabled: bool,
793    /// Agent poll interval (configurable via `[agent] poll_interval_ms`).
794    pub agent_poll_interval: std::time::Duration,
795    /// Label text for each agent state shown in the branch picker.
796    pub agent_labels: AgentLabelsConfig,
797    /// Main repo root path from CWD (for repo ordering)
798    pub current_repo_path: Option<PathBuf>,
799    /// CWD resolved to repo/worktree root (for branch current detection)
800    pub cwd_worktree_path: Option<PathBuf>,
801    /// Tracks repo paths already seen during streaming discovery (O(1) dedup).
802    /// Cleared when a new scan starts.
803    pub seen_repo_paths: HashSet<PathBuf>,
804}
805
806impl AppState {
807    fn base(mode: Mode) -> Self {
808        Self {
809            repos: Vec::new(),
810            repo_list: SearchableList::new(0),
811            loading_repos: false,
812            selected_repo_idx: None,
813            branches: Vec::new(),
814            branch_list: SearchableList::new(0),
815            base_branch_selection: None,
816            help_overlay: None,
817            setup: None,
818            split_command: None,
819            mode,
820            loading_branches: false,
821            fetching_remotes: false,
822            error: None,
823            active_list_page_rows: 10,
824            pending_worktree_deletes: Vec::new(),
825            session_activity: HashMap::new(),
826            agent_poller_cancel: None,
827            agent_enabled: true,
828            agent_poll_interval: std::time::Duration::from_millis(
829                AgentConfig::default().poll_interval_ms,
830            ),
831            agent_labels: AgentLabelsConfig::default(),
832            current_repo_path: None,
833            cwd_worktree_path: None,
834            seen_repo_paths: HashSet::new(),
835        }
836    }
837
838    pub fn new(repos: Vec<Repo>, split_command: Option<String>) -> Self {
839        let repo_list = SearchableList::new(repos.len());
840        let seen_repo_paths: HashSet<PathBuf> = repos.iter().map(|r| r.path.clone()).collect();
841        Self {
842            repos,
843            repo_list,
844            split_command,
845            seen_repo_paths,
846            mode: Mode::RepoSelect,
847            ..Self::base(Mode::RepoSelect)
848        }
849    }
850
851    pub fn new_loading(loading_message: &str, split_command: Option<String>) -> Self {
852        Self {
853            split_command,
854            ..Self::base(Mode::Loading(loading_message.to_string()))
855        }
856    }
857
858    pub fn set_error(&mut self, msg: &str) {
859        // Collapse newlines/whitespace runs into single spaces so multi-line
860        // stderr output (e.g. from git) renders cleanly in the error toast.
861        self.error = Some(msg.split_whitespace().collect::<Vec<_>>().join(" "));
862    }
863
864    pub fn clear_error(&mut self) {
865        self.error = None;
866    }
867
868    pub fn new_setup() -> Self {
869        Self {
870            setup: Some(SetupState::new()),
871            ..Self::base(Mode::Setup(SetupStep::Welcome))
872        }
873    }
874
875    /// Signal the current agent poller thread to stop and clear the cancel token.
876    pub fn cancel_agent_poller(&mut self) {
877        if let Some(token) = self.agent_poller_cancel.take() {
878            token.store(true, Ordering::Relaxed);
879        }
880    }
881
882    /// Get the active text input for the current mode (mutable).
883    /// Works for both `SearchableList` modes and Setup mode.
884    pub fn active_text_input(&mut self) -> Option<&mut TextInput> {
885        match self.mode {
886            Mode::Setup(SetupStep::SearchDirs) => self.setup.as_mut().map(|s| &mut s.input),
887            _ => self.active_list_mut().map(|list| &mut list.input),
888        }
889    }
890
891    /// Get the active searchable list for the current mode (mutable)
892    pub fn active_list_mut(&mut self) -> Option<&mut SearchableList> {
893        match self.mode {
894            Mode::RepoSelect => Some(&mut self.repo_list),
895            Mode::BranchSelect => Some(&mut self.branch_list),
896            Mode::SelectBaseBranch => self.base_branch_selection.as_mut().map(|f| &mut f.list),
897            Mode::Help { .. } => self.active_help_list_mut(),
898            _ => None,
899        }
900    }
901
902    /// Get the active searchable list for the current mode (immutable)
903    pub fn active_list(&self) -> Option<&SearchableList> {
904        match self.mode {
905            Mode::RepoSelect => Some(&self.repo_list),
906            Mode::BranchSelect => Some(&self.branch_list),
907            Mode::SelectBaseBranch => self.base_branch_selection.as_ref().map(|f| &f.list),
908            Mode::Help { .. } => self.active_help_list(),
909            _ => None,
910        }
911    }
912
913    pub fn active_help_list_mut(&mut self) -> Option<&mut SearchableList> {
914        self.help_overlay.as_mut().map(|overlay| &mut overlay.list)
915    }
916
917    pub fn active_help_list(&self) -> Option<&SearchableList> {
918        self.help_overlay.as_ref().map(|overlay| &overlay.list)
919    }
920
921    pub fn is_branch_pending_delete(&self, repo_path: &Path, branch_name: &str) -> bool {
922        self.pending_worktree_deletes
923            .iter()
924            .any(|pending| pending.repo_path == repo_path && pending.branch_name == branch_name)
925    }
926
927    pub fn set_active_list_page_rows(&mut self, rows: usize) {
928        self.active_list_page_rows = rows.max(1);
929    }
930
931    pub fn active_list_page_rows(&self) -> usize {
932        self.active_list_page_rows.max(1)
933    }
934
935    pub fn mark_pending_worktree_delete(&mut self, pending: PendingWorktreeDelete) {
936        self.pending_worktree_deletes.retain(|entry| {
937            !(entry.repo_path == pending.repo_path && entry.branch_name == pending.branch_name)
938        });
939        self.pending_worktree_deletes.push(pending);
940    }
941
942    pub fn clear_pending_worktree_delete_by_path(&mut self, worktree_path: &Path) -> bool {
943        let before = self.pending_worktree_deletes.len();
944        self.pending_worktree_deletes
945            .retain(|pending| pending.worktree_path != worktree_path);
946        before != self.pending_worktree_deletes.len()
947    }
948
949    pub fn clear_pending_worktree_delete_by_branch(
950        &mut self,
951        repo_path: &Path,
952        branch_name: &str,
953    ) -> bool {
954        let before = self.pending_worktree_deletes.len();
955        self.pending_worktree_deletes.retain(|pending| {
956            !(pending.repo_path == repo_path && pending.branch_name == branch_name)
957        });
958        before != self.pending_worktree_deletes.len()
959    }
960
961    /// Drop stale pending delete entries that no longer correspond to an existing worktree.
962    pub fn reconcile_pending_worktree_deletes(&mut self) -> bool {
963        let active_worktree_paths: HashSet<&Path> = self
964            .repos
965            .iter()
966            .flat_map(|repo| repo.worktrees.iter().map(|wt| wt.path.as_path()))
967            .collect();
968
969        let before = self.pending_worktree_deletes.len();
970        self.pending_worktree_deletes.retain(|pending| {
971            !pending.is_expired() && active_worktree_paths.contains(pending.worktree_path.as_path())
972        });
973        before != self.pending_worktree_deletes.len()
974    }
975}
976
977/// Determine where to put a new worktree for a branch, avoiding collisions.
978///
979/// Worktrees are placed in `.kiosk_worktrees/` inside the repo's parent directory:
980/// ```text
981/// ~/Development/.kiosk_worktrees/kiosk--feat-awesome/
982/// ~/Development/.kiosk_worktrees/scooter--fix-bug/
983/// ```
984pub fn worktree_dir(repo: &Repo, branch: &str) -> anyhow::Result<PathBuf> {
985    let parent = repo.path.parent().unwrap_or(&repo.path);
986    let worktree_root = parent.join(WORKTREE_DIR_NAME);
987    let safe_branch = branch.replace('/', "-");
988    let base = format!("{}{WORKTREE_NAME_SEPARATOR}{safe_branch}", repo.name);
989    let candidate = worktree_root.join(&base);
990    if !candidate.exists() {
991        return Ok(candidate);
992    }
993    for i in 2..WORKTREE_DIR_DEDUP_MAX_ATTEMPTS {
994        let candidate = worktree_root.join(format!("{base}-{i}"));
995        if !candidate.exists() {
996            return Ok(candidate);
997        }
998    }
999    anyhow::bail!(
1000        "Could not find an available worktree directory name after {WORKTREE_DIR_DEDUP_MAX_ATTEMPTS} attempts"
1001    )
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::git::{Repo, Worktree};
1008    use crate::pending_delete::PendingWorktreeDelete;
1009    use std::fs;
1010    use tempfile::tempdir;
1011
1012    fn make_repo(dir: &std::path::Path, name: &str) -> Repo {
1013        Repo {
1014            name: name.to_string(),
1015            session_name: name.to_string(),
1016            path: dir.join(name),
1017            worktrees: vec![],
1018        }
1019    }
1020
1021    #[test]
1022    fn test_cursor_grapheme_combining_mark() {
1023        let mut list = SearchableList::new(0);
1024        list.input.text = "e\u{0301}".to_string();
1025        list.input.cursor_end();
1026
1027        list.input.cursor_left();
1028        assert_eq!(list.input.cursor, 0);
1029
1030        list.input.cursor_right();
1031        assert_eq!(list.input.cursor, list.input.text.len());
1032
1033        list.input.cursor_end();
1034        assert!(list.input.backspace());
1035        assert_eq!(list.input.text, "");
1036        assert_eq!(list.input.cursor, 0);
1037    }
1038
1039    #[test]
1040    fn test_cursor_grapheme_zwj_sequence() {
1041        let emoji = "👩‍💻";
1042        let mut list = SearchableList::new(0);
1043        list.input.text = format!("{emoji}a");
1044
1045        list.input.cursor_start();
1046        list.input.cursor_right();
1047        assert_eq!(list.input.cursor, emoji.len());
1048
1049        list.input.cursor_right();
1050        assert_eq!(list.input.cursor, list.input.text.len());
1051    }
1052
1053    #[test]
1054    fn test_cursor_clamps_inside_grapheme() {
1055        let mut list = SearchableList::new(0);
1056        list.input.text = "café".to_string();
1057        list.input.cursor = 4;
1058
1059        list.input.cursor_left();
1060        assert_eq!(list.input.cursor, 2);
1061
1062        list.input.cursor = 4;
1063        list.input.cursor_right();
1064        assert_eq!(list.input.cursor, 5);
1065    }
1066
1067    #[test]
1068    fn test_delete_forward_grapheme() {
1069        let emoji = "👩‍💻";
1070        let mut list = SearchableList::new(0);
1071        list.input.text = format!("{emoji}a");
1072        list.input.cursor = 0;
1073
1074        assert!(list.input.delete_forward_char());
1075        assert_eq!(list.input.text, "a");
1076        assert_eq!(list.input.cursor, 0);
1077    }
1078
1079    #[test]
1080    fn test_word_boundaries_unicode_whitespace() {
1081        let text = "alpha\u{00A0}\u{00A0}beta";
1082        let mut list = SearchableList::new(0);
1083        list.input.text = text.to_string();
1084        let beta_idx = text.find('b').unwrap();
1085        let alpha_end = text.find('\u{00A0}').unwrap();
1086
1087        list.input.cursor_end();
1088        list.input.cursor_word_left();
1089        assert_eq!(list.input.cursor, beta_idx);
1090
1091        list.input.cursor_start();
1092        list.input.cursor_word_right();
1093        assert_eq!(list.input.cursor, alpha_end);
1094    }
1095
1096    #[test]
1097    fn test_delete_word_respects_whitespace() {
1098        let text = "alpha  beta";
1099        let mut list = SearchableList::new(0);
1100        list.input.text = text.to_string();
1101        list.input.cursor_end();
1102
1103        list.input.delete_word();
1104        assert_eq!(list.input.text, "alpha  ");
1105        assert_eq!(list.input.cursor, "alpha  ".len());
1106    }
1107
1108    #[test]
1109    fn test_delete_word_forward_respects_whitespace() {
1110        let text = "alpha  beta";
1111        let mut list = SearchableList::new(0);
1112        list.input.text = text.to_string();
1113        list.input.cursor_start();
1114
1115        list.input.delete_word_forward();
1116        assert_eq!(list.input.text, "  beta");
1117        assert_eq!(list.input.cursor, 0);
1118    }
1119
1120    #[test]
1121    fn test_cursor_word_from_whitespace() {
1122        let text = "alpha   beta";
1123        let mut list = SearchableList::new(0);
1124        list.input.text = text.to_string();
1125        list.input.cursor = 6;
1126
1127        list.input.cursor_word_left();
1128        assert_eq!(list.input.cursor, 0);
1129
1130        list.input.cursor = 5;
1131        list.input.cursor_word_right();
1132        assert_eq!(list.input.cursor, text.len());
1133    }
1134
1135    #[test]
1136    fn test_delete_word_forward_from_whitespace() {
1137        let text = "alpha   beta";
1138        let mut list = SearchableList::new(0);
1139        list.input.text = text.to_string();
1140        list.input.cursor = 5;
1141
1142        list.input.delete_word_forward();
1143        assert_eq!(list.input.text, "alpha");
1144        assert_eq!(list.input.cursor, 5);
1145    }
1146
1147    #[test]
1148    fn test_delete_to_start_clamps_cursor() {
1149        let mut list = SearchableList::new(0);
1150        list.input.text = "café".to_string();
1151        list.input.cursor = 4;
1152
1153        list.input.delete_to_start();
1154        assert_eq!(list.input.text, "é");
1155        assert_eq!(list.input.cursor, 0);
1156    }
1157
1158    #[test]
1159    fn test_delete_to_end_clamps_cursor() {
1160        let mut list = SearchableList::new(0);
1161        list.input.text = "café".to_string();
1162        list.input.cursor = 4;
1163
1164        list.input.delete_to_end();
1165        assert_eq!(list.input.text, "caf");
1166        assert_eq!(list.input.cursor, 3);
1167    }
1168
1169    #[cfg(unix)]
1170    #[test]
1171    fn test_sort_repos_prefers_current_with_symlinked_paths() {
1172        use std::os::unix::fs::symlink;
1173
1174        let tmp = tempdir().unwrap();
1175        let repo_dir = tmp.path().join("repo");
1176        let other_dir = tmp.path().join("other");
1177        fs::create_dir_all(&repo_dir).unwrap();
1178        fs::create_dir_all(&other_dir).unwrap();
1179
1180        let link_dir = tmp.path().join("repo-link");
1181        symlink(&repo_dir, &link_dir).unwrap();
1182
1183        let mut repos = vec![
1184            Repo {
1185                name: "repo-link".to_string(),
1186                session_name: "repo-link".to_string(),
1187                path: link_dir.clone(),
1188                worktrees: vec![],
1189            },
1190            Repo {
1191                name: "other".to_string(),
1192                session_name: "other".to_string(),
1193                path: other_dir.clone(),
1194                worktrees: vec![],
1195            },
1196        ];
1197
1198        sort_repos(&mut repos, Some(&repo_dir), &HashMap::new());
1199        assert_eq!(repos[0].path, link_dir);
1200    }
1201
1202    #[test]
1203    fn test_worktree_dir_basic() {
1204        let tmp = tempdir().unwrap();
1205        let repo = make_repo(tmp.path(), "myrepo");
1206        let result = worktree_dir(&repo, "main").unwrap();
1207        assert_eq!(
1208            result,
1209            tmp.path()
1210                .join(WORKTREE_DIR_NAME)
1211                .join(format!("myrepo{WORKTREE_NAME_SEPARATOR}main"))
1212        );
1213    }
1214
1215    #[test]
1216    fn test_worktree_dir_slash_in_branch() {
1217        let tmp = tempdir().unwrap();
1218        let repo = make_repo(tmp.path(), "repo");
1219        let result = worktree_dir(&repo, "feat/awesome").unwrap();
1220        assert_eq!(
1221            result,
1222            tmp.path()
1223                .join(WORKTREE_DIR_NAME)
1224                .join(format!("repo{WORKTREE_NAME_SEPARATOR}feat-awesome"))
1225        );
1226    }
1227
1228    #[test]
1229    fn test_worktree_dir_dedup() {
1230        let tmp = tempdir().unwrap();
1231        let repo = make_repo(tmp.path(), "repo");
1232        let first = tmp
1233            .path()
1234            .join(WORKTREE_DIR_NAME)
1235            .join(format!("repo{WORKTREE_NAME_SEPARATOR}main"));
1236        fs::create_dir_all(&first).unwrap();
1237        let result = worktree_dir(&repo, "main").unwrap();
1238        assert_eq!(
1239            result,
1240            tmp.path()
1241                .join(WORKTREE_DIR_NAME)
1242                .join(format!("repo{WORKTREE_NAME_SEPARATOR}main-2"))
1243        );
1244    }
1245
1246    #[test]
1247    fn test_worktree_dir_bounded_error() {
1248        let tmp = tempdir().unwrap();
1249        let repo = make_repo(tmp.path(), "repo");
1250        let wt_root = tmp.path().join(WORKTREE_DIR_NAME);
1251        // Create the base and 2..999 suffixed dirs to exhaust the loop
1252        let base = format!("repo{WORKTREE_NAME_SEPARATOR}main");
1253        fs::create_dir_all(wt_root.join(&base)).unwrap();
1254        for i in 2..WORKTREE_DIR_DEDUP_MAX_ATTEMPTS {
1255            fs::create_dir_all(wt_root.join(format!("{base}-{i}"))).unwrap();
1256        }
1257        let result = worktree_dir(&repo, "main");
1258        assert!(result.is_err());
1259        assert!(
1260            result
1261                .unwrap_err()
1262                .to_string()
1263                .contains(&format!("{WORKTREE_DIR_DEDUP_MAX_ATTEMPTS} attempts"))
1264        );
1265    }
1266
1267    #[test]
1268    fn test_worktree_dir_in_kiosk_worktrees_subdir() {
1269        let tmp = tempdir().unwrap();
1270        let repo = make_repo(tmp.path(), "myrepo");
1271        let result = worktree_dir(&repo, "dev").unwrap();
1272        assert!(result.to_string_lossy().contains(WORKTREE_DIR_NAME));
1273    }
1274
1275    #[test]
1276    fn test_build_sorted_basic() {
1277        let repo = Repo {
1278            name: "myrepo".to_string(),
1279            session_name: "myrepo".to_string(),
1280            path: PathBuf::from("/tmp/myrepo"),
1281            worktrees: vec![
1282                Worktree {
1283                    path: PathBuf::from("/tmp/myrepo"),
1284                    branch: Some("main".to_string()),
1285                    is_main: true,
1286                },
1287                Worktree {
1288                    path: PathBuf::from("/tmp/myrepo-dev"),
1289                    branch: Some("dev".to_string()),
1290                    is_main: false,
1291                },
1292            ],
1293        };
1294
1295        let branches = vec!["main".into(), "dev".into(), "feature".into()];
1296        let sessions = vec!["myrepo-dev".to_string()];
1297
1298        let entries = BranchEntry::build_sorted(&repo, &branches, &sessions);
1299
1300        // main is current → first
1301        assert_eq!(entries[0].name, "main");
1302        assert!(entries[0].is_current);
1303        assert!(entries[0].worktree_path.is_some());
1304
1305        // dev has session → second
1306        assert_eq!(entries[1].name, "dev");
1307        assert!(entries[1].has_session);
1308        assert!(entries[1].worktree_path.is_some());
1309
1310        // feature has nothing → last
1311        assert_eq!(entries[2].name, "feature");
1312        assert!(!entries[2].has_session);
1313        assert!(entries[2].worktree_path.is_none());
1314    }
1315
1316    #[test]
1317    fn test_build_remote_deduplication() {
1318        let remote = vec!["main".into(), "dev".into(), "remote-only".into()];
1319        let local = vec!["main".into(), "dev".into()];
1320
1321        let entries = BranchEntry::build_remote("origin", &remote, &local);
1322
1323        // Only "remote-only" should appear (main and dev are local)
1324        assert_eq!(entries.len(), 1);
1325        assert_eq!(entries[0].name, "remote-only");
1326        assert!(entries[0].remote.is_some());
1327    }
1328
1329    #[test]
1330    fn test_build_remote_empty_when_all_local() {
1331        let remote = vec!["main".into(), "dev".into()];
1332        let local = vec!["main".into(), "dev".into()];
1333
1334        let entries = BranchEntry::build_remote("origin", &remote, &local);
1335        assert!(entries.is_empty());
1336    }
1337
1338    #[test]
1339    fn test_sort_remote_after_local() {
1340        let repo = Repo {
1341            name: "myrepo".to_string(),
1342            session_name: "myrepo".to_string(),
1343            path: PathBuf::from("/tmp/myrepo"),
1344            worktrees: vec![Worktree {
1345                path: PathBuf::from("/tmp/myrepo"),
1346                branch: Some("main".to_string()),
1347                is_main: true,
1348            }],
1349        };
1350
1351        let local_names = vec!["main".into(), "dev".into()];
1352        let mut entries = BranchEntry::build_sorted(&repo, &local_names, &[]);
1353
1354        // Add remote branches
1355        let remote_names = vec!["feature-a".into(), "feature-b".into()];
1356        let remote = BranchEntry::build_remote("origin", &remote_names, &local_names);
1357        entries.extend(remote);
1358        BranchEntry::sort_entries(&mut entries);
1359
1360        // Local branches should come before remote
1361        assert!(entries[0].remote.is_none()); // main (current)
1362        assert!(entries[1].remote.is_none()); // dev
1363        assert!(entries[2].remote.is_some()); // feature-a
1364        assert!(entries[3].remote.is_some()); // feature-b
1365    }
1366
1367    #[test]
1368    fn test_pending_delete_mark_and_clear() {
1369        let mut state = AppState::new(vec![make_repo(std::path::Path::new("/tmp"), "repo")], None);
1370        let repo_path = PathBuf::from("/tmp/repo");
1371        let worktree_path = PathBuf::from("/tmp/repo-dev");
1372        let pending =
1373            PendingWorktreeDelete::new(repo_path.clone(), "dev".to_string(), worktree_path.clone());
1374        state.mark_pending_worktree_delete(pending);
1375        assert!(state.is_branch_pending_delete(&repo_path, "dev"));
1376
1377        assert!(state.clear_pending_worktree_delete_by_path(&worktree_path));
1378        assert!(!state.is_branch_pending_delete(&repo_path, "dev"));
1379    }
1380
1381    #[test]
1382    fn test_scroll_anchor_behavior_down_then_up() {
1383        let mut list = SearchableList::new(100);
1384        let viewport_rows = 20;
1385
1386        // Move down into the middle: selection should be anchored one row above bottom.
1387        for _ in 0..25 {
1388            list.move_selection(1);
1389            list.update_scroll_offset_for_selection(viewport_rows);
1390        }
1391        let selected = list.selected.unwrap_or(0);
1392        assert_eq!(selected - list.scroll_offset, 18);
1393
1394        // Move to bottom: selection may reach the actual bottom row.
1395        for _ in 0..200 {
1396            list.move_selection(1);
1397            list.update_scroll_offset_for_selection(viewport_rows);
1398        }
1399        let selected = list.selected.unwrap_or(0);
1400        assert_eq!(selected, 99);
1401        assert_eq!(selected - list.scroll_offset, 19);
1402
1403        // Move up: keep viewport stationary first, then anchor one below top.
1404        list.move_selection(-1);
1405        list.update_scroll_offset_for_selection(viewport_rows);
1406        let selected = list.selected.unwrap_or(0);
1407        assert_eq!(selected, 98);
1408        assert_eq!(selected - list.scroll_offset, 18);
1409
1410        for _ in 0..17 {
1411            list.move_selection(-1);
1412            list.update_scroll_offset_for_selection(viewport_rows);
1413        }
1414        let selected = list.selected.unwrap_or(0);
1415        assert_eq!(selected, 81);
1416        assert_eq!(selected - list.scroll_offset, 1);
1417
1418        list.move_selection(-1);
1419        list.update_scroll_offset_for_selection(viewport_rows);
1420        let selected = list.selected.unwrap_or(0);
1421        assert_eq!(selected, 80);
1422        assert_eq!(selected - list.scroll_offset, 1);
1423    }
1424
1425    #[test]
1426    fn test_scroll_down_starts_before_last_viewport_row() {
1427        let mut list = SearchableList::new(100);
1428        let viewport_rows = 20;
1429
1430        for _ in 0..18 {
1431            list.move_selection(1);
1432            list.update_scroll_offset_for_selection(viewport_rows);
1433        }
1434        assert_eq!(list.selected, Some(18));
1435        assert_eq!(list.scroll_offset, 0);
1436
1437        list.move_selection(1);
1438        list.update_scroll_offset_for_selection(viewport_rows);
1439        assert_eq!(list.selected, Some(19));
1440        assert_eq!(list.scroll_offset, 1);
1441    }
1442
1443    #[test]
1444    fn test_scroll_up_from_bottom_keeps_offset_until_top_anchor_hit() {
1445        let mut list = SearchableList::new(100);
1446        let viewport_rows = 20;
1447
1448        for _ in 0..200 {
1449            list.move_selection(1);
1450            list.update_scroll_offset_for_selection(viewport_rows);
1451        }
1452        let offset_at_bottom = list.scroll_offset;
1453        assert_eq!(list.selected, Some(99));
1454
1455        for expected_selected in (81..=98).rev() {
1456            list.move_selection(-1);
1457            list.update_scroll_offset_for_selection(viewport_rows);
1458            assert_eq!(list.selected, Some(expected_selected));
1459            assert_eq!(list.scroll_offset, offset_at_bottom);
1460        }
1461    }
1462
1463    #[test]
1464    fn test_scroll_reversing_direction_near_bottom_does_not_move_offset() {
1465        let mut list = SearchableList::new(100);
1466        let viewport_rows = 20;
1467        for _ in 0..200 {
1468            list.move_selection(1);
1469            list.update_scroll_offset_for_selection(viewport_rows);
1470        }
1471
1472        let offset_before = list.scroll_offset;
1473        list.move_selection(-1);
1474        list.update_scroll_offset_for_selection(viewport_rows);
1475        let offset_after_up = list.scroll_offset;
1476        list.move_selection(1);
1477        list.update_scroll_offset_for_selection(viewport_rows);
1478        let offset_after_down = list.scroll_offset;
1479
1480        assert_eq!(offset_before, offset_after_up);
1481        assert_eq!(offset_after_up, offset_after_down);
1482    }
1483
1484    #[test]
1485    fn test_first_up_from_bottom_does_not_change_offset_across_viewports() {
1486        for viewport_rows in 3..=40 {
1487            let mut list = SearchableList::new(35);
1488            for _ in 0..200 {
1489                list.move_selection(1);
1490                list.update_scroll_offset_for_selection(viewport_rows);
1491            }
1492            let offset_before = list.scroll_offset;
1493            list.move_selection(-1);
1494            list.update_scroll_offset_for_selection(viewport_rows);
1495            assert_eq!(
1496                list.scroll_offset, offset_before,
1497                "Offset changed for viewport_rows={viewport_rows}"
1498            );
1499        }
1500    }
1501
1502    #[test]
1503    fn test_prev_word_boundary_edges() {
1504        let mut list = SearchableList::new(0);
1505        list.input.text = "alpha   beta".to_string();
1506
1507        assert_eq!(list.input.prev_word_boundary(0), 0);
1508        assert_eq!(list.input.prev_word_boundary(list.input.text.len()), 8);
1509        assert_eq!(list.input.prev_word_boundary(7), 0);
1510        assert_eq!(list.input.prev_word_boundary(usize::MAX), 8);
1511    }
1512
1513    #[test]
1514    fn test_next_word_boundary_edges() {
1515        let mut list = SearchableList::new(0);
1516        list.input.text = "alpha   beta".to_string();
1517
1518        assert_eq!(list.input.next_word_boundary(0), 5);
1519        assert_eq!(list.input.next_word_boundary(5), 12);
1520        assert_eq!(
1521            list.input.next_word_boundary(list.input.text.len()),
1522            list.input.text.len()
1523        );
1524        assert_eq!(
1525            list.input.next_word_boundary(usize::MAX),
1526            list.input.text.len()
1527        );
1528    }
1529
1530    #[test]
1531    fn test_word_boundary_empty_and_spaces_only() {
1532        let empty = SearchableList::new(0);
1533        assert_eq!(empty.input.prev_word_boundary(3), 0);
1534        assert_eq!(empty.input.next_word_boundary(3), 0);
1535
1536        let mut spaces = SearchableList::new(0);
1537        spaces.input.text = "   ".to_string();
1538        assert_eq!(spaces.input.prev_word_boundary(3), 0);
1539        assert_eq!(spaces.input.next_word_boundary(0), 3);
1540    }
1541
1542    #[test]
1543    fn test_branch_sort_order_with_activity() {
1544        let repo = Repo {
1545            name: "myrepo".to_string(),
1546            session_name: "myrepo".to_string(),
1547            path: PathBuf::from("/tmp/myrepo"),
1548            worktrees: vec![
1549                Worktree {
1550                    path: PathBuf::from("/tmp/myrepo"),
1551                    branch: Some("main".to_string()),
1552                    is_main: true,
1553                },
1554                Worktree {
1555                    path: PathBuf::from("/tmp/myrepo--dev"),
1556                    branch: Some("dev".to_string()),
1557                    is_main: false,
1558                },
1559                Worktree {
1560                    path: PathBuf::from("/tmp/myrepo--hotfix"),
1561                    branch: Some("hotfix".to_string()),
1562                    is_main: false,
1563                },
1564            ],
1565        };
1566
1567        let branches = vec![
1568            "main".into(),
1569            "dev".into(),
1570            "hotfix".into(),
1571            "feature".into(),
1572        ];
1573        let sessions = vec!["myrepo--dev".to_string(), "myrepo--hotfix".to_string()];
1574        let mut activity = HashMap::new();
1575        activity.insert("myrepo--dev".to_string(), 100);
1576        activity.insert("myrepo--hotfix".to_string(), 200);
1577
1578        let entries = BranchEntry::build_sorted_with_activity(
1579            &repo,
1580            &branches,
1581            &sessions,
1582            Some("main"),
1583            &activity,
1584            None,
1585        );
1586
1587        // Order: current (main), default (main, but already current), sessions by recency, worktrees, rest
1588        assert_eq!(entries[0].name, "main"); // current + default
1589        assert!(entries[0].is_current);
1590        assert!(entries[0].is_default);
1591        assert_eq!(entries[1].name, "hotfix"); // session ts=200
1592        assert_eq!(entries[2].name, "dev"); // session ts=100
1593        assert_eq!(entries[3].name, "feature"); // no session, no worktree
1594    }
1595
1596    #[test]
1597    fn test_branch_sort_default_after_current() {
1598        let repo = Repo {
1599            name: "myrepo".to_string(),
1600            session_name: "myrepo".to_string(),
1601            path: PathBuf::from("/tmp/myrepo"),
1602            worktrees: vec![Worktree {
1603                path: PathBuf::from("/tmp/myrepo"),
1604                branch: Some("dev".to_string()),
1605                is_main: true,
1606            }],
1607        };
1608
1609        let branches = vec!["main".into(), "dev".into(), "feature".into()];
1610        let entries = BranchEntry::build_sorted_with_activity(
1611            &repo,
1612            &branches,
1613            &[],
1614            Some("main"),
1615            &HashMap::new(),
1616            None,
1617        );
1618
1619        assert_eq!(entries[0].name, "dev"); // current (main worktree has dev checked out)
1620        assert_eq!(entries[1].name, "main"); // default
1621        assert_eq!(entries[2].name, "feature");
1622    }
1623
1624    #[test]
1625    fn test_sort_repos_ordering() {
1626        let mut repos = vec![
1627            Repo {
1628                name: "zebra".to_string(),
1629                session_name: "zebra".to_string(),
1630                path: PathBuf::from("/tmp/zebra"),
1631                worktrees: vec![Worktree {
1632                    path: PathBuf::from("/tmp/zebra"),
1633                    branch: Some("main".to_string()),
1634                    is_main: true,
1635                }],
1636            },
1637            Repo {
1638                name: "alpha".to_string(),
1639                session_name: "alpha".to_string(),
1640                path: PathBuf::from("/tmp/alpha"),
1641                worktrees: vec![Worktree {
1642                    path: PathBuf::from("/tmp/alpha"),
1643                    branch: Some("main".to_string()),
1644                    is_main: true,
1645                }],
1646            },
1647            Repo {
1648                name: "current".to_string(),
1649                session_name: "current".to_string(),
1650                path: PathBuf::from("/tmp/current"),
1651                worktrees: vec![],
1652            },
1653        ];
1654
1655        let mut activity = HashMap::new();
1656        activity.insert("zebra".to_string(), 500);
1657
1658        sort_repos(&mut repos, Some(Path::new("/tmp/current")), &activity);
1659
1660        assert_eq!(repos[0].name, "current"); // current repo
1661        assert_eq!(repos[1].name, "zebra"); // has session
1662        assert_eq!(repos[2].name, "alpha"); // alphabetical
1663    }
1664
1665    #[test]
1666    fn test_reconcile_pending_deletes_removes_missing_worktree() {
1667        let repo = Repo {
1668            name: "repo".to_string(),
1669            session_name: "repo".to_string(),
1670            path: PathBuf::from("/tmp/repo"),
1671            worktrees: vec![Worktree {
1672                path: PathBuf::from("/tmp/repo"),
1673                branch: Some("main".to_string()),
1674                is_main: true,
1675            }],
1676        };
1677        let mut state = AppState::new(vec![repo], None);
1678        state.mark_pending_worktree_delete(PendingWorktreeDelete::new(
1679            PathBuf::from("/tmp/repo"),
1680            "dev".to_string(),
1681            PathBuf::from("/tmp/repo-dev"),
1682        ));
1683
1684        assert!(state.reconcile_pending_worktree_deletes());
1685        assert!(state.pending_worktree_deletes.is_empty());
1686    }
1687
1688    #[test]
1689    fn test_sort_repos_no_current_repo() {
1690        let mut repos = vec![
1691            Repo {
1692                name: "zebra".to_string(),
1693                session_name: "zebra".to_string(),
1694                path: PathBuf::from("/tmp/zebra"),
1695                worktrees: vec![Worktree {
1696                    path: PathBuf::from("/tmp/zebra"),
1697                    branch: Some("main".to_string()),
1698                    is_main: true,
1699                }],
1700            },
1701            Repo {
1702                name: "alpha".to_string(),
1703                session_name: "alpha".to_string(),
1704                path: PathBuf::from("/tmp/alpha"),
1705                worktrees: vec![],
1706            },
1707            Repo {
1708                name: "mango".to_string(),
1709                session_name: "mango".to_string(),
1710                path: PathBuf::from("/tmp/mango"),
1711                worktrees: vec![Worktree {
1712                    path: PathBuf::from("/tmp/mango"),
1713                    branch: Some("main".to_string()),
1714                    is_main: true,
1715                }],
1716            },
1717        ];
1718
1719        let mut activity = HashMap::new();
1720        activity.insert("mango".to_string(), 300);
1721        activity.insert("zebra".to_string(), 100);
1722
1723        sort_repos(&mut repos, None, &activity);
1724
1725        // Sessions by recency first, then alphabetical
1726        assert_eq!(repos[0].name, "mango"); // session ts=300
1727        assert_eq!(repos[1].name, "zebra"); // session ts=100
1728        assert_eq!(repos[2].name, "alpha"); // no session, alphabetical
1729    }
1730
1731    #[test]
1732    fn test_sort_repos_multiple_worktree_sessions() {
1733        let mut repos = vec![
1734            Repo {
1735                name: "repo-a".to_string(),
1736                session_name: "repo-a".to_string(),
1737                path: PathBuf::from("/tmp/repo-a"),
1738                worktrees: vec![
1739                    Worktree {
1740                        path: PathBuf::from("/tmp/repo-a"),
1741                        branch: Some("main".to_string()),
1742                        is_main: true,
1743                    },
1744                    Worktree {
1745                        path: PathBuf::from("/tmp/repo-a--feat"),
1746                        branch: Some("feat".to_string()),
1747                        is_main: false,
1748                    },
1749                ],
1750            },
1751            Repo {
1752                name: "repo-b".to_string(),
1753                session_name: "repo-b".to_string(),
1754                path: PathBuf::from("/tmp/repo-b"),
1755                worktrees: vec![Worktree {
1756                    path: PathBuf::from("/tmp/repo-b"),
1757                    branch: Some("main".to_string()),
1758                    is_main: true,
1759                }],
1760            },
1761        ];
1762
1763        let mut activity = HashMap::new();
1764        // repo-a has two worktree sessions: main at 50, feat at 500
1765        activity.insert("repo-a".to_string(), 50);
1766        activity.insert("repo-a--feat".to_string(), 500);
1767        // repo-b has one session at 200
1768        activity.insert("repo-b".to_string(), 200);
1769
1770        sort_repos(&mut repos, None, &activity);
1771
1772        // repo-a max activity is 500 > repo-b's 200
1773        assert_eq!(repos[0].name, "repo-a");
1774        assert_eq!(repos[1].name, "repo-b");
1775    }
1776
1777    #[test]
1778    fn test_sort_repos_empty() {
1779        let mut repos: Vec<Repo> = vec![];
1780        sort_repos(&mut repos, None, &HashMap::new());
1781        assert!(repos.is_empty());
1782    }
1783
1784    #[test]
1785    fn test_branch_sort_current_is_also_default() {
1786        let repo = Repo {
1787            name: "myrepo".to_string(),
1788            session_name: "myrepo".to_string(),
1789            path: PathBuf::from("/tmp/myrepo"),
1790            worktrees: vec![Worktree {
1791                path: PathBuf::from("/tmp/myrepo"),
1792                branch: Some("main".to_string()),
1793                is_main: true,
1794            }],
1795        };
1796
1797        let branches = vec!["main".into(), "dev".into(), "feature".into()];
1798        let entries = BranchEntry::build_sorted_with_activity(
1799            &repo,
1800            &branches,
1801            &[],
1802            Some("main"),
1803            &HashMap::new(),
1804            None,
1805        );
1806
1807        // main is both current and default — should appear exactly once at position 0
1808        assert_eq!(entries.len(), 3);
1809        assert_eq!(entries[0].name, "main");
1810        assert!(entries[0].is_current);
1811        assert!(entries[0].is_default);
1812        // No duplicate
1813        assert_eq!(
1814            entries.iter().filter(|e| e.name == "main").count(),
1815            1,
1816            "main should appear exactly once"
1817        );
1818    }
1819
1820    #[test]
1821    fn test_branch_sort_session_without_activity_ts() {
1822        let repo = Repo {
1823            name: "myrepo".to_string(),
1824            session_name: "myrepo".to_string(),
1825            path: PathBuf::from("/tmp/myrepo"),
1826            worktrees: vec![
1827                Worktree {
1828                    path: PathBuf::from("/tmp/myrepo"),
1829                    branch: Some("main".to_string()),
1830                    is_main: true,
1831                },
1832                Worktree {
1833                    path: PathBuf::from("/tmp/myrepo--dev"),
1834                    branch: Some("dev".to_string()),
1835                    is_main: false,
1836                },
1837                Worktree {
1838                    path: PathBuf::from("/tmp/myrepo--hotfix"),
1839                    branch: Some("hotfix".to_string()),
1840                    is_main: false,
1841                },
1842                Worktree {
1843                    path: PathBuf::from("/tmp/myrepo--no-ts"),
1844                    branch: Some("no-ts".to_string()),
1845                    is_main: false,
1846                },
1847            ],
1848        };
1849
1850        let branches = vec![
1851            "main".into(),
1852            "dev".into(),
1853            "hotfix".into(),
1854            "no-ts".into(),
1855            "plain".into(),
1856        ];
1857        // no-ts has a session but no activity timestamp
1858        let sessions = vec![
1859            "myrepo--dev".to_string(),
1860            "myrepo--hotfix".to_string(),
1861            "myrepo--no-ts".to_string(),
1862        ];
1863        let mut activity = HashMap::new();
1864        activity.insert("myrepo--dev".to_string(), 100);
1865        activity.insert("myrepo--hotfix".to_string(), 200);
1866        // no-ts intentionally missing from activity map
1867
1868        let entries = BranchEntry::build_sorted_with_activity(
1869            &repo,
1870            &branches,
1871            &sessions,
1872            Some("main"),
1873            &activity,
1874            None,
1875        );
1876
1877        assert_eq!(entries[0].name, "main"); // current + default
1878        assert_eq!(entries[1].name, "hotfix"); // session ts=200
1879        assert_eq!(entries[2].name, "dev"); // session ts=100
1880        // no-ts has session but no timestamp — has_session=true but session_activity_ts=None
1881        // It has a worktree, so it sorts among worktree branches
1882        // The sort_entries sorts by session_activity_ts first (Some before None),
1883        // then worktree presence. no-ts has no activity_ts so it falls to worktree tier.
1884        let no_ts_pos = entries.iter().position(|e| e.name == "no-ts").unwrap();
1885        let plain_pos = entries.iter().position(|e| e.name == "plain").unwrap();
1886        assert!(
1887            no_ts_pos < plain_pos,
1888            "no-ts (has worktree) should sort before plain (no worktree)"
1889        );
1890    }
1891
1892    #[test]
1893    fn test_branch_sort_no_default_no_current() {
1894        // No default branch; CWD is None so fallback picks first worktree as current
1895        let repo = Repo {
1896            name: "myrepo".to_string(),
1897            session_name: "myrepo".to_string(),
1898            path: PathBuf::from("/tmp/myrepo"),
1899            worktrees: vec![
1900                Worktree {
1901                    path: PathBuf::from("/tmp/myrepo--alpha"),
1902                    branch: Some("alpha".to_string()),
1903                    is_main: false,
1904                },
1905                Worktree {
1906                    path: PathBuf::from("/tmp/myrepo--beta"),
1907                    branch: Some("beta".to_string()),
1908                    is_main: false,
1909                },
1910            ],
1911        };
1912
1913        let branches = vec![
1914            "alpha".into(),
1915            "beta".into(),
1916            "gamma".into(),
1917            "delta".into(),
1918        ];
1919        let sessions = vec!["myrepo--alpha".to_string()];
1920        let mut activity = HashMap::new();
1921        activity.insert("myrepo--alpha".to_string(), 999);
1922
1923        let entries = BranchEntry::build_sorted_with_activity(
1924            &repo, &branches, &sessions, None, // no default
1925            &activity, None,
1926        );
1927
1928        // alpha has session with ts → first
1929        assert_eq!(entries[0].name, "alpha");
1930        // beta has worktree but no session → next
1931        assert_eq!(entries[1].name, "beta");
1932        // gamma and delta are plain, alphabetical
1933        assert_eq!(entries[2].name, "delta");
1934        assert_eq!(entries[3].name, "gamma");
1935    }
1936
1937    #[test]
1938    fn test_branch_sort_worktrees_before_plain() {
1939        let repo = Repo {
1940            name: "myrepo".to_string(),
1941            session_name: "myrepo".to_string(),
1942            path: PathBuf::from("/tmp/myrepo"),
1943            worktrees: vec![
1944                Worktree {
1945                    path: PathBuf::from("/tmp/myrepo"),
1946                    branch: Some("main".to_string()),
1947                    is_main: true,
1948                },
1949                Worktree {
1950                    path: PathBuf::from("/tmp/myrepo--wt-branch"),
1951                    branch: Some("wt-branch".to_string()),
1952                    is_main: false,
1953                },
1954            ],
1955        };
1956
1957        let branches = vec![
1958            "main".into(),
1959            "aaa-plain".into(),
1960            "wt-branch".into(),
1961            "zzz-plain".into(),
1962        ];
1963
1964        let entries = BranchEntry::build_sorted_with_activity(
1965            &repo,
1966            &branches,
1967            &[],
1968            None,
1969            &HashMap::new(),
1970            None,
1971        );
1972
1973        assert_eq!(entries[0].name, "main"); // current
1974        assert_eq!(entries[1].name, "wt-branch"); // has worktree
1975        // plain branches alphabetical
1976        assert_eq!(entries[2].name, "aaa-plain");
1977        assert_eq!(entries[3].name, "zzz-plain");
1978    }
1979
1980    #[test]
1981    fn test_branch_sort_agent_waiting_before_running() {
1982        use crate::agent::{AgentKind, AgentState, AgentStatus};
1983
1984        let mut entries = vec![
1985            BranchEntry {
1986                name: "feat-running".to_string(),
1987                worktree_path: Some(PathBuf::from("/tmp/r")),
1988                has_session: true,
1989                is_current: false,
1990                is_default: false,
1991                remote: None,
1992                session_activity_ts: Some(100),
1993                agent_status: Some(AgentStatus {
1994                    kind: AgentKind::ClaudeCode,
1995                    state: AgentState::Running,
1996                }),
1997            },
1998            BranchEntry {
1999                name: "feat-waiting".to_string(),
2000                worktree_path: Some(PathBuf::from("/tmp/w")),
2001                has_session: true,
2002                is_current: false,
2003                is_default: false,
2004                remote: None,
2005                session_activity_ts: Some(100),
2006                agent_status: Some(AgentStatus {
2007                    kind: AgentKind::Codex,
2008                    state: AgentState::Waiting,
2009                }),
2010            },
2011            BranchEntry {
2012                name: "feat-idle".to_string(),
2013                worktree_path: Some(PathBuf::from("/tmp/i")),
2014                has_session: true,
2015                is_current: false,
2016                is_default: false,
2017                remote: None,
2018                session_activity_ts: Some(100),
2019                agent_status: Some(AgentStatus {
2020                    kind: AgentKind::ClaudeCode,
2021                    state: AgentState::Idle,
2022                }),
2023            },
2024        ];
2025        BranchEntry::sort_entries(&mut entries);
2026        assert_eq!(entries[0].name, "feat-waiting", "Waiting should sort first");
2027        assert_eq!(
2028            entries[1].name, "feat-running",
2029            "Running should sort second"
2030        );
2031        assert_eq!(entries[2].name, "feat-idle", "Idle should sort last");
2032    }
2033
2034    #[test]
2035    fn test_branch_sort_remote_always_last() {
2036        let mut entries = vec![
2037            BranchEntry {
2038                name: "aaa-remote".to_string(),
2039                worktree_path: None,
2040                has_session: false,
2041                is_current: false,
2042                is_default: false,
2043                remote: Some("origin".to_string()),
2044                session_activity_ts: None,
2045                agent_status: None,
2046            },
2047            BranchEntry {
2048                name: "zzz-local".to_string(),
2049                worktree_path: None,
2050                has_session: false,
2051                is_current: false,
2052                is_default: false,
2053                remote: None,
2054                session_activity_ts: None,
2055                agent_status: None,
2056            },
2057            BranchEntry {
2058                name: "mmm-local".to_string(),
2059                worktree_path: None,
2060                has_session: false,
2061                is_current: false,
2062                is_default: false,
2063                remote: None,
2064                session_activity_ts: None,
2065                agent_status: None,
2066            },
2067        ];
2068
2069        BranchEntry::sort_entries(&mut entries);
2070
2071        // Local branches first (alphabetical), then remote
2072        assert_eq!(entries[0].name, "mmm-local");
2073        assert!(entries[0].remote.is_none());
2074        assert_eq!(entries[1].name, "zzz-local");
2075        assert!(entries[1].remote.is_none());
2076        assert_eq!(entries[2].name, "aaa-remote");
2077        assert!(entries[2].remote.is_some());
2078    }
2079
2080    #[test]
2081    fn test_cwd_worktree_determines_current_branch() {
2082        let repo = Repo {
2083            name: "myrepo".to_string(),
2084            session_name: "myrepo".to_string(),
2085            path: PathBuf::from("/tmp/myrepo"),
2086            worktrees: vec![
2087                Worktree {
2088                    path: PathBuf::from("/tmp/myrepo"),
2089                    branch: Some("main".to_string()),
2090                    is_main: true,
2091                },
2092                Worktree {
2093                    path: PathBuf::from("/tmp/myrepo--feature"),
2094                    branch: Some("feature".to_string()),
2095                    is_main: false,
2096                },
2097            ],
2098        };
2099
2100        let branches = vec!["main".into(), "feature".into(), "dev".into()];
2101
2102        // CWD is in the feature worktree — feature should be current, not main
2103        let entries = BranchEntry::build_sorted_with_activity(
2104            &repo,
2105            &branches,
2106            &[],
2107            Some("main"),
2108            &HashMap::new(),
2109            Some(Path::new("/tmp/myrepo--feature")),
2110        );
2111
2112        assert_eq!(entries[0].name, "feature"); // current (CWD worktree)
2113        assert!(entries[0].is_current);
2114        assert_eq!(entries[1].name, "main"); // default
2115        assert!(entries[1].is_default);
2116        assert!(!entries[1].is_current);
2117        assert_eq!(entries[2].name, "dev");
2118    }
2119
2120    #[test]
2121    fn test_cwd_main_repo_marks_main_worktree_current() {
2122        let repo = Repo {
2123            name: "myrepo".to_string(),
2124            session_name: "myrepo".to_string(),
2125            path: PathBuf::from("/tmp/myrepo"),
2126            worktrees: vec![
2127                Worktree {
2128                    path: PathBuf::from("/tmp/myrepo"),
2129                    branch: Some("main".to_string()),
2130                    is_main: true,
2131                },
2132                Worktree {
2133                    path: PathBuf::from("/tmp/myrepo--feature"),
2134                    branch: Some("feature".to_string()),
2135                    is_main: false,
2136                },
2137            ],
2138        };
2139
2140        let branches = vec!["main".into(), "feature".into()];
2141
2142        // CWD is the main repo dir — main should be current
2143        let entries = BranchEntry::build_sorted_with_activity(
2144            &repo,
2145            &branches,
2146            &[],
2147            Some("main"),
2148            &HashMap::new(),
2149            Some(Path::new("/tmp/myrepo")),
2150        );
2151
2152        assert_eq!(entries[0].name, "main"); // current + default
2153        assert!(entries[0].is_current);
2154        assert_eq!(entries[1].name, "feature");
2155        assert!(!entries[1].is_current);
2156    }
2157
2158    #[test]
2159    fn test_cwd_unrelated_falls_back_to_main_worktree() {
2160        let repo = Repo {
2161            name: "myrepo".to_string(),
2162            session_name: "myrepo".to_string(),
2163            path: PathBuf::from("/tmp/myrepo"),
2164            worktrees: vec![
2165                Worktree {
2166                    path: PathBuf::from("/tmp/myrepo"),
2167                    branch: Some("main".to_string()),
2168                    is_main: true,
2169                },
2170                Worktree {
2171                    path: PathBuf::from("/tmp/myrepo--feature"),
2172                    branch: Some("feature".to_string()),
2173                    is_main: false,
2174                },
2175            ],
2176        };
2177
2178        let branches = vec!["main".into(), "feature".into()];
2179
2180        // CWD doesn't match any worktree — falls back to first (main)
2181        let entries = BranchEntry::build_sorted_with_activity(
2182            &repo,
2183            &branches,
2184            &[],
2185            Some("main"),
2186            &HashMap::new(),
2187            Some(Path::new("/tmp/unrelated-dir")),
2188        );
2189
2190        assert_eq!(entries[0].name, "main"); // current (fallback to first worktree)
2191        assert!(entries[0].is_current);
2192    }
2193
2194    #[test]
2195    fn test_build_remote_has_correct_defaults() {
2196        let remote = vec!["feat-x".into(), "feat-y".into()];
2197        let local: Vec<String> = vec![];
2198
2199        let entries = BranchEntry::build_remote("origin", &remote, &local);
2200
2201        assert_eq!(entries.len(), 2);
2202        for entry in &entries {
2203            assert!(!entry.is_default, "remote entries should not be default");
2204            assert!(
2205                entry.session_activity_ts.is_none(),
2206                "remote entries should have no activity ts"
2207            );
2208            assert!(
2209                entry.remote.is_some(),
2210                "remote entries should be marked remote"
2211            );
2212            assert!(!entry.has_session);
2213            assert!(!entry.is_current);
2214            assert!(entry.worktree_path.is_none());
2215        }
2216    }
2217
2218    #[test]
2219    fn test_active_list_points_to_help_overlay_in_help_mode() {
2220        let mut state = AppState::new(vec![make_repo(std::path::Path::new("/tmp"), "repo")], None);
2221        state.help_overlay = Some(HelpOverlayState {
2222            list: SearchableList::new(3),
2223            rows: Vec::new(),
2224        });
2225        state.mode = Mode::Help {
2226            previous: Box::new(Mode::RepoSelect),
2227        };
2228
2229        assert!(state.active_list().is_some());
2230        assert_eq!(state.active_list().and_then(|list| list.selected), Some(0));
2231
2232        if let Some(list) = state.active_list_mut() {
2233            list.move_selection(1);
2234        }
2235        assert_eq!(
2236            state
2237                .help_overlay
2238                .as_ref()
2239                .and_then(|overlay| overlay.list.selected),
2240            Some(1)
2241        );
2242    }
2243
2244    #[test]
2245    fn test_branch_entry_serde_round_trip() {
2246        let entry = BranchEntry {
2247            name: "feat/test".to_string(),
2248            worktree_path: Some(PathBuf::from("/tmp/repo-feat-test")),
2249            has_session: true,
2250            is_current: false,
2251            is_default: false,
2252            remote: None,
2253            session_activity_ts: Some(12345),
2254            agent_status: None,
2255        };
2256
2257        let json = serde_json::to_string(&entry).unwrap();
2258        let decoded: BranchEntry = serde_json::from_str(&json).unwrap();
2259        assert_eq!(decoded, entry);
2260    }
2261
2262    #[test]
2263    fn test_setup_state_new() {
2264        let setup = SetupState::new();
2265        assert!(setup.input.text.is_empty());
2266        assert_eq!(setup.input.cursor, 0);
2267        assert!(setup.completions.is_empty());
2268        assert!(setup.selected_completion.is_none());
2269        assert!(setup.dirs.is_empty());
2270    }
2271
2272    #[test]
2273    fn test_app_state_new_setup() {
2274        let state = AppState::new_setup();
2275        assert!(state.setup.is_some());
2276        assert_eq!(state.mode, Mode::Setup(SetupStep::Welcome));
2277        assert!(state.repos.is_empty());
2278    }
2279
2280    #[test]
2281    fn test_setup_step_supports_text_edit() {
2282        assert!(Mode::Setup(SetupStep::SearchDirs).supports_text_edit());
2283        assert!(!Mode::Setup(SetupStep::Welcome).supports_text_edit());
2284    }
2285
2286    #[test]
2287    fn test_setup_step_supports_modal() {
2288        assert!(Mode::Setup(SetupStep::Welcome).supports_modal_actions());
2289        assert!(Mode::Setup(SetupStep::SearchDirs).supports_modal_actions());
2290    }
2291
2292    #[test]
2293    fn test_set_error_collapses_newlines_to_spaces() {
2294        let mut state = AppState::new(Vec::new(), None);
2295        state.set_error("line one\nline two\nline three");
2296        assert_eq!(state.error.as_deref(), Some("line one line two line three"));
2297    }
2298
2299    #[test]
2300    fn test_set_error_collapses_carriage_return_newlines() {
2301        let mut state = AppState::new(Vec::new(), None);
2302        state.set_error("first\r\nsecond\r\nthird");
2303        assert_eq!(state.error.as_deref(), Some("first second third"));
2304    }
2305
2306    #[test]
2307    fn test_set_error_collapses_multiple_whitespace() {
2308        let mut state = AppState::new(Vec::new(), None);
2309        state.set_error("spaced   out\n\n\ntext");
2310        assert_eq!(state.error.as_deref(), Some("spaced out text"));
2311    }
2312
2313    #[test]
2314    fn test_clear_error() {
2315        let mut state = AppState::new(Vec::new(), None);
2316        state.set_error("something failed");
2317        assert!(state.error.is_some());
2318        state.clear_error();
2319        assert!(state.error.is_none());
2320    }
2321
2322    #[test]
2323    fn test_mode_effective_plain() {
2324        assert_eq!(*Mode::BranchSelect.effective(), Mode::BranchSelect);
2325        assert_eq!(*Mode::RepoSelect.effective(), Mode::RepoSelect);
2326    }
2327
2328    #[test]
2329    fn test_mode_effective_sees_through_help() {
2330        let mode = Mode::Help {
2331            previous: Box::new(Mode::BranchSelect),
2332        };
2333        assert_eq!(*mode.effective(), Mode::BranchSelect);
2334    }
2335
2336    #[test]
2337    fn test_mode_effective_nested_help() {
2338        let mode = Mode::Help {
2339            previous: Box::new(Mode::Help {
2340                previous: Box::new(Mode::RepoSelect),
2341            }),
2342        };
2343        assert_eq!(*mode.effective(), Mode::RepoSelect);
2344    }
2345
2346    #[test]
2347    fn test_agent_enabled_defaults_to_true() {
2348        let state = AppState::new(vec![], None);
2349        assert!(state.agent_enabled);
2350    }
2351
2352    #[test]
2353    fn test_agent_poll_interval_defaults_to_config_default() {
2354        let state = AppState::new(vec![], None);
2355        assert_eq!(
2356            state.agent_poll_interval,
2357            std::time::Duration::from_millis(500)
2358        );
2359    }
2360
2361    #[test]
2362    fn test_agent_poll_interval_can_be_overridden() {
2363        let mut state = AppState::new(vec![], None);
2364        state.agent_poll_interval = std::time::Duration::from_millis(5000);
2365        assert_eq!(
2366            state.agent_poll_interval,
2367            std::time::Duration::from_millis(5000)
2368        );
2369    }
2370
2371    #[test]
2372    fn test_agent_enabled_can_be_disabled() {
2373        let mut state = AppState::new(vec![], None);
2374        state.agent_enabled = false;
2375        assert!(!state.agent_enabled);
2376    }
2377
2378    #[test]
2379    fn test_agent_labels_stored_in_state() {
2380        let mut state = AppState::new(vec![], None);
2381        assert_eq!(state.agent_labels.running, "[RUNNING]");
2382        assert_eq!(state.agent_labels.waiting, "[WAITING]");
2383
2384        state.agent_labels = AgentLabelsConfig {
2385            running: "GO".to_string(),
2386            waiting: "PEND".to_string(),
2387            idle: "OFF".to_string(),
2388            unknown: "N/A".to_string(),
2389        };
2390        assert_eq!(state.agent_labels.running, "GO");
2391        assert_eq!(state.agent_labels.waiting, "PEND");
2392    }
2393}