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}