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 | | } |