Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-tui/src/widgets/command_palette.rs
Line
Count
Source
1
use std::any::Any;
2
3
use crossterm::event::{KeyCode, KeyEvent};
4
use flotilla_commands::{HostResolution, RepoContext, Resolved};
5
use flotilla_protocol::{Command, CommandAction, HostName, ProvisioningTarget, RepoIdentity, RepoSelector, WorkItem};
6
use ratatui::{
7
    layout::Rect,
8
    style::{Modifier, Style},
9
    text::{Line, Span},
10
    widgets::{Block, Clear, Paragraph},
11
    Frame,
12
};
13
use tui_input::{backend::crossterm::EventHandler as InputEventHandler, Input};
14
15
use super::{AppAction, InteractiveWidget, Outcome, RenderContext, WidgetContext};
16
use crate::{
17
    app::TuiModel,
18
    binding_table::{BindingModeId, KeyBindingMode, StatusContent, StatusFragment},
19
    keymap::Action,
20
    palette::{self, PaletteCompletion, PaletteEntry, PaletteLocalResult, PaletteParseResult, MAX_PALETTE_ROWS},
21
};
22
23
/// Map a work item to a palette pre-fill string.
24
7
pub fn palette_prefill(item: &WorkItem) -> Option<String> {
25
7
    if let Some(
cr_key2
) = &item.change_request_key {
26
2
        return Some(format!("cr {} ", cr_key));
27
5
    }
28
5
    if let Some(
branch2
) = &item.branch {
29
2
        if item.checkout_key().is_some() {
30
1
            return Some(format!("checkout {} ", branch));
31
1
        }
32
3
    }
33
4
    if let Some(
issue_key1
) = item.issue_keys.first() {
34
1
        return Some(format!("issue {} ", issue_key));
35
3
    }
36
3
    if let Some(
session_key1
) = &item.session_key {
37
1
        return Some(format!("agent {} ", session_key));
38
2
    }
39
2
    if let Some(
ws_ref1
) = item.workspace_refs.first() {
40
1
        return Some(format!("workspace {} ", ws_ref));
41
1
    }
42
1
    None
43
7
}
44
45
pub struct CommandPaletteWidget {
46
    input: Input,
47
    entries: &'static [PaletteEntry],
48
    selected: usize,
49
    scroll_top: usize,
50
    /// The work item that was selected when the contextual palette was opened.
51
    /// Used by tui_dispatch for SubjectHost/ProviderHost resolution.
52
    source_item: Option<WorkItem>,
53
}
54
55
impl Default for CommandPaletteWidget {
56
0
    fn default() -> Self {
57
0
        Self::new()
58
0
    }
59
}
60
61
impl CommandPaletteWidget {
62
40
    pub fn new() -> Self {
63
40
        Self { input: Input::default(), entries: palette::all_entries(), selected: 0, scroll_top: 0, source_item: None }
64
40
    }
65
66
    /// Create a palette widget with pre-filled input text and selection.
67
2
    pub fn with_state(input: Input, selected: usize, scroll_top: usize) -> Self {
68
2
        Self { input, entries: palette::all_entries(), selected, scroll_top, source_item: None }
69
2
    }
70
71
    /// Create a palette widget with a pre-filled input string (cursor at end)
72
    /// and the work item that was selected when the palette was opened.
73
0
    pub fn with_prefill(text: impl AsRef<str>, item: Option<WorkItem>) -> Self {
74
0
        Self { input: Input::from(text.as_ref()), entries: palette::all_entries(), selected: 0, scroll_top: 0, source_item: item }
75
0
    }
76
77
1
    fn filtered(&self) -> Vec<&'static PaletteEntry> {
78
1
        palette::filter_entries(self.entries, self.input.value())
79
1
    }
80
81
    /// Compute position-aware completions using model context.
82
39
    fn completions(&self, model: &TuiModel, has_repo_context: bool) -> Vec<PaletteCompletion> {
83
39
        palette::palette_completions(self.input.value(), model, has_repo_context)
84
39
    }
85
86
    /// Fill the selected completion value into the input, appending to the
87
    /// existing prefix (everything before the token being completed).
88
2
    fn fill_completion(&mut self, completion: &PaletteCompletion) {
89
2
        let input = self.input.value();
90
2
        let trailing_space = input.ends_with(' ');
91
2
        let tokens = palette::tokenize_palette_input(input).unwrap_or_default();
92
93
        // Determine prefix: everything before the token being completed.
94
2
        let prefix = if trailing_space || tokens.is_empty() {
95
            // Cursor is after a space — completion replaces nothing, just append.
96
2
            input.to_string()
97
        } else {
98
            // The last token is a partial — slice input at its start offset.
99
0
            let last = tokens.last().expect("tokens is non-empty");
100
0
            input[..last.offset].to_string()
101
        };
102
103
2
        let filled = format!("{}{} ", prefix, completion.value);
104
2
        self.input = Input::from(filled.as_str());
105
2
        self.selected = 0;
106
2
        self.scroll_top = 0;
107
2
    }
108
109
29
    fn adjust_scroll(&mut self) {
110
29
        let max_visible = MAX_PALETTE_ROWS;
111
29
        if self.selected >= self.scroll_top + max_visible {
112
16
            self.scroll_top = self.selected.saturating_sub(max_visible - 1);
113
16
        } else if 
self.selected < self.scroll_top13
{
114
1
            self.scroll_top = self.selected;
115
12
        }
116
29
    }
117
118
19
    fn confirm(&mut self, ctx: &mut WidgetContext) -> Outcome {
119
19
        let text = self.input.value().to_string();
120
121
19
        match palette::parse_palette_input(&text) {
122
15
            Ok(PaletteParseResult::Local(local)) => self.dispatch_local(local, ctx),
123
3
            Ok(PaletteParseResult::Resolved(resolved)) => self.dispatch_resolved(resolved, ctx),
124
1
            Err(err) => {
125
                // If parse failed, fall back to the selected entry's action (fuzzy match)
126
1
                let filtered = self.filtered();
127
1
                if let Some(entry) = filtered.get(self.selected) {
128
1
                    let action = entry.action;
129
1
                    return self.dispatch_palette_action(action, ctx);
130
0
                }
131
0
                ctx.app_actions.push(AppAction::ShowStatus(err));
132
0
                Outcome::Finished
133
            }
134
        }
135
19
    }
136
137
15
    fn dispatch_local(&mut self, local: PaletteLocalResult<'_>, ctx: &mut WidgetContext) -> Outcome {
138
15
        match local {
139
7
            PaletteLocalResult::Action(action) => self.dispatch_palette_action(action, ctx),
140
1
            PaletteLocalResult::SetLayout(name) => {
141
1
                ctx.app_actions.push(AppAction::SetLayout(name.to_string()));
142
1
                Outcome::Finished
143
            }
144
1
            PaletteLocalResult::SetTheme(name) => {
145
1
                ctx.app_actions.push(AppAction::SetTheme(name.to_string()));
146
1
                Outcome::Finished
147
            }
148
1
            PaletteLocalResult::SetTarget(name) => {
149
1
                ctx.app_actions.push(AppAction::SetTarget(name.to_string()));
150
1
                Outcome::Finished
151
            }
152
5
            PaletteLocalResult::Search(query) => {
153
5
                if *ctx.is_config {
154
0
                    ctx.app_actions.push(AppAction::ShowStatus("switch to a repo tab first".into()));
155
0
                    return Outcome::Finished;
156
5
                }
157
5
                let query = query.trim().to_string();
158
5
                let Some(
repo_identity4
) = ctx.model.active_repo_identity_opt().cloned() else {
159
1
                    ctx.app_actions.push(AppAction::ShowStatus("No active repo".into()));
160
1
                    return Outcome::Finished;
161
                };
162
4
                if query.is_empty() {
163
2
                    ctx.app_actions.push(AppAction::ClearSearchQuery { repo: repo_identity });
164
2
                } else {
165
2
                    let cmd = Command {
166
2
                        host: None,
167
2
                        provisioning_target: None,
168
2
                        context_repo: None,
169
2
                        action: CommandAction::QueryIssues {
170
2
                            repo: RepoSelector::Identity(repo_identity.clone()),
171
2
                            params: flotilla_protocol::issue_query::IssueQuery { search: Some(query.clone()) },
172
2
                            page: 1,
173
2
                            count: 50,
174
2
                        },
175
2
                    };
176
2
                    ctx.commands.push(cmd);
177
2
                    ctx.app_actions.push(AppAction::SetSearchQuery { repo: repo_identity, query });
178
2
                }
179
4
                Outcome::Finished
180
            }
181
        }
182
15
    }
183
184
3
    fn dispatch_resolved(&self, resolved: Resolved, ctx: &mut WidgetContext) -> Outcome {
185
3
        let active_repo = ctx.model.active_repo_identity_opt().cloned();
186
3
        match tui_dispatch(
187
3
            resolved,
188
3
            self.source_item.as_ref(),
189
3
            *ctx.is_config,
190
3
            active_repo.as_ref(),
191
3
            ctx.provisioning_target,
192
3
            &ctx.my_host,
193
3
            ctx.active_repo_is_remote_only,
194
3
        ) {
195
1
            Ok(command) => {
196
1
                ctx.commands.push(command);
197
1
            }
198
2
            Err(err) => {
199
2
                ctx.app_actions.push(AppAction::ShowStatus(err));
200
2
            }
201
        }
202
3
        Outcome::Finished
203
3
    }
204
205
8
    fn dispatch_palette_action(&self, action: Action, ctx: &mut WidgetContext) -> Outcome {
206
8
        match action {
207
            // Actions that open other widgets — use Swap to replace the palette
208
            Action::OpenBranchInput => {
209
1
                if *ctx.is_config || ctx.model.active_repo_identity_opt().is_none() {
210
0
                    ctx.app_actions.push(AppAction::ShowStatus("switch to a repo tab first".into()));
211
0
                    return Outcome::Finished;
212
1
                }
213
1
                let widget = super::branch_input::BranchInputWidget::new(crate::app::ui_state::BranchInputKind::Manual);
214
1
                Outcome::Swap(Box::new(widget))
215
            }
216
            Action::OpenIssueSearch => {
217
2
                if *ctx.is_config || ctx.model.active_repo_identity_opt().is_none() {
218
0
                    ctx.app_actions.push(AppAction::ShowStatus("switch to a repo tab first".into()));
219
0
                    Outcome::Finished
220
                } else {
221
2
                    Outcome::Swap(Box::new(super::issue_search::IssueSearchWidget::new()))
222
                }
223
            }
224
            Action::OpenFilePicker => {
225
0
                let start_dir = ctx
226
0
                    .model
227
0
                    .active_repo_root_opt()
228
0
                    .and_then(|r| r.parent())
229
0
                    .map(|p| p.to_path_buf())
230
0
                    .or_else(|| std::env::current_dir().ok())
231
0
                    .or_else(dirs::home_dir)
232
0
                    .unwrap_or_default();
233
0
                let input = Input::from(format!("{}/", start_dir.display()).as_str());
234
0
                let dir_entries = refresh_dir_listing_standalone(input.value(), ctx.model);
235
0
                let widget = super::file_picker::FilePickerWidget::new(input.clone(), dir_entries);
236
0
                Outcome::Swap(Box::new(widget))
237
            }
238
            Action::ToggleHelp => {
239
1
                let widget = super::help::HelpWidget::new();
240
1
                Outcome::Swap(Box::new(widget))
241
            }
242
243
            // Actions that map to AppActions — push the action and close the palette
244
            Action::Quit => {
245
1
                ctx.app_actions.push(AppAction::Quit);
246
1
                Outcome::Finished
247
            }
248
            Action::CycleLayout => {
249
0
                ctx.app_actions.push(AppAction::CycleLayout);
250
0
                Outcome::Finished
251
            }
252
            Action::CycleTheme => {
253
0
                ctx.app_actions.push(AppAction::CycleTheme);
254
0
                Outcome::Finished
255
            }
256
            Action::CycleHost => {
257
0
                ctx.app_actions.push(AppAction::CycleHost);
258
0
                Outcome::Finished
259
            }
260
            Action::ToggleDebug => {
261
0
                ctx.app_actions.push(AppAction::ToggleDebug);
262
0
                Outcome::Finished
263
            }
264
            Action::ToggleStatusBarKeys => {
265
0
                ctx.app_actions.push(AppAction::ToggleStatusBarKeys);
266
0
                Outcome::Finished
267
            }
268
            Action::Refresh => {
269
0
                if *ctx.is_config {
270
0
                    ctx.app_actions.push(AppAction::ShowStatus("switch to a repo tab first".into()));
271
0
                    return Outcome::Finished;
272
0
                }
273
0
                ctx.app_actions.push(AppAction::Refresh);
274
0
                Outcome::Finished
275
            }
276
277
            // Widget-level actions dispatched via AppAction
278
            Action::ToggleProviders => {
279
1
                ctx.app_actions.push(AppAction::ToggleProviders);
280
1
                Outcome::Finished
281
            }
282
            Action::ToggleMultiSelect => {
283
1
                ctx.app_actions.push(AppAction::ToggleMultiSelect);
284
1
                Outcome::Finished
285
            }
286
            Action::OpenActionMenu => {
287
1
                if *ctx.is_config {
288
0
                    ctx.app_actions.push(AppAction::ShowStatus("switch to a repo tab first".into()));
289
0
                    return Outcome::Finished;
290
1
                }
291
1
                ctx.app_actions.push(AppAction::OpenActionMenu);
292
1
                Outcome::Finished
293
            }
294
295
            // Remaining actions that don't have meaningful palette behavior
296
0
            _ => Outcome::Finished,
297
        }
298
8
    }
299
}
300
301
/// Standalone directory listing that doesn't require `&mut App`.
302
0
pub fn refresh_dir_listing_standalone(path_str: &str, model: &crate::app::TuiModel) -> Vec<crate::app::ui_state::DirEntry> {
303
    use std::path::PathBuf;
304
305
    use crate::app::ui_state::DirEntry;
306
307
0
    let dir = if path_str.ends_with('/') {
308
0
        PathBuf::from(path_str)
309
    } else {
310
0
        PathBuf::from(path_str).parent().map(|p| p.to_path_buf()).unwrap_or_default()
311
    };
312
313
0
    let filter = if !path_str.ends_with('/') {
314
0
        PathBuf::from(path_str).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default()
315
    } else {
316
0
        String::new()
317
    };
318
319
0
    let mut entries = Vec::new();
320
0
    if let Ok(read_dir) = std::fs::read_dir(&dir) {
321
0
        for entry in read_dir.flatten() {
322
0
            let name = entry.file_name().to_string_lossy().to_string();
323
0
            if name.starts_with('.') {
324
0
                continue;
325
0
            }
326
0
            if !filter.is_empty() && !name.to_lowercase().starts_with(&filter) {
327
0
                continue;
328
0
            }
329
0
            let path = entry.path();
330
0
            let is_dir = path.is_dir();
331
0
            if !is_dir {
332
0
                continue;
333
0
            }
334
0
            let is_git_repo = path.join(".git").exists();
335
0
            let canonical = std::fs::canonicalize(&path).unwrap_or(path);
336
0
            let is_added = model.repos.values().any(|repo| repo.path == canonical);
337
0
            entries.push(DirEntry { name, is_dir, is_git_repo, is_added });
338
        }
339
0
    }
340
0
    entries.sort_by(|a, b| a.name.cmp(&b.name));
341
0
    entries
342
0
}
343
344
/// Fill SENTINEL empty `RepoSelector::Query("")` fields in a `CommandAction` with a real repo selector.
345
1
fn fill_repo_sentinels(action: &mut CommandAction, repo: RepoSelector) {
346
1
    match action {
347
1
        CommandAction::Checkout { repo: r, .. } if *r == RepoSelector::Query(String::new()) => *r = repo,
348
0
        CommandAction::QueryIssues { repo: r, .. } if *r == RepoSelector::Query(String::new()) => *r = repo,
349
0
        _ => {}
350
    }
351
1
}
352
353
/// Resolve the host a work item should execute on relative to our own host.
354
1
fn item_execution_host(item: &WorkItem, my_host: &Option<HostName>) -> Option<HostName> {
355
1
    match my_host {
356
1
        Some(host) if item.host != *host => Some(item.host.clone()),
357
0
        _ => None,
358
    }
359
1
}
360
361
/// Dispatch a resolved command with ambient context from the TUI environment.
362
11
pub(crate) fn tui_dispatch(
363
11
    resolved: Resolved,
364
11
    item: Option<&WorkItem>,
365
11
    is_config: bool,
366
11
    active_repo: Option<&RepoIdentity>,
367
11
    provisioning_target: &ProvisioningTarget,
368
11
    my_host: &Option<HostName>,
369
11
    active_repo_is_remote_only: bool,
370
11
) -> Result<Command, String> {
371
11
    match resolved {
372
1
        Resolved::Ready(cmd) => Ok(cmd),
373
10
        Resolved::NeedsContext { mut command, repo, host } => {
374
            // Repo context from active tab
375
10
            let tab_repo = if is_config {
376
3
                None // overview tab — no repo context
377
            } else {
378
7
                active_repo.map(|id| RepoSelector::Identity(id.clone()))
379
            };
380
381
10
            match repo {
382
                RepoContext::Required => {
383
3
                    let 
repo_sel1
= tab_repo.ok_or_else(||
"no active repo — switch to a repo tab first"2
.
to_string2
())
?2
;
384
1
                    command.context_repo = Some(repo_sel.clone());
385
1
                    fill_repo_sentinels(&mut command.action, repo_sel);
386
                }
387
                RepoContext::Inferred => {
388
7
                    if is_config {
389
1
                        return Err("no active repo — switch to a repo tab first".to_string());
390
6
                    }
391
6
                    command.context_repo = tab_repo;
392
                }
393
            }
394
395
            // Host resolution — only fill if not already set by explicit `host <name>` routing.
396
            // When the user types `host feta cr #42 open`, HostNoun::resolve() calls set_host("feta")
397
            // during noun resolution, so command.host is already Some. We must not clobber it.
398
7
            if command.host.is_none() {
399
6
                match host {
400
2
                    HostResolution::Local => {}
401
1
                    HostResolution::ProvisioningTarget => {
402
1
                        command.host = Some(provisioning_target.host().clone());
403
1
                        command.provisioning_target = Some(provisioning_target.clone());
404
1
                    }
405
                    HostResolution::SubjectHost => {
406
0
                        command.host = item.and_then(|i| item_execution_host(i, my_host));
407
                    }
408
                    HostResolution::ProviderHost => {
409
3
                        if active_repo_is_remote_only {
410
1
                            command.host = item.and_then(|i| item_execution_host(i, my_host));
411
2
                        }
412
                    }
413
                }
414
1
            }
415
416
7
            Ok(command)
417
        }
418
    }
419
11
}
420
421
impl InteractiveWidget for CommandPaletteWidget {
422
53
    fn handle_action(&mut self, action: Action, ctx: &mut WidgetContext) -> Outcome {
423
53
        let has_repo_context = !*ctx.is_config;
424
53
        match action {
425
            Action::SelectNext => {
426
26
                let count = self.completions(ctx.model, has_repo_context).len();
427
26
                if count > 0 {
428
26
                    self.selected = (self.selected + 1) % count;
429
26
                    self.adjust_scroll();
430
26
                
}0
431
26
                Outcome::Consumed
432
            }
433
            Action::SelectPrev => {
434
3
                let count = self.completions(ctx.model, has_repo_context).len();
435
3
                if count > 0 {
436
3
                    self.selected = if self.selected == 0 { 
count - 12
} else {
self.selected - 11
};
437
3
                    self.adjust_scroll();
438
0
                }
439
3
                Outcome::Consumed
440
            }
441
19
            Action::Confirm => self.confirm(ctx),
442
2
            Action::Dismiss => Outcome::Finished,
443
            Action::FillSelected => {
444
2
                let completions = self.completions(ctx.model, has_repo_context);
445
2
                if let Some(completion) = completions.get(self.selected) {
446
2
                    self.fill_completion(completion);
447
2
                
}0
448
2
                Outcome::Consumed
449
            }
450
1
            _ => Outcome::Ignored,
451
        }
452
53
    }
453
454
24
    fn handle_raw_key(&mut self, key: KeyEvent, ctx: &mut WidgetContext) -> Outcome {
455
24
        let has_repo_context = !*ctx.is_config;
456
        // Right arrow: fill selected completion into input (Tab goes through handle_action)
457
24
        if matches!(key.code, KeyCode::Right) {
458
0
            let completions = self.completions(ctx.model, has_repo_context);
459
0
            if let Some(completion) = completions.get(self.selected) {
460
0
                self.fill_completion(completion);
461
0
            }
462
0
            return Outcome::Consumed;
463
24
        }
464
465
        // Backspace on empty input closes the palette
466
24
        if 
matches!23
(key.code, KeyCode::Backspace) &&
self.input.value()1
.
is_empty1
() {
467
1
            return Outcome::Finished;
468
23
        }
469
470
23
        self.input.handle_event(&crossterm::event::Event::Key(key));
471
472
        // Shortcut: typing / when input is empty fills "search "
473
23
        if self.input.value() == "/" {
474
3
            self.input = Input::from("search ");
475
3
            self.selected = 0;
476
3
            self.scroll_top = 0;
477
3
            return Outcome::Consumed;
478
20
        }
479
480
20
        self.selected = 0;
481
20
        self.scroll_top = 0;
482
20
        Outcome::Consumed
483
24
    }
484
485
5
    fn render(&mut self, frame: &mut Frame, _area: Rect, ctx: &mut RenderContext) {
486
5
        let theme = ctx.theme;
487
5
        let has_repo_context = !ctx.ui.is_config;
488
5
        let completions = self.completions(ctx.model, has_repo_context);
489
5
        let overlay = crate::ui_helpers::bottom_anchored_overlay(frame.area(), 1, MAX_PALETTE_ROWS as u16);
490
5
        let area = overlay.body;
491
492
5
        frame.render_widget(Clear, area);
493
5
        frame.render_widget(Block::default().style(Style::default().bg(theme.bar_bg)), area);
494
495
89
        let 
name_width5
=
completions.iter()5
.
map5
(|c| c.value.len()).
max5
().
unwrap_or5
(0).
min5
(20);
496
5
        let hint_width: u16 = 7;
497
498
29
        for (i, completion) in 
completions.iter()5
.
skip5
(
self.scroll_top5
).
take5
(
overlay.visible_body_rows as usize5
).
enumerate5
() {
499
29
            let row_y = area.y + i as u16;
500
29
            let is_selected = self.scroll_top + i == self.selected;
501
502
29
            let row_style = if is_selected {
503
5
                Style::default().bg(theme.action_highlight).add_modifier(Modifier::BOLD)
504
            } else {
505
24
                Style::default().bg(theme.bar_bg)
506
            };
507
508
29
            let row_area = Rect::new(area.x, row_y, area.width, 1);
509
29
            frame.render_widget(Block::default().style(row_style), row_area);
510
511
29
            let name_span = Span::styled(format!("  {:<width$}", completion.value, width = name_width), row_style.fg(theme.text));
512
29
            let desc_span = Span::styled(format!("  {}", completion.description), row_style.fg(theme.muted));
513
514
29
            let line = Line::from(vec![name_span, desc_span]);
515
29
            frame.render_widget(Paragraph::new(line), Rect::new(area.x, row_y, area.width.saturating_sub(hint_width), 1));
516
517
29
            let hint_text = completion.key_hint.unwrap_or("");
518
29
            if !hint_text.is_empty() {
519
1
                let hint_span = Span::styled(format!(" {} ", hint_text), row_style.fg(theme.key_hint));
520
1
                let hint_x = area.x + area.width.saturating_sub(hint_width);
521
1
                frame.render_widget(Paragraph::new(Line::from(hint_span)), Rect::new(hint_x, row_y, hint_width, 1));
522
28
            }
523
        }
524
525
        // Cursor on the status bar row (computed via the same overlay layout)
526
5
        let cursor_x = overlay.status_row.x + 1 + self.input.visual_cursor() as u16;
527
5
        frame.set_cursor_position((cursor_x, overlay.status_row.y));
528
5
    }
529
530
53
    fn binding_mode(&self) -> KeyBindingMode {
531
53
        BindingModeId::CommandPalette.into()
532
53
    }
533
534
33
    fn captures_raw_keys(&self) -> bool {
535
33
        false
536
33
    }
537
538
5
    fn status_fragment(&self) -> StatusFragment {
539
5
        StatusFragment { status: Some(StatusContent::ActiveInput { prefix: "/".into(), text: self.input.value().to_string() }) }
540
5
    }
541
542
0
    fn as_any(&self) -> &dyn Any {
543
0
        self
544
0
    }
545
546
0
    fn as_any_mut(&mut self) -> &mut dyn Any {
547
0
        self
548
0
    }
549
}
550
551
#[cfg(test)]
552
mod tests {
553
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
554
    use flotilla_protocol::{Command, CommandAction, ProvisioningTarget, WorkItemIdentity, WorkItemKind};
555
556
    use super::*;
557
    use crate::app::test_support::{bare_item, checkout_item, session_item, TestWidgetHarness};
558
559
3
    fn key(code: KeyCode) -> KeyEvent {
560
3
        KeyEvent::new(code, KeyModifiers::NONE)
561
3
    }
562
563
    #[test]
564
1
    fn binding_mode_is_command_palette() {
565
1
        let widget = CommandPaletteWidget::new();
566
1
        assert_eq!(widget.binding_mode(), KeyBindingMode::from(BindingModeId::CommandPalette));
567
1
    }
568
569
    #[test]
570
1
    fn does_not_capture_raw_keys() {
571
1
        let widget = CommandPaletteWidget::new();
572
1
        assert!(!widget.captures_raw_keys());
573
1
    }
574
575
    #[test]
576
1
    fn dismiss_returns_finished() {
577
1
        let mut widget = CommandPaletteWidget::new();
578
1
        let mut harness = TestWidgetHarness::new();
579
1
        let mut ctx = harness.ctx();
580
1
        let outcome = widget.handle_action(Action::Dismiss, &mut ctx);
581
1
        assert!(
matches!0
(outcome, Outcome::Finished));
582
1
    }
583
584
    #[test]
585
1
    fn select_next_wraps_around() {
586
1
        let mut widget = CommandPaletteWidget::new();
587
1
        let mut harness = TestWidgetHarness::new();
588
1
        let total = widget.completions(&harness.model, true).len();
589
590
        // Advance to end
591
21
        for _ in 
0..total - 11
{
592
21
            let mut ctx = harness.ctx();
593
21
            widget.handle_action(Action::SelectNext, &mut ctx);
594
21
        }
595
1
        assert_eq!(widget.selected, total - 1);
596
597
        // One more wraps to 0
598
1
        let mut ctx = harness.ctx();
599
1
        widget.handle_action(Action::SelectNext, &mut ctx);
600
1
        assert_eq!(widget.selected, 0);
601
1
    }
602
603
    #[test]
604
1
    fn select_prev_wraps_around() {
605
1
        let mut widget = CommandPaletteWidget::new();
606
1
        let mut harness = TestWidgetHarness::new();
607
1
        let total = widget.completions(&harness.model, true).len();
608
609
        // Prev from 0 wraps to end
610
1
        let mut ctx = harness.ctx();
611
1
        widget.handle_action(Action::SelectPrev, &mut ctx);
612
1
        assert_eq!(widget.selected, total - 1);
613
1
    }
614
615
    #[test]
616
1
    fn fill_selected_fills_completion_value() {
617
1
        let mut widget = CommandPaletteWidget::new();
618
1
        let mut harness = TestWidgetHarness::new();
619
620
        // Get the first completion value to verify fill works
621
1
        let first_value = widget.completions(&harness.model, true)[0].value.clone();
622
623
1
        let mut ctx = harness.ctx();
624
1
        let outcome = widget.handle_action(Action::FillSelected, &mut ctx);
625
1
        assert!(
matches!0
(outcome, Outcome::Consumed));
626
1
        assert_eq!(widget.input.value(), format!("{first_value} "));
627
1
        assert_eq!(widget.selected, 0);
628
1
    }
629
630
    #[test]
631
1
    fn backspace_on_empty_closes_palette() {
632
1
        let mut widget = CommandPaletteWidget::new();
633
1
        let mut harness = TestWidgetHarness::new();
634
1
        let mut ctx = harness.ctx();
635
636
1
        let outcome = widget.handle_raw_key(key(KeyCode::Backspace), &mut ctx);
637
1
        assert!(
matches!0
(outcome, Outcome::Finished));
638
1
    }
639
640
    #[test]
641
1
    fn slash_fills_search_prefix() {
642
1
        let mut widget = CommandPaletteWidget::new();
643
1
        let mut harness = TestWidgetHarness::new();
644
1
        let mut ctx = harness.ctx();
645
646
1
        let outcome = widget.handle_raw_key(key(KeyCode::Char('/')), &mut ctx);
647
1
        assert!(
matches!0
(outcome, Outcome::Consumed));
648
1
        assert_eq!(widget.input.value(), "search ");
649
1
    }
650
651
    #[test]
652
1
    fn confirm_search_pushes_search_command() {
653
1
        let mut widget = CommandPaletteWidget::new();
654
1
        widget.input = Input::from("search bug fix");
655
1
        let mut harness = TestWidgetHarness::new();
656
1
        let mut ctx = harness.ctx();
657
658
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
659
1
        assert!(
matches!0
(outcome, Outcome::Finished));
660
661
1
        let (cmd, _) = harness.commands.take_next().expect("expected QueryIssues command");
662
1
        match cmd {
663
1
            Command { action: CommandAction::QueryIssues { params, .. }, .. } => {
664
1
                assert_eq!(params.search.as_deref(), Some("bug fix"));
665
            }
666
0
            other => panic!("expected QueryIssues, got {:?}", other),
667
        }
668
1
    }
669
670
    #[test]
671
1
    fn confirm_search_empty_clears() {
672
1
        let mut widget = CommandPaletteWidget::new();
673
1
        widget.input = Input::from("search ");
674
1
        let mut harness = TestWidgetHarness::new();
675
1
        let mut ctx = harness.ctx();
676
677
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
678
1
        assert!(
matches!0
(outcome, Outcome::Finished));
679
680
        // Empty search no longer sends a command — only a ClearSearchQuery app action
681
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::ClearSearchQuery { .. })));
682
1
        drop(ctx);
683
1
        assert!(harness.commands.take_next().is_none());
684
1
    }
685
686
    #[test]
687
1
    fn confirm_entry_quit_pushes_app_action() {
688
1
        let mut widget = CommandPaletteWidget::new();
689
        // Type "quit" to filter to the quit entry
690
1
        widget.input = Input::from("quit");
691
1
        let mut harness = TestWidgetHarness::new();
692
1
        let mut ctx = harness.ctx();
693
694
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
695
1
        assert!(
matches!0
(outcome, Outcome::Finished));
696
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::Quit)));
697
1
    }
698
699
    #[test]
700
1
    fn confirm_entry_branch_returns_swap() {
701
1
        let mut widget = CommandPaletteWidget::new();
702
1
        widget.input = Input::from("branch");
703
1
        let mut harness = TestWidgetHarness::new();
704
1
        let mut ctx = harness.ctx();
705
706
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
707
1
        assert!(
matches!0
(outcome, Outcome::Swap(_)));
708
1
    }
709
710
    #[test]
711
1
    fn confirm_entry_search_returns_swap() {
712
1
        let mut widget = CommandPaletteWidget::new();
713
        // "search" as entry name (not "search <terms>")
714
1
        widget.input = Input::from("search");
715
        // Make sure selected is 0 so it picks "search" entry
716
1
        widget.selected = 0;
717
1
        let mut harness = TestWidgetHarness::new();
718
1
        let mut ctx = harness.ctx();
719
720
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
721
        // "search" entry has action OpenIssueSearch, which should Swap
722
1
        assert!(
matches!0
(outcome, Outcome::Swap(_)));
723
1
    }
724
725
    #[test]
726
1
    fn confirm_entry_help_returns_swap() {
727
1
        let mut widget = CommandPaletteWidget::new();
728
1
        widget.input = Input::from("help");
729
1
        let mut harness = TestWidgetHarness::new();
730
1
        let mut ctx = harness.ctx();
731
732
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
733
1
        assert!(
matches!0
(outcome, Outcome::Swap(_)));
734
1
    }
735
736
    #[test]
737
1
    fn typing_text_resets_selected() {
738
1
        let mut widget = CommandPaletteWidget::new();
739
1
        let mut harness = TestWidgetHarness::new();
740
741
        // Move selected down
742
1
        {
743
1
            let mut ctx = harness.ctx();
744
1
            widget.handle_action(Action::SelectNext, &mut ctx);
745
1
        }
746
1
        assert_eq!(widget.selected, 1);
747
748
        // Type a character — selected should reset to 0
749
1
        let mut ctx = harness.ctx();
750
1
        widget.handle_raw_key(key(KeyCode::Char('r')), &mut ctx);
751
1
        assert_eq!(widget.selected, 0);
752
1
    }
753
754
    #[test]
755
1
    fn confirm_entry_providers_pushes_app_action() {
756
1
        let mut widget = CommandPaletteWidget::new();
757
1
        widget.input = Input::from("providers");
758
1
        let mut harness = TestWidgetHarness::new();
759
1
        let mut ctx = harness.ctx();
760
761
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
762
1
        assert!(
matches!0
(outcome, Outcome::Finished));
763
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::ToggleProviders)));
764
1
    }
765
766
    #[test]
767
1
    fn confirm_entry_select_pushes_app_action() {
768
1
        let mut widget = CommandPaletteWidget::new();
769
1
        widget.input = Input::from("select");
770
1
        let mut harness = TestWidgetHarness::new();
771
1
        let mut ctx = harness.ctx();
772
773
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
774
1
        assert!(
matches!0
(outcome, Outcome::Finished));
775
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::ToggleMultiSelect)));
776
1
    }
777
778
    #[test]
779
1
    fn confirm_entry_actions_pushes_app_action() {
780
1
        let mut widget = CommandPaletteWidget::new();
781
1
        widget.input = Input::from("actions");
782
1
        let mut harness = TestWidgetHarness::new();
783
1
        let mut ctx = harness.ctx();
784
785
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
786
1
        assert!(
matches!0
(outcome, Outcome::Finished));
787
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::OpenActionMenu)));
788
1
    }
789
790
    #[test]
791
1
    fn unhandled_action_returns_ignored() {
792
1
        let mut widget = CommandPaletteWidget::new();
793
1
        let mut harness = TestWidgetHarness::new();
794
1
        let mut ctx = harness.ctx();
795
796
1
        let outcome = widget.handle_action(Action::PrevTab, &mut ctx);
797
1
        assert!(
matches!0
(outcome, Outcome::Ignored));
798
1
    }
799
800
    #[test]
801
1
    fn confirm_noun_verb_pushes_command() {
802
1
        let mut widget = CommandPaletteWidget::new();
803
1
        widget.input = Input::from("cr 42 close");
804
1
        let mut harness = TestWidgetHarness::new();
805
1
        let mut ctx = harness.ctx();
806
807
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
808
1
        assert!(
matches!0
(outcome, Outcome::Finished));
809
810
1
        let (cmd, _) = harness.commands.take_next().expect("expected command");
811
1
        assert!(matches!(cmd.action, CommandAction::CloseChangeRequest { ref id } if id == "42"));
812
1
    }
813
814
    #[test]
815
1
    fn confirm_noun_verb_required_repo_on_overview_tab_rejects() {
816
        // `checkout create --branch feat` uses RepoContext::Required — must fail on overview tab
817
1
        let mut widget = CommandPaletteWidget::new();
818
1
        widget.input = Input::from("checkout create --branch feat");
819
1
        let mut harness = TestWidgetHarness::new();
820
1
        harness.is_config = true;
821
1
        let mut ctx = harness.ctx();
822
823
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
824
1
        assert!(
matches!0
(outcome, Outcome::Finished));
825
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::ShowStatus(msg) if msg.contains("repo tab"))));
826
1
        assert!(harness.commands.take_next().is_none());
827
1
    }
828
829
    #[test]
830
1
    fn confirm_noun_verb_inferred_repo_on_overview_tab_rejects() {
831
        // `cr 42 close` uses RepoContext::Inferred — should be rejected on overview tab
832
1
        let mut widget = CommandPaletteWidget::new();
833
1
        widget.input = Input::from("cr 42 close");
834
1
        let mut harness = TestWidgetHarness::new();
835
1
        harness.is_config = true;
836
1
        let mut ctx = harness.ctx();
837
838
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
839
1
        assert!(
matches!0
(outcome, Outcome::Finished));
840
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::ShowStatus(msg) if msg.contains("repo tab"))));
841
1
        assert!(harness.commands.take_next().is_none());
842
1
    }
843
844
    #[test]
845
1
    fn confirm_layout_set_pushes_app_action() {
846
1
        let mut widget = CommandPaletteWidget::new();
847
1
        widget.input = Input::from("layout zoom");
848
1
        let mut harness = TestWidgetHarness::new();
849
1
        let mut ctx = harness.ctx();
850
851
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
852
1
        assert!(
matches!0
(outcome, Outcome::Finished));
853
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::SetLayout(name) if name == "zoom")));
854
1
    }
855
856
    #[test]
857
1
    fn confirm_theme_set_pushes_app_action() {
858
1
        let mut widget = CommandPaletteWidget::new();
859
1
        widget.input = Input::from("theme catppuccin-mocha");
860
1
        let mut harness = TestWidgetHarness::new();
861
1
        let mut ctx = harness.ctx();
862
863
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
864
1
        assert!(
matches!0
(outcome, Outcome::Finished));
865
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::SetTheme(name) if name == "catppuccin-mocha")));
866
1
    }
867
868
    #[test]
869
1
    fn confirm_target_set_pushes_app_action() {
870
1
        let mut widget = CommandPaletteWidget::new();
871
1
        widget.input = Input::from("target feta");
872
1
        let mut harness = TestWidgetHarness::new();
873
1
        let mut ctx = harness.ctx();
874
875
1
        let outcome = widget.handle_action(Action::Confirm, &mut ctx);
876
1
        assert!(
matches!0
(outcome, Outcome::Finished));
877
1
        assert!(ctx.app_actions.iter().any(|a| matches!(a, AppAction::SetTarget(name) if name == "feta")));
878
1
    }
879
880
    // ── palette_prefill ──
881
882
    #[test]
883
1
    fn prefill_from_cr() {
884
1
        let mut item = bare_item();
885
1
        item.change_request_key = Some("42".into());
886
1
        assert_eq!(palette_prefill(&item), Some("cr 42 ".into()));
887
1
    }
888
889
    #[test]
890
1
    fn prefill_prefers_cr_over_checkout() {
891
1
        let mut item = checkout_item("feat", "/tmp/repo", false);
892
1
        item.change_request_key = Some("42".into());
893
1
        assert_eq!(palette_prefill(&item), Some("cr 42 ".into()));
894
1
    }
895
896
    #[test]
897
1
    fn prefill_empty_item_returns_none() {
898
1
        let item = bare_item();
899
1
        assert_eq!(palette_prefill(&item), None);
900
1
    }
901
902
    #[test]
903
1
    fn prefill_from_checkout_branch() {
904
1
        let item = checkout_item("feat/my-branch", "/tmp/repo", false);
905
1
        assert_eq!(palette_prefill(&item), Some("checkout feat/my-branch ".into()));
906
1
    }
907
908
    #[test]
909
1
    fn prefill_from_session_key() {
910
1
        let item = session_item("ses-123");
911
1
        assert_eq!(palette_prefill(&item), Some("agent ses-123 ".into()));
912
1
    }
913
914
    #[test]
915
1
    fn prefill_from_issue_key() {
916
1
        let mut item = bare_item();
917
1
        item.issue_keys = vec!["99".into()];
918
1
        assert_eq!(palette_prefill(&item), Some("issue 99 ".into()));
919
1
    }
920
921
    #[test]
922
1
    fn prefill_from_workspace_ref() {
923
1
        let mut item = bare_item();
924
1
        item.workspace_refs = vec!["ws-abc".into()];
925
1
        assert_eq!(palette_prefill(&item), Some("workspace ws-abc ".into()));
926
1
    }
927
928
    // ── tui_dispatch ──
929
930
    #[test]
931
1
    fn dispatch_ready_passes_through() {
932
1
        let cmd = Command { host: None, provisioning_target: None, context_repo: None, action: CommandAction::Refresh { repo: None } };
933
1
        let local_target = ProvisioningTarget::Host { host: HostName::local() };
934
1
        let result = tui_dispatch(Resolved::Ready(cmd), None, false, None, &local_target, &None, false);
935
1
        assert!(result.is_ok());
936
1
    }
937
938
    #[test]
939
1
    fn dispatch_needs_repo_on_overview_errors() {
940
        use flotilla_protocol::CheckoutTarget;
941
1
        let cmd = Command {
942
1
            host: None,
943
1
            provisioning_target: None,
944
1
            context_repo: None,
945
1
            action: CommandAction::Checkout {
946
1
                repo: RepoSelector::Query("".into()),
947
1
                target: CheckoutTarget::Branch("feat".into()),
948
1
                issue_ids: vec![],
949
1
            },
950
1
        };
951
1
        let resolved = Resolved::NeedsContext { command: cmd, repo: RepoContext::Required, host: HostResolution::ProvisioningTarget };
952
1
        let local_target = ProvisioningTarget::Host { host: HostName::local() };
953
1
        let result = tui_dispatch(resolved, None, true, None, &local_target, &None, false);
954
1
        assert!(result.is_err());
955
1
    }
956
957
    #[test]
958
1
    fn dispatch_fills_repo_sentinels() {
959
        use flotilla_protocol::CheckoutTarget;
960
1
        let cmd = Command {
961
1
            host: None,
962
1
            provisioning_target: None,
963
1
            context_repo: None,
964
1
            action: CommandAction::Checkout {
965
1
                repo: RepoSelector::Query("".into()),
966
1
                target: CheckoutTarget::Branch("feat".into()),
967
1
                issue_ids: vec![],
968
1
            },
969
1
        };
970
1
        let repo_id = RepoIdentity { authority: "github.com".into(), path: "org/repo".into() };
971
1
        let resolved = Resolved::NeedsContext { command: cmd, repo: RepoContext::Required, host: HostResolution::Local };
972
1
        let local_target = ProvisioningTarget::Host { host: HostName::local() };
973
1
        let result = tui_dispatch(resolved, None, false, Some(&repo_id), &local_target, &None, false).unwrap();
974
1
        assert!(result.context_repo.is_some());
975
1
        match &result.action {
976
1
            CommandAction::Checkout { repo, .. } => assert_ne!(*repo, RepoSelector::Query("".into())),
977
0
            _ => panic!("wrong action"),
978
        }
979
1
    }
980
981
    #[test]
982
1
    fn explicit_host_routing_preserved_for_needs_context() {
983
1
        let repo_id = RepoIdentity { authority: "github.com".into(), path: "org/repo".into() };
984
        // Simulate `host feta cr 42 open` — HostNoun::resolve() sets command.host = Some("feta")
985
1
        let cmd = Command {
986
1
            host: Some(HostName::new("feta")),
987
1
            provisioning_target: None,
988
1
            context_repo: None,
989
1
            action: CommandAction::OpenChangeRequest { id: "42".into() },
990
1
        };
991
1
        let resolved = Resolved::NeedsContext { command: cmd, repo: RepoContext::Inferred, host: HostResolution::ProviderHost };
992
1
        let local_target = ProvisioningTarget::Host { host: HostName::local() };
993
1
        let result = tui_dispatch(resolved, None, false, Some(&repo_id), &local_target, &None, false).expect("should succeed");
994
        // Explicit host must be preserved, not clobbered by ProviderHost resolution
995
1
        assert_eq!(result.host, Some(HostName::new("feta")));
996
1
    }
997
998
    #[test]
999
1
    fn contextual_palette_derives_host_from_source_item() {
1000
1
        let repo_id = RepoIdentity { authority: "github.com".into(), path: "org/repo".into() };
1001
1
        let item = WorkItem {
1002
1
            kind: WorkItemKind::ChangeRequest,
1003
1
            identity: WorkItemIdentity::ChangeRequest("42".into()),
1004
1
            host: HostName::new("remote-peer"),
1005
1
            branch: None,
1006
1
            description: String::new(),
1007
1
            checkout: None,
1008
1
            change_request_key: Some("42".into()),
1009
1
            session_key: None,
1010
1
            issue_keys: Vec::new(),
1011
1
            workspace_refs: Vec::new(),
1012
1
            is_main_checkout: false,
1013
1
            debug_group: Vec::new(),
1014
1
            source: None,
1015
1
            terminal_keys: Vec::new(),
1016
1
            attachable_set_id: None,
1017
1
            agent_keys: Vec::new(),
1018
1
        };
1019
1
        let cmd = Command {
1020
1
            host: None,
1021
1
            provisioning_target: None,
1022
1
            context_repo: None,
1023
1
            action: CommandAction::OpenChangeRequest { id: "42".into() },
1024
1
        };
1025
1
        let my_host = Some(HostName::new("local-host"));
1026
        // ProviderHost on a remote-only repo should derive host from the item
1027
1
        let resolved = Resolved::NeedsContext { command: cmd, repo: RepoContext::Inferred, host: HostResolution::ProviderHost };
1028
1
        let local_target = ProvisioningTarget::Host { host: HostName::local() };
1029
1
        let result = tui_dispatch(resolved, Some(&item), false, Some(&repo_id), &local_target, &my_host, true).expect("should succeed");
1030
1
        assert_eq!(result.host, Some(HostName::new("remote-peer")));
1031
1
    }
1032
}