Skip to main content

wisp_app/
lib.rs

1use std::path::PathBuf;
2
3use wisp_config::{ResolvedConfig, UiMode};
4use wisp_core::{
5    AlertState, Candidate, CandidateId, ClientFocus, DirectoryRecord, DomainState, PaneRecord,
6    PreviewContent, PreviewKey, PreviewRequest, ResolvedAction, SessionRecord, SessionSortKey,
7    WindowRecord, deduplicate_candidates, derive_candidates, preview_request_for_candidate,
8    resolve_action, sort_candidates,
9};
10use wisp_tmux::TmuxSnapshot;
11use wisp_zoxide::DirectoryEntry;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum AppMode {
15    Popup,
16    Fullscreen,
17    Auto,
18}
19
20impl From<UiMode> for AppMode {
21    fn from(value: UiMode) -> Self {
22        match value {
23            UiMode::Popup => Self::Popup,
24            UiMode::Fullscreen => Self::Fullscreen,
25            UiMode::Auto => Self::Auto,
26        }
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum LoadState<T> {
32    Idle,
33    Loading,
34    Ready(T),
35    Failed(String),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum StatusLevel {
40    Info,
41    Warning,
42    Error,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StatusLine {
47    pub level: StatusLevel,
48    pub message: String,
49}
50
51impl Default for StatusLine {
52    fn default() -> Self {
53        Self {
54            level: StatusLevel::Info,
55            message: String::new(),
56        }
57    }
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct PreviewState {
62    pub generation: u64,
63    pub active_key: Option<PreviewKey>,
64    pub content: Option<PreviewContent>,
65    pub loading: bool,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct PendingTasks {
70    pub loading_sources: usize,
71    pub action_in_flight: bool,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum UserIntent {
76    MoveUp,
77    MoveDown,
78    QueryChanged(String),
79    ConfirmSelection,
80    Refresh,
81    ToggleHelp,
82    Cancel,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum AppCommand {
87    RequestPreview {
88        generation: u64,
89        request: PreviewRequest,
90    },
91    ExecuteAction(ResolvedAction),
92    RefreshSources,
93    Quit,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct CandidateBuildOptions {
98    pub home: Option<PathBuf>,
99    pub include_missing_directories: bool,
100}
101
102impl Default for CandidateBuildOptions {
103    fn default() -> Self {
104        Self {
105            home: std::env::var("HOME").ok().map(PathBuf::from),
106            include_missing_directories: false,
107        }
108    }
109}
110
111#[derive(Debug, Clone, PartialEq)]
112pub struct CandidateSources {
113    pub tmux: TmuxSnapshot,
114    pub zoxide: Vec<DirectoryEntry>,
115}
116
117#[derive(Debug, Clone, PartialEq)]
118pub enum AppEvent {
119    Startup,
120    Input(UserIntent),
121    CandidatesLoaded(Vec<Candidate>),
122    PreviewReady {
123        generation: u64,
124        key: PreviewKey,
125        result: Result<PreviewContent, String>,
126    },
127    ActionCompleted(Result<(), String>),
128    Quit,
129}
130
131#[derive(Debug, Clone, PartialEq)]
132pub struct AppState {
133    pub mode: AppMode,
134    pub config: ResolvedConfig,
135    pub candidates: Vec<Candidate>,
136    pub filtered: Vec<CandidateId>,
137    pub selection: usize,
138    pub query: String,
139    pub preview: PreviewState,
140    pub status: StatusLine,
141    pub pending_tasks: PendingTasks,
142    pub show_help: bool,
143}
144
145impl AppState {
146    #[must_use]
147    pub fn new(config: ResolvedConfig) -> Self {
148        Self {
149            mode: AppMode::from(config.ui.mode),
150            show_help: config.ui.show_help,
151            config,
152            candidates: Vec::new(),
153            filtered: Vec::new(),
154            selection: 0,
155            query: String::new(),
156            preview: PreviewState::default(),
157            status: StatusLine::default(),
158            pending_tasks: PendingTasks::default(),
159        }
160    }
161
162    pub fn replace_candidates(&mut self, candidates: Vec<Candidate>) {
163        let mut candidates = deduplicate_candidates(candidates);
164        sort_candidates(&mut candidates);
165        self.candidates = candidates;
166        self.refresh_filter();
167        self.status = StatusLine {
168            level: StatusLevel::Info,
169            message: format!("Loaded {} candidates", self.candidates.len()),
170        };
171    }
172
173    pub fn apply_query(&mut self, query: impl Into<String>) {
174        self.query = query.into();
175        self.refresh_filter();
176        self.status = StatusLine {
177            level: StatusLevel::Info,
178            message: format!("{} matches", self.filtered.len()),
179        };
180    }
181
182    pub fn move_selection(&mut self, delta: isize) {
183        if self.filtered.is_empty() {
184            self.selection = 0;
185            return;
186        }
187
188        let max_index = self.filtered.len().saturating_sub(1) as isize;
189        let next = (self.selection as isize + delta).clamp(0, max_index);
190        self.selection = next as usize;
191    }
192
193    #[must_use]
194    pub fn selected_candidate(&self) -> Option<&Candidate> {
195        let selected_id = self.filtered.get(self.selection)?;
196        self.candidates
197            .iter()
198            .find(|candidate| &candidate.id == selected_id)
199    }
200
201    pub fn request_preview(&mut self) -> Option<AppCommand> {
202        let candidate = self.selected_candidate()?;
203        let request = preview_request_for_candidate(candidate);
204
205        self.preview.generation += 1;
206        self.preview.active_key = Some(request.key().clone());
207        self.preview.loading = true;
208        self.preview.content = None;
209
210        Some(AppCommand::RequestPreview {
211            generation: self.preview.generation,
212            request,
213        })
214    }
215
216    pub fn handle_intent(&mut self, intent: UserIntent) -> Option<AppCommand> {
217        match intent {
218            UserIntent::MoveUp => {
219                self.move_selection(-1);
220                self.request_preview()
221            }
222            UserIntent::MoveDown => {
223                self.move_selection(1);
224                self.request_preview()
225            }
226            UserIntent::QueryChanged(query) => {
227                self.apply_query(query);
228                self.request_preview()
229            }
230            UserIntent::ConfirmSelection => self
231                .selected_candidate()
232                .and_then(resolve_action)
233                .map(AppCommand::ExecuteAction),
234            UserIntent::Refresh => Some(AppCommand::RefreshSources),
235            UserIntent::ToggleHelp => {
236                self.show_help = !self.show_help;
237                None
238            }
239            UserIntent::Cancel => Some(AppCommand::Quit),
240        }
241    }
242
243    pub fn apply_preview_result(
244        &mut self,
245        generation: u64,
246        key: &PreviewKey,
247        result: Result<PreviewContent, String>,
248    ) {
249        if generation != self.preview.generation || self.preview.active_key.as_ref() != Some(key) {
250            return;
251        }
252
253        self.preview.loading = false;
254        match result {
255            Ok(content) => {
256                self.preview.content = Some(content);
257            }
258            Err(message) => {
259                self.status = StatusLine {
260                    level: StatusLevel::Warning,
261                    message,
262                };
263            }
264        }
265    }
266
267    fn refresh_filter(&mut self) {
268        self.filtered = self
269            .candidates
270            .iter()
271            .filter(|candidate| candidate.matches_query(&self.query))
272            .map(|candidate| candidate.id.clone())
273            .collect();
274        self.selection = self.selection.min(self.filtered.len().saturating_sub(1));
275    }
276}
277
278#[must_use]
279pub fn rebuild_candidates(
280    sources: &CandidateSources,
281    options: &CandidateBuildOptions,
282) -> Vec<Candidate> {
283    let state = build_domain_state(sources);
284    derive_candidates(
285        &state,
286        options.home.as_deref(),
287        options.include_missing_directories,
288    )
289}
290
291#[must_use]
292pub fn build_domain_state(sources: &CandidateSources) -> DomainState {
293    let sessions = sources
294        .tmux
295        .sessions
296        .iter()
297        .map(|session| {
298            let windows = sources
299                .tmux
300                .windows
301                .iter()
302                .filter(|window| window.session_name == session.name)
303                .map(|window| {
304                    let pane_id = format!("{}:{}.1", session.name, window.index);
305                    (
306                        format!("{}:{}", session.name, window.index),
307                        WindowRecord {
308                            id: format!("{}:{}", session.name, window.index),
309                            index: window.index as i32,
310                            name: window.name.clone(),
311                            active: window.active,
312                            panes: std::collections::BTreeMap::from([(
313                                pane_id.clone(),
314                                PaneRecord {
315                                    id: pane_id,
316                                    index: 1,
317                                    title: None,
318                                    current_path: window.current_path.clone(),
319                                    current_command: window.current_command.clone(),
320                                    is_active: window.active,
321                                },
322                            )]),
323                            alerts: AlertState {
324                                activity: window.activity,
325                                bell: window.bell,
326                                silence: window.silence,
327                                unseen_output: false,
328                            },
329                            has_unseen: false,
330                            current_path: window.current_path.clone(),
331                            active_command: window.current_command.clone(),
332                        },
333                    )
334                })
335                .collect();
336
337            (
338                session.name.clone(),
339                SessionRecord {
340                    id: session.name.clone(),
341                    tmux_id: Some(session.id.clone()),
342                    name: session.name.clone(),
343                    attached: session.attached,
344                    windows,
345                    aggregate_alerts: Default::default(),
346                    has_unseen: false,
347                    sort_key: SessionSortKey {
348                        last_activity: session.last_activity,
349                    },
350                },
351            )
352        })
353        .collect();
354    let clients = sources
355        .tmux
356        .context
357        .session_name
358        .as_ref()
359        .zip(sources.tmux.context.window_index)
360        .map(|(session_name, window_index)| {
361            (
362                "default".to_string(),
363                ClientFocus {
364                    session_id: session_name.clone(),
365                    window_id: format!("{session_name}:{window_index}"),
366                    pane_id: sources.tmux.context.pane_id.clone(),
367                },
368            )
369        })
370        .into_iter()
371        .collect();
372    let directories = sources
373        .zoxide
374        .iter()
375        .map(|entry| DirectoryRecord {
376            path: entry.path.clone(),
377            score: entry.score,
378            exists: entry.exists,
379        })
380        .collect();
381
382    let mut state = DomainState {
383        sessions,
384        clients,
385        previous_session_by_client: Default::default(),
386        directories,
387        config: Default::default(),
388    };
389    state.recompute_aggregates();
390    state
391}
392
393#[cfg(test)]
394mod tests {
395    use std::path::PathBuf;
396
397    use wisp_config::ResolvedConfig;
398    use wisp_core::{
399        AttentionBadge, Candidate, DirectoryMetadata, PreviewContent, PreviewKey, SessionMetadata,
400    };
401    use wisp_tmux::{
402        TmuxCapabilities, TmuxContext, TmuxSession, TmuxSnapshot, TmuxVersion, TmuxWindow,
403    };
404    use wisp_zoxide::DirectoryEntry;
405
406    use crate::{
407        AppCommand, AppMode, AppState, CandidateBuildOptions, CandidateSources, StatusLevel,
408        UserIntent, build_domain_state, rebuild_candidates,
409    };
410
411    #[test]
412    fn bootstraps_from_config() {
413        let config = ResolvedConfig::default();
414
415        let state = AppState::new(config.clone());
416
417        assert_eq!(state.mode, AppMode::Auto);
418        assert!(state.show_help);
419        assert_eq!(state.config, config);
420    }
421
422    #[test]
423    fn query_changes_rebuild_the_filtered_set() {
424        let mut state = AppState::new(ResolvedConfig::default());
425        state.replace_candidates(vec![
426            Candidate::session(SessionMetadata {
427                session_name: "alpha".to_string(),
428                attached: false,
429                current: true,
430                window_count: 1,
431                last_activity: Some(10),
432            }),
433            Candidate::directory(DirectoryMetadata {
434                full_path: PathBuf::from("/tmp/project-beta"),
435                display_path: "/tmp/project-beta".to_string(),
436                zoxide_score: Some(5.0),
437                git_root_hint: None,
438                exists: true,
439            }),
440        ]);
441
442        state.apply_query("beta");
443
444        assert_eq!(state.filtered.len(), 1);
445        assert_eq!(state.selection, 0);
446    }
447
448    #[test]
449    fn selection_is_clamped_to_the_available_results() {
450        let mut state = AppState::new(ResolvedConfig::default());
451        state.replace_candidates(vec![Candidate::session(SessionMetadata {
452            session_name: "alpha".to_string(),
453            attached: false,
454            current: true,
455            window_count: 1,
456            last_activity: Some(10),
457        })]);
458
459        state.move_selection(5);
460
461        assert_eq!(state.selection, 0);
462    }
463
464    #[test]
465    fn confirm_selection_resolves_actions() {
466        let mut state = AppState::new(ResolvedConfig::default());
467        state.replace_candidates(vec![Candidate::directory(DirectoryMetadata {
468            full_path: PathBuf::from("/tmp/wisp"),
469            display_path: "/tmp/wisp".to_string(),
470            zoxide_score: Some(8.0),
471            git_root_hint: None,
472            exists: true,
473        })]);
474
475        let command = state.handle_intent(UserIntent::ConfirmSelection);
476
477        assert!(matches!(command, Some(AppCommand::ExecuteAction(_))));
478    }
479
480    #[test]
481    fn build_domain_state_preserves_tmux_alert_flags() {
482        let state = build_domain_state(&CandidateSources {
483            tmux: TmuxSnapshot {
484                context: TmuxContext {
485                    session_name: Some("alpha".to_string()),
486                    window_index: Some(1),
487                    ..TmuxContext::default()
488                },
489                capabilities: TmuxCapabilities {
490                    version: TmuxVersion {
491                        major: 3,
492                        minor: 6,
493                        patch: None,
494                    },
495                    supports_popup: true,
496                    supports_multi_status_lines: true,
497                    supports_status_mouse_ranges: true,
498                    mouse_enabled: true,
499                },
500                sessions: vec![TmuxSession {
501                    id: "$1".to_string(),
502                    name: "alpha".to_string(),
503                    attached: true,
504                    windows: 1,
505                    current: true,
506                    last_activity: Some(10),
507                }],
508                windows: vec![TmuxWindow {
509                    session_name: "alpha".to_string(),
510                    index: 1,
511                    name: "shell".to_string(),
512                    active: true,
513                    activity: false,
514                    bell: true,
515                    silence: false,
516                    current_path: Some(PathBuf::from("/tmp")),
517                    current_command: Some("bash".to_string()),
518                }],
519            },
520            zoxide: Vec::new(),
521        });
522
523        assert_eq!(
524            state.sessions["alpha"].aggregate_alerts.highest_priority,
525            AttentionBadge::Bell
526        );
527        assert!(state.sessions["alpha"].aggregate_alerts.any_bell);
528    }
529
530    #[test]
531    fn stale_preview_results_are_ignored() {
532        let mut state = AppState::new(ResolvedConfig::default());
533        state.replace_candidates(vec![Candidate::session(SessionMetadata {
534            session_name: "alpha".to_string(),
535            attached: false,
536            current: true,
537            window_count: 1,
538            last_activity: Some(10),
539        })]);
540
541        let command = state.request_preview().expect("preview request");
542        let AppCommand::RequestPreview {
543            generation,
544            request,
545        } = command
546        else {
547            panic!("expected preview request");
548        };
549
550        state.apply_preview_result(
551            generation + 1,
552            request.key(),
553            Ok(PreviewContent::from_text("preview", "ignored", 8)),
554        );
555
556        assert!(state.preview.content.is_none());
557
558        state.apply_preview_result(
559            generation,
560            request.key(),
561            Ok(PreviewContent::from_text("preview", "accepted", 8)),
562        );
563
564        let content = state.preview.content.expect("preview content");
565        assert_eq!(content.body, vec!["accepted".to_string()]);
566    }
567
568    #[test]
569    fn preview_failures_update_status_without_crashing() {
570        let mut state = AppState::new(ResolvedConfig::default());
571        state.preview.generation = 3;
572        state.preview.active_key = Some(PreviewKey::Session("alpha".to_string()));
573        state.preview.loading = true;
574
575        state.apply_preview_result(
576            3,
577            &PreviewKey::Session("alpha".to_string()),
578            Err("preview unavailable".to_string()),
579        );
580
581        assert_eq!(state.status.level, StatusLevel::Warning);
582        assert_eq!(state.status.message, "preview unavailable");
583        assert!(!state.preview.loading);
584    }
585
586    #[test]
587    fn rebuilds_unified_candidates_from_tmux_and_zoxide() {
588        let existing = std::env::temp_dir().join("wisp-app-candidate-existing");
589        std::fs::create_dir_all(&existing).expect("existing directory");
590        let sources = CandidateSources {
591            tmux: TmuxSnapshot {
592                context: TmuxContext::default(),
593                capabilities: TmuxCapabilities {
594                    version: TmuxVersion {
595                        major: 3,
596                        minor: 6,
597                        patch: None,
598                    },
599                    supports_popup: true,
600                    supports_multi_status_lines: true,
601                    supports_status_mouse_ranges: true,
602                    mouse_enabled: true,
603                },
604                sessions: vec![TmuxSession {
605                    id: "$1".to_string(),
606                    name: "alpha".to_string(),
607                    attached: true,
608                    windows: 2,
609                    current: true,
610                    last_activity: Some(5),
611                }],
612                windows: Vec::new(),
613            },
614            zoxide: vec![DirectoryEntry {
615                path: existing.clone(),
616                score: Some(10.0),
617                exists: true,
618            }],
619        };
620
621        let candidates = rebuild_candidates(
622            &sources,
623            &CandidateBuildOptions {
624                home: None,
625                include_missing_directories: false,
626            },
627        );
628
629        assert_eq!(candidates.len(), 2);
630        assert!(
631            candidates
632                .iter()
633                .any(|candidate| candidate.primary_text == "alpha")
634        );
635        assert!(
636            candidates
637                .iter()
638                .any(|candidate| candidate.primary_text == existing.display().to_string())
639        );
640
641        let _ = std::fs::remove_dir_all(existing);
642    }
643
644    #[test]
645    fn omits_missing_zoxide_directories_by_default() {
646        let sources = CandidateSources {
647            tmux: TmuxSnapshot {
648                context: TmuxContext::default(),
649                capabilities: TmuxCapabilities {
650                    version: TmuxVersion {
651                        major: 3,
652                        minor: 6,
653                        patch: None,
654                    },
655                    supports_popup: true,
656                    supports_multi_status_lines: true,
657                    supports_status_mouse_ranges: true,
658                    mouse_enabled: true,
659                },
660                sessions: Vec::new(),
661                windows: Vec::new(),
662            },
663            zoxide: vec![DirectoryEntry {
664                path: PathBuf::from("/path/that/does/not/exist"),
665                score: Some(99.0),
666                exists: false,
667            }],
668        };
669
670        let candidates = rebuild_candidates(&sources, &CandidateBuildOptions::default());
671
672        assert!(candidates.is_empty());
673    }
674}