1use std::path::{Path, PathBuf};
2
3use crate::{
4 AttentionBadge, Candidate, DirectoryMetadata, DirectoryRecord, DomainState, SessionMetadata,
5 deduplicate_candidates, normalize_display_path, sort_candidates,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SessionListItem {
10 pub session_id: String,
11 pub label: String,
12 pub kind: SessionListItemKind,
13 pub is_current: bool,
14 pub is_previous: bool,
15 pub last_activity: Option<u64>,
16 pub attached: bool,
17 pub attention: AttentionBadge,
18 pub attention_count: usize,
19 pub active_window_label: Option<String>,
20 pub path_hint: Option<String>,
21 pub command_hint: Option<String>,
22 pub git_branch: Option<GitBranchStatus>,
23 pub worktree_path: Option<PathBuf>,
24 pub worktree_branch: Option<String>,
25}
26
27impl SessionListItem {
28 #[must_use]
29 pub fn picker_search_text(&self) -> String {
30 [
31 Some(self.label.as_str()),
32 self.active_window_label.as_deref(),
33 self.path_hint.as_deref(),
34 self.command_hint.as_deref(),
35 self.git_branch.as_ref().map(|branch| branch.name.as_str()),
36 self.worktree_branch.as_deref(),
37 ]
38 .into_iter()
39 .flatten()
40 .filter(|value| !value.is_empty())
41 .collect::<Vec<_>>()
42 .join(" ")
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct GitBranchStatus {
48 pub name: String,
49 pub sync: GitBranchSync,
50 pub dirty: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct WorktreeInfo {
55 pub path: PathBuf,
56 pub branch: Option<String>,
57 pub is_locked: bool,
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub enum PickerMode {
62 #[default]
63 AllSessions,
64 Worktree,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum GitBranchSync {
69 Unknown,
70 Pushed,
71 NotPushed,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum SessionListItemKind {
76 Info, Session, WorktreeSession, Worktree, }
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct StatusSessionItem {
84 pub session_id: String,
85 pub session_name: String,
86 pub is_current: bool,
87 pub is_previous: bool,
88 pub badge: AttentionBadge,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum SessionListSortMode {
93 Recent,
94 Alphabetical,
95}
96
97#[must_use]
98pub fn derive_candidates(
99 state: &DomainState,
100 home: Option<&Path>,
101 include_missing_directories: bool,
102) -> Vec<Candidate> {
103 let mut candidates = state
104 .sessions
105 .iter()
106 .map(|(session_id, session)| {
107 Candidate::session(SessionMetadata {
108 session_name: session.name.clone(),
109 attached: session.attached,
110 current: state.current_session_id(None) == Some(session_id),
111 window_count: session.windows.len(),
112 last_activity: session.sort_key.last_activity,
113 })
114 })
115 .collect::<Vec<_>>();
116
117 candidates.extend(
118 state
119 .directories
120 .iter()
121 .filter(|entry| include_missing_directories || entry.exists)
122 .map(|entry| directory_candidate(entry, home)),
123 );
124
125 let mut candidates = deduplicate_candidates(candidates);
126 sort_candidates(&mut candidates);
127 candidates
128}
129
130#[must_use]
131pub fn derive_session_list(state: &DomainState, client_id: Option<&str>) -> Vec<SessionListItem> {
132 let current = state.current_session_id(client_id);
133 let previous = state.previous_session_id(client_id);
134
135 let mut items = state
136 .sessions
137 .iter()
138 .map(|(session_id, session)| {
139 let active_window = session
140 .windows
141 .values()
142 .find(|window| window.active)
143 .or_else(|| session.windows.values().next());
144
145 SessionListItem {
146 session_id: session_id.clone(),
147 label: session.name.clone(),
148 kind: SessionListItemKind::Session,
149 is_current: current == Some(session_id),
150 is_previous: previous == Some(session_id),
151 last_activity: session.sort_key.last_activity,
152 attached: session.attached,
153 attention: session.aggregate_alerts.highest_priority,
154 attention_count: session.aggregate_alerts.attention_count,
155 active_window_label: active_window.map(|window| window.name.clone()),
156 path_hint: active_window.and_then(|window| {
157 window
158 .current_path
159 .as_deref()
160 .map(|path| normalize_display_path(path, None))
161 }),
162 command_hint: active_window.and_then(|window| window.active_command.clone()),
163 git_branch: None,
164 worktree_path: None,
165 worktree_branch: None,
166 }
167 })
168 .collect::<Vec<_>>();
169
170 sort_session_list_items(&mut items, SessionListSortMode::Recent);
171 items
172}
173
174pub fn derive_session_list_with_worktrees(
179 state: &DomainState,
180 client_id: Option<&str>,
181 worktrees: &[WorktreeInfo],
182) -> Vec<SessionListItem> {
183 use std::collections::{BTreeMap, BTreeSet};
184
185 let current = state.current_session_id(client_id);
186 let previous = state.previous_session_id(client_id);
187
188 let worktree_map: BTreeMap<&Path, &WorktreeInfo> =
190 worktrees.iter().map(|w| (w.path.as_path(), w)).collect();
191
192 fn find_worktree_for_path<'a>(
194 path: &Path,
195 worktree_map: &'a BTreeMap<&Path, &WorktreeInfo>,
196 ) -> Option<&'a WorktreeInfo> {
197 worktree_map
198 .iter()
199 .filter(|(wt_path, _)| path == **wt_path || path.starts_with(*wt_path))
200 .max_by_key(|(wt_path, _)| wt_path.as_os_str().len())
201 .map(|(_, wt)| *wt)
202 }
203
204 let mut items: Vec<SessionListItem> = state
206 .sessions
207 .iter()
208 .filter_map(|(session_id, session)| {
209 let active_window = session
210 .windows
211 .values()
212 .find(|window| window.active)
213 .or_else(|| session.windows.values().next());
214
215 let current_path = active_window.and_then(|w| w.current_path.as_deref());
216
217 let worktree = current_path.and_then(|p| find_worktree_for_path(p, &worktree_map))?;
218
219 Some(SessionListItem {
220 session_id: session_id.clone(),
221 label: session.name.clone(),
222 kind: SessionListItemKind::WorktreeSession,
223 is_current: current == Some(session_id),
224 is_previous: previous == Some(session_id),
225 last_activity: session.sort_key.last_activity,
226 attached: session.attached,
227 attention: session.aggregate_alerts.highest_priority,
228 attention_count: session.aggregate_alerts.attention_count,
229 active_window_label: active_window.map(|window| window.name.clone()),
230 path_hint: active_window.and_then(|window| {
231 window
232 .current_path
233 .as_deref()
234 .map(|path| normalize_display_path(path, None))
235 }),
236 command_hint: active_window.and_then(|window| window.active_command.clone()),
237 git_branch: Some(GitBranchStatus {
238 name: worktree
239 .branch
240 .clone()
241 .unwrap_or_else(|| "(detached)".to_string()),
242 sync: GitBranchSync::Unknown,
243 dirty: false,
244 }),
245 worktree_path: Some(worktree.path.clone()),
246 worktree_branch: worktree.branch.clone(),
247 })
248 })
249 .collect();
250
251 let session_paths: BTreeSet<&Path> = state
253 .sessions
254 .iter()
255 .filter_map(|(_, session)| {
256 session
257 .windows
258 .values()
259 .find(|window| window.active)
260 .or_else(|| session.windows.values().next())
261 .and_then(|w| w.current_path.as_deref())
262 })
263 .collect();
264
265 for worktree in worktrees {
266 let matched_session = session_paths.iter().any(|path| {
267 *path == worktree.path.as_path() || path.starts_with(worktree.path.as_path())
268 });
269
270 if !matched_session {
271 let basename = worktree
272 .path
273 .file_name()
274 .and_then(|n| n.to_str())
275 .unwrap_or("unknown")
276 .to_string();
277
278 items.push(SessionListItem {
279 session_id: format!("worktree:{}", worktree.path.display()),
280 label: basename,
281 kind: SessionListItemKind::Worktree,
282 is_current: false,
283 is_previous: false,
284 last_activity: None,
285 attached: false,
286 attention: AttentionBadge::None,
287 attention_count: 0,
288 active_window_label: None,
289 path_hint: Some(normalize_display_path(&worktree.path, None)),
290 command_hint: None,
291 git_branch: Some(GitBranchStatus {
292 name: worktree.branch.clone().unwrap_or_default(),
293 sync: GitBranchSync::Unknown,
294 dirty: false,
295 }),
296 worktree_path: Some(worktree.path.clone()),
297 worktree_branch: worktree.branch.clone(),
298 });
299 }
300 }
301
302 items
303}
304
305#[must_use]
306pub fn derive_status_items(state: &DomainState, client_id: Option<&str>) -> Vec<StatusSessionItem> {
307 let current = state.current_session_id(client_id);
308 let previous = state.previous_session_id(client_id);
309
310 let mut items = state
311 .sessions
312 .iter()
313 .map(|(session_id, session)| StatusSessionItem {
314 session_id: session
315 .tmux_id
316 .clone()
317 .unwrap_or_else(|| session_id.clone()),
318 session_name: session.name.clone(),
319 is_current: current == Some(session_id),
320 is_previous: previous == Some(session_id),
321 badge: session.aggregate_alerts.highest_priority,
322 })
323 .collect::<Vec<_>>();
324
325 items.sort_by(|left, right| left.session_name.cmp(&right.session_name));
326 items
327}
328
329pub fn sort_session_list_items(items: &mut [SessionListItem], mode: SessionListSortMode) {
330 match mode {
331 SessionListSortMode::Recent => items.sort_by(recent_session_cmp),
332 SessionListSortMode::Alphabetical => {
333 items.sort_by(|left, right| left.label.cmp(&right.label));
334 }
335 }
336}
337
338fn recent_session_cmp(left: &SessionListItem, right: &SessionListItem) -> std::cmp::Ordering {
339 right
340 .is_current
341 .cmp(&left.is_current)
342 .then_with(|| right.is_previous.cmp(&left.is_previous))
343 .then_with(|| right.last_activity.cmp(&left.last_activity))
344 .then_with(|| right.attention.cmp(&left.attention))
345 .then_with(|| left.label.cmp(&right.label))
346}
347
348fn directory_candidate(entry: &DirectoryRecord, home: Option<&Path>) -> Candidate {
349 Candidate::directory(DirectoryMetadata {
350 full_path: entry.path.clone(),
351 display_path: normalize_display_path(&entry.path, home),
352 zoxide_score: entry.score,
353 git_root_hint: None,
354 exists: entry.exists,
355 })
356}
357
358#[cfg(test)]
359mod tests {
360 use std::{collections::BTreeMap, path::PathBuf};
361
362 use crate::{
363 AlertAggregate, AttentionBadge, ClientFocus, DirectoryRecord, DomainState, GitBranchStatus,
364 GitBranchSync, SessionListItem, SessionListItemKind, SessionListSortMode, SessionRecord,
365 SessionSortKey, WindowRecord, WorktreeInfo, derive_candidates, derive_session_list,
366 derive_session_list_with_worktrees, derive_status_items, sort_session_list_items,
367 };
368
369 fn seeded_state() -> DomainState {
370 DomainState {
371 sessions: BTreeMap::from([(
372 "alpha".to_string(),
373 SessionRecord {
374 id: "alpha".to_string(),
375 tmux_id: Some("$1".to_string()),
376 name: "alpha".to_string(),
377 attached: true,
378 windows: BTreeMap::from([(
379 "alpha:1".to_string(),
380 WindowRecord {
381 id: "alpha:1".to_string(),
382 index: 1,
383 name: "shell".to_string(),
384 active: true,
385 panes: BTreeMap::new(),
386 alerts: Default::default(),
387 has_unseen: false,
388 current_path: Some(PathBuf::from("/tmp/alpha")),
389 active_command: Some("nvim".to_string()),
390 },
391 )]),
392 aggregate_alerts: AlertAggregate {
393 any_activity: true,
394 any_bell: false,
395 any_silence: false,
396 any_unseen: false,
397 attention_count: 1,
398 highest_priority: AttentionBadge::Activity,
399 },
400 has_unseen: false,
401 sort_key: SessionSortKey {
402 last_activity: Some(10),
403 },
404 },
405 )]),
406 clients: BTreeMap::from([(
407 "client-1".to_string(),
408 ClientFocus {
409 session_id: "alpha".to_string(),
410 window_id: "alpha:1".to_string(),
411 pane_id: None,
412 },
413 )]),
414 previous_session_by_client: BTreeMap::from([(
415 "client-1".to_string(),
416 "beta".to_string(),
417 )]),
418 directories: vec![DirectoryRecord {
419 path: PathBuf::from("/tmp/project"),
420 score: Some(5.0),
421 exists: true,
422 }],
423 ..DomainState::default()
424 }
425 }
426
427 #[test]
428 fn derives_candidates_from_canonical_state() {
429 let candidates = derive_candidates(&seeded_state(), None, false);
430
431 assert_eq!(candidates.len(), 2);
432 assert!(
433 candidates
434 .iter()
435 .any(|candidate| candidate.primary_text == "alpha")
436 );
437 assert!(
438 candidates
439 .iter()
440 .any(|candidate| candidate.primary_text == "/tmp/project")
441 );
442 }
443
444 #[test]
445 fn derives_session_list_markers() {
446 let items = derive_session_list(&seeded_state(), Some("client-1"));
447
448 assert_eq!(items.len(), 1);
449 assert!(items[0].is_current);
450 assert_eq!(items[0].attention, AttentionBadge::Activity);
451 }
452
453 #[test]
454 fn derives_status_items_from_session_projection() {
455 let items = derive_status_items(&seeded_state(), Some("client-1"));
456
457 assert_eq!(items[0].session_id, "$1");
458 assert_eq!(items[0].session_name, "alpha");
459 assert!(items[0].is_current);
460 }
461
462 #[test]
463 fn derives_status_items_in_alphabetical_order() {
464 let mut state = seeded_state();
465 state.sessions.insert(
466 "beta".to_string(),
467 SessionRecord {
468 id: "beta".to_string(),
469 tmux_id: Some("$2".to_string()),
470 name: "beta".to_string(),
471 attached: false,
472 windows: BTreeMap::new(),
473 aggregate_alerts: AlertAggregate::default(),
474 has_unseen: false,
475 sort_key: SessionSortKey::default(),
476 },
477 );
478 state.sessions.insert(
479 "aardvark".to_string(),
480 SessionRecord {
481 id: "aardvark".to_string(),
482 tmux_id: Some("$3".to_string()),
483 name: "aardvark".to_string(),
484 attached: false,
485 windows: BTreeMap::new(),
486 aggregate_alerts: AlertAggregate::default(),
487 has_unseen: false,
488 sort_key: SessionSortKey::default(),
489 },
490 );
491
492 let items = derive_status_items(&state, Some("client-1"));
493 let names = items
494 .into_iter()
495 .map(|item| item.session_name)
496 .collect::<Vec<_>>();
497
498 assert_eq!(names, vec!["aardvark", "alpha", "beta"]);
499 }
500
501 #[test]
502 fn sorts_session_lists_by_requested_mode() {
503 let mut items = vec![
504 SessionListItem {
505 session_id: "beta".to_string(),
506 label: "beta".to_string(),
507 kind: SessionListItemKind::Session,
508 is_current: false,
509 is_previous: true,
510 last_activity: Some(2),
511 attached: false,
512 attention: AttentionBadge::None,
513 attention_count: 0,
514 active_window_label: None,
515 path_hint: None,
516 command_hint: None,
517 git_branch: None,
518 worktree_path: None,
519 worktree_branch: None,
520 },
521 SessionListItem {
522 session_id: "alpha".to_string(),
523 label: "alpha".to_string(),
524 kind: SessionListItemKind::Session,
525 is_current: true,
526 is_previous: false,
527 last_activity: Some(3),
528 attached: false,
529 attention: AttentionBadge::None,
530 attention_count: 0,
531 active_window_label: None,
532 path_hint: None,
533 command_hint: None,
534 git_branch: None,
535 worktree_path: None,
536 worktree_branch: None,
537 },
538 SessionListItem {
539 session_id: "aardvark".to_string(),
540 label: "aardvark".to_string(),
541 kind: SessionListItemKind::Session,
542 is_current: false,
543 is_previous: false,
544 last_activity: Some(1),
545 attached: false,
546 attention: AttentionBadge::None,
547 attention_count: 0,
548 active_window_label: None,
549 path_hint: None,
550 command_hint: None,
551 git_branch: None,
552 worktree_path: None,
553 worktree_branch: None,
554 },
555 ];
556
557 sort_session_list_items(&mut items, SessionListSortMode::Recent);
558 assert_eq!(
559 items
560 .iter()
561 .map(|item| item.label.as_str())
562 .collect::<Vec<_>>(),
563 vec!["alpha", "beta", "aardvark"]
564 );
565
566 sort_session_list_items(&mut items, SessionListSortMode::Alphabetical);
567 assert_eq!(
568 items
569 .iter()
570 .map(|item| item.label.as_str())
571 .collect::<Vec<_>>(),
572 vec!["aardvark", "alpha", "beta"]
573 );
574 }
575
576 #[test]
577 fn picker_search_text_includes_git_branch_name() {
578 let item = SessionListItem {
579 session_id: "alpha".to_string(),
580 label: "alpha".to_string(),
581 kind: SessionListItemKind::Session,
582 is_current: false,
583 is_previous: false,
584 last_activity: None,
585 attached: false,
586 attention: AttentionBadge::None,
587 attention_count: 0,
588 active_window_label: Some("editor".to_string()),
589 path_hint: None,
590 command_hint: Some("nvim".to_string()),
591 git_branch: Some(GitBranchStatus {
592 name: "feature/picker-branches".to_string(),
593 sync: GitBranchSync::Unknown,
594 dirty: false,
595 }),
596 worktree_path: None,
597 worktree_branch: None,
598 };
599
600 assert_eq!(
601 item.picker_search_text(),
602 "alpha editor nvim feature/picker-branches"
603 );
604 }
605
606 #[test]
607 fn picker_search_text_includes_path_hint() {
608 let item = SessionListItem {
609 session_id: "worktree:/tmp/demo/app".to_string(),
610 label: "app".to_string(),
611 kind: SessionListItemKind::Worktree,
612 is_current: false,
613 is_previous: false,
614 last_activity: None,
615 attached: false,
616 attention: AttentionBadge::None,
617 attention_count: 0,
618 active_window_label: None,
619 path_hint: Some("~/src/demo/app".to_string()),
620 command_hint: None,
621 git_branch: None,
622 worktree_path: Some(PathBuf::from("/tmp/demo/app")),
623 worktree_branch: Some("feature/demo".to_string()),
624 };
625
626 assert_eq!(item.picker_search_text(), "app ~/src/demo/app feature/demo");
627 }
628
629 #[test]
630 fn omits_worktree_rows_when_a_session_path_is_nested_inside_the_worktree() {
631 let state = seeded_state();
632 let worktrees = vec![WorktreeInfo {
633 path: PathBuf::from("/tmp"),
634 branch: Some("main".to_string()),
635 is_locked: false,
636 }];
637
638 let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees);
639
640 assert_eq!(items.len(), 1);
641 assert_eq!(items[0].kind, SessionListItemKind::WorktreeSession);
642 assert_eq!(
643 items[0].worktree_path.as_deref(),
644 Some(std::path::Path::new("/tmp"))
645 );
646 }
647
648 #[test]
649 fn picks_the_deepest_matching_worktree_for_nested_paths() {
650 let state = seeded_state();
651 let worktrees = vec![
652 WorktreeInfo {
653 path: PathBuf::from("/tmp"),
654 branch: Some("root".to_string()),
655 is_locked: false,
656 },
657 WorktreeInfo {
658 path: PathBuf::from("/tmp/alpha"),
659 branch: Some("nested".to_string()),
660 is_locked: false,
661 },
662 ];
663
664 let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees);
665
666 assert_eq!(items.len(), 1);
667 assert_eq!(items[0].kind, SessionListItemKind::WorktreeSession);
668 assert_eq!(
669 items[0].worktree_path.as_deref(),
670 Some(std::path::Path::new("/tmp/alpha"))
671 );
672 assert_eq!(items[0].worktree_branch.as_deref(), Some("nested"));
673 }
674
675 #[test]
676 fn detached_worktrees_still_get_git_branch_status_placeholders() {
677 let state = seeded_state();
678 let worktrees = vec![WorktreeInfo {
679 path: PathBuf::from("/tmp/detached"),
680 branch: None,
681 is_locked: false,
682 }];
683
684 let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees);
685 let detached = items
686 .into_iter()
687 .find(|item| item.kind == SessionListItemKind::Worktree)
688 .expect("detached worktree row");
689
690 assert_eq!(
691 detached.git_branch,
692 Some(GitBranchStatus {
693 name: String::new(),
694 sync: GitBranchSync::Unknown,
695 dirty: false,
696 })
697 );
698 }
699}