Skip to main content

wisp_core/
view.rs

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,            // informational row, not an actionable session
77    Session,         // regular tmux session (not in a worktree)
78    WorktreeSession, // session running in a worktree
79    Worktree,        // worktree with no session
80}
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
174/// Derives a session list that shows only worktree-related items.
175/// - Sessions in worktrees are shown as WorktreeSession
176/// - Worktrees without sessions are shown as Worktree
177/// - Regular sessions (not in any worktree) are excluded
178pub 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    // Build a map of worktree path -> worktree info for matching
189    let worktree_map: BTreeMap<&Path, &WorktreeInfo> =
190        worktrees.iter().map(|w| (w.path.as_path(), w)).collect();
191
192    // Find which worktree a path belongs to (if any)
193    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    // Process sessions: only include if they match a worktree
205    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    // Add worktrees that don't have matching sessions
252    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}