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#[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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
272pub struct SearchableList {
273 pub input: TextInput,
274 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 pub fn search(&self) -> &str {
301 &self.input.text
302 }
303
304 pub fn cursor(&self) -> usize {
306 self.input.cursor
307 }
308
309 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
367#[allow(clippy::struct_excessive_bools)]
368pub struct BranchEntry {
369 pub name: String,
370 pub worktree_path: Option<PathBuf>,
372 pub has_session: bool,
373 pub is_current: bool,
374 pub is_default: bool,
376 pub remote: Option<String>,
378 pub session_activity_ts: Option<u64>,
380 pub agent_status: Option<AgentStatus>,
382}
383
384impl BranchEntry {
385 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 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 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 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 a.remote
516 .is_some()
517 .cmp(&b.remote.is_some())
518 .then(b.is_current.cmp(&a.is_current))
520 .then(b.is_default.cmp(&a.is_default))
522 .then(cmp_optional_recency(
524 a.session_activity_ts,
525 b.session_activity_ts,
526 ))
527 .then(b.has_session.cmp(&a.has_session))
529 .then(agent_sort_priority(b.agent_status).cmp(&agent_sort_priority(a.agent_status)))
531 .then(b.worktree_path.is_some().cmp(&a.worktree_path.is_some()))
533 .then(a.name.cmp(&b.name))
534 });
535 }
536}
537
538fn 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
551fn 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#[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 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
595fn 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#[derive(Debug, Clone, PartialEq, Eq)]
610pub enum SetupStep {
611 Welcome,
613 SearchDirs,
615}
616
617#[derive(Debug, Clone)]
619pub struct SetupState {
620 pub input: TextInput,
622 pub completions: Vec<String>,
624 pub selected_completion: Option<usize>,
626 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#[derive(Debug, Clone, PartialEq, Eq)]
649pub enum Mode {
650 RepoSelect,
651 BranchSelect,
652 SelectBaseBranch,
653 Loading(String),
655 ConfirmWorktreeDelete {
657 branch_name: String,
658 has_session: bool,
659 },
660 Help {
662 previous: Box<Mode>,
663 },
664 Setup(SetupStep),
666}
667
668impl Mode {
669 pub fn effective(&self) -> &Mode {
671 match self {
672 Mode::Help { previous } => previous.effective(),
673 other => other,
674 }
675 }
676
677 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#[derive(Debug, Clone)]
749pub struct BaseBranchSelection {
750 pub new_name: String,
752 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#[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 pub agent_poller_cancel: Option<Arc<AtomicBool>>,
791 pub agent_enabled: bool,
793 pub agent_poll_interval: std::time::Duration,
795 pub agent_labels: AgentLabelsConfig,
797 pub current_repo_path: Option<PathBuf>,
799 pub cwd_worktree_path: Option<PathBuf>,
801 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 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 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 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 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 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 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
977pub 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 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 assert_eq!(entries[0].name, "main");
1302 assert!(entries[0].is_current);
1303 assert!(entries[0].worktree_path.is_some());
1304
1305 assert_eq!(entries[1].name, "dev");
1307 assert!(entries[1].has_session);
1308 assert!(entries[1].worktree_path.is_some());
1309
1310 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 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 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 assert!(entries[0].remote.is_none()); assert!(entries[1].remote.is_none()); assert!(entries[2].remote.is_some()); assert!(entries[3].remote.is_some()); }
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 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 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 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 assert_eq!(entries[0].name, "main"); assert!(entries[0].is_current);
1590 assert!(entries[0].is_default);
1591 assert_eq!(entries[1].name, "hotfix"); assert_eq!(entries[2].name, "dev"); assert_eq!(entries[3].name, "feature"); }
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"); assert_eq!(entries[1].name, "main"); 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"); assert_eq!(repos[1].name, "zebra"); assert_eq!(repos[2].name, "alpha"); }
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 assert_eq!(repos[0].name, "mango"); assert_eq!(repos[1].name, "zebra"); assert_eq!(repos[2].name, "alpha"); }
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 activity.insert("repo-a".to_string(), 50);
1766 activity.insert("repo-a--feat".to_string(), 500);
1767 activity.insert("repo-b".to_string(), 200);
1769
1770 sort_repos(&mut repos, None, &activity);
1771
1772 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 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 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 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 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"); 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();
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 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, &activity, None,
1926 );
1927
1928 assert_eq!(entries[0].name, "alpha");
1930 assert_eq!(entries[1].name, "beta");
1932 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"); assert_eq!(entries[1].name, "wt-branch"); 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 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 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"); assert!(entries[0].is_current);
2114 assert_eq!(entries[1].name, "main"); 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 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"); 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 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"); 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}