返回 CodeWhale
fleet_list.rs
根目录 / crates / tui / src / tui / views / fleet_list.rs
1 //! `/fleet fleets` — named saved-Fleet picker (secondary surface).
2 //!
3 //! Bare `/fleet` opens the roster/setup face for the selected Fleet. This view
4 //! is only for switching between named configurations. One row per saved Fleet
5 //! across both scopes: user-global (`$CODEWHALE_HOME/fleets/`) and folder
6 //! (`.codewhale/fleets/`). Rows show name, scope badge, and operator summary —
7 //! not filesystem paths (paths belong in receipts). Same-name Fleets in both
8 //! scopes are two rows, never a silent shadow. Legacy per-role profiles get
9 //! one migration banner, not a pile of shadow badges.
10 //!
11 //! The view reads and writes the Fleet store directly (local, atomic file
12 //! operations); it never touches the live session route.
13
14 use std::path::PathBuf;
15
16 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
17 use ratatui::{
18 buffer::Buffer,
19 layout::{Constraint, Direction, Layout, Rect},
20 style::Style,
21 text::{Line, Span},
22 widgets::{Block, Clear, Paragraph, Widget, Wrap},
23 };
24
25 use crate::config::Config;
26 use crate::fleet::store::{
27 FleetEntry, FleetScope, SelectedFleet, delete_fleet, list_fleets, migrate_legacy_roster,
28 selected_fleet, set_selected,
29 };
30 use crate::palette;
31 use crate::tui::app::App;
32 use crate::tui::views::{
33 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
34 };
35
36 /// What the host should do after this view acted on the store.
37 #[derive(Debug, Clone, PartialEq, Eq)]
38 #[allow(dead_code)] // OpenDetail/None are reserved for the qualified-name flow
39 pub enum FleetListOutcome {
40 /// A store mutation happened; show `message`, refresh, and pop the stack.
41 Done { message: String },
42 /// Open the detail view for the given Fleet.
43 OpenDetail { name: String, scope: FleetScope },
44 /// Nothing to do.
45 None,
46 }
47
48 pub struct FleetListView {
49 entries: Vec<FleetEntry>,
50 /// The active selection snapshot (workspace then personal).
51 selected: Option<SelectedFleet>,
52 /// Legacy profile count (how many per-role files exist), for the banner.
53 legacy_profile_count: usize,
54 /// Whether a v2 Fleet named "Default" already exists (so migration is
55 /// offered only when it would not clobber).
56 default_fleet_exists: bool,
57 row: usize,
58 /// Saved scope of the row being acted on (delete/select flow through
59 /// confirmation state).
60 pending_delete: Option<usize>,
61 fleet_config: codewhale_config::FleetConfigToml,
62 workspace: PathBuf,
63 }
64
65 impl FleetListView {
66 #[must_use]
67 pub fn new(app: &App, config: &Config) -> Self {
68 let workspace = app.workspace.clone();
69 let entries = list_fleets(&workspace);
70 let selected = selected_fleet(&workspace);
71 let legacy_profile_count = legacy_profile_file_count(&workspace);
72 let default_fleet_exists = entries
73 .iter()
74 .any(|e| e.name.eq_ignore_ascii_case("default") && !e.legacy);
75 Self {
76 entries,
77 selected,
78 legacy_profile_count,
79 default_fleet_exists,
80 row: 0,
81 pending_delete: None,
82 fleet_config: config.fleet_config(),
83 workspace,
84 }
85 }
86
87 fn selected_entry(&self) -> Option<&FleetEntry> {
88 self.entries.get(self.row)
89 }
90
91 fn banner_visible(&self) -> bool {
92 self.legacy_profile_count > 0 && !self.default_fleet_exists
93 }
94
95 fn move_row(&mut self, delta: isize) {
96 let rows = self.entries.len();
97 if rows == 0 {
98 return;
99 }
100 self.row = crate::tui::list_nav::wrap_index(self.row, rows, delta);
101 }
102
103 fn footer_hints(&self) -> Vec<ActionHint> {
104 let mut hints = vec![
105 ActionHint::new("↑/↓", "move"),
106 ActionHint::new("Enter", "select"),
107 ];
108 if !self.entries.is_empty() {
109 // Explicit scope overrides; Enter selects in the row's own scope.
110 hints.push(ActionHint::new("u", "as user default"));
111 hints.push(ActionHint::new("w", "for this folder"));
112 hints.push(ActionHint::new("d", "delete"));
113 }
114 if self.banner_visible() {
115 hints.push(ActionHint::new("m", "migrate"));
116 }
117 hints.push(ActionHint::new("Esc", "close"));
118 hints
119 }
120
121 /// Select the highlighted Fleet in `scope` and close with a receipt that
122 /// names the exact file written. Editing stays on `/fleet setup` / roster —
123 /// this surface is a switcher, not a file manager.
124 fn select_highlighted(&self, scope: FleetScope) -> Option<FleetListOutcome> {
125 let entry = self.selected_entry()?;
126 if entry.legacy {
127 return None;
128 }
129 match set_selected(&entry.name, scope, &self.workspace) {
130 Ok(path) => Some(FleetListOutcome::Done {
131 message: format!(
132 "Selected Fleet `{}` ({}) — wrote {}",
133 entry.name,
134 scope.long_label(),
135 path.display()
136 ),
137 }),
138 Err(err) => Some(FleetListOutcome::Done {
139 message: format!("Selection failed: {err}"),
140 }),
141 }
142 }
143
144 fn confirm_delete(&mut self) -> Option<FleetListOutcome> {
145 let idx = self.pending_delete?;
146 let entry = self.entries.get(idx)?;
147 let name = entry.name.clone();
148 let scope = entry.scope;
149 match delete_fleet(&name, scope, &self.workspace) {
150 Ok(path) => Some(FleetListOutcome::Done {
151 message: format!(
152 "Deleted Fleet `{name}` ({}) — removed {}",
153 scope.label(),
154 path.display()
155 ),
156 }),
157 Err(err) => Some(FleetListOutcome::Done {
158 message: format!("Delete failed: {err}"),
159 }),
160 }
161 }
162 }
163
164 impl ModalView for FleetListView {
165 fn kind(&self) -> ModalKind {
166 ModalKind::FleetList
167 }
168
169 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
170 self
171 }
172
173 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
174 if self.pending_delete.is_some() {
175 match key.code {
176 KeyCode::Char('y') | KeyCode::Enter => {
177 let outcome = self.confirm_delete();
178 self.pending_delete = None;
179 return outcome_to_action(outcome);
180 }
181 KeyCode::Char('n') | KeyCode::Esc => {
182 self.pending_delete = None;
183 return ViewAction::None;
184 }
185 _ => return ViewAction::None,
186 }
187 }
188 match key.code {
189 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
190 KeyCode::Up | KeyCode::Char('k') => {
191 self.move_row(-1);
192 ViewAction::None
193 }
194 KeyCode::Down | KeyCode::Char('j') => {
195 self.move_row(1);
196 ViewAction::None
197 }
198 KeyCode::Enter => {
199 let Some(entry) = self.selected_entry() else {
200 return ViewAction::None;
201 };
202 if entry.legacy {
203 return ViewAction::Emit(ViewEvent::OpenTextPager {
204 title: format!("Fleet `{}` — legacy format", entry.name),
205 content: format!(
206 "This Fleet file predates the named-Fleet format ({}).\n\n\
207 It is listed so nothing you saved disappears, but it is \
208 read-only here. To edit it, create a new Fleet and copy \
209 the settings you want; legacy files are never migrated \
210 silently.\n\nParse error: {}",
211 entry.path.display(),
212 entry.parse_error.as_deref().unwrap_or("unknown")
213 ),
214 });
215 }
216 // Enter selects in the row's own scope and returns to the
217 // roster/setup face. The list/detail editor is not the product.
218 outcome_to_action(self.select_highlighted(entry.scope))
219 }
220 KeyCode::Char('u') => outcome_to_action(self.select_highlighted(FleetScope::Personal)),
221 KeyCode::Char('w') => outcome_to_action(self.select_highlighted(FleetScope::Workspace)),
222 KeyCode::Char('d') => {
223 let Some(entry) = self.selected_entry() else {
224 return ViewAction::None;
225 };
226 if entry.legacy {
227 return ViewAction::None;
228 }
229 self.pending_delete = Some(self.row);
230 ViewAction::None
231 }
232 KeyCode::Char('m') if self.banner_visible() => {
233 match migrate_legacy_roster(
234 &self.fleet_config,
235 &self.workspace,
236 true,
237 FleetScope::Personal,
238 ) {
239 Ok(receipt) => {
240 let mut content = format!(
241 "Migrated {} legacy role profiles into Fleet `Default` \
242 (user-global) — wrote {}\n\n",
243 receipt.rows.len(),
244 receipt.saved_to.display()
245 );
246 for row in &receipt.rows {
247 let pin = match &row.pin {
248 Some((model, provider)) => {
249 format!("{provider}/{model}")
250 }
251 None => "inherit session route".to_string(),
252 };
253 content.push_str(&format!("- {} → {pin}", row.id));
254 if let Some(shadow) = &row.conflicting_shadow {
255 content.push_str(&format!(" [conflict: {shadow}]"));
256 }
257 content.push('\n');
258 }
259 content.push_str(
260 "\nLegacy profile files were left untouched — they are no \
261 longer live configuration once a Fleet is selected.",
262 );
263 if let Ok(path) =
264 set_selected("Default", FleetScope::Personal, &self.workspace)
265 {
266 content.push_str(&format!(
267 "\n\nFleet `Default` is now your user-global default — wrote {}.",
268 path.display()
269 ));
270 }
271 ViewAction::Emit(ViewEvent::OpenTextPager {
272 title: "Legacy migration receipt".to_string(),
273 content,
274 })
275 }
276 Err(err) => ViewAction::Emit(ViewEvent::OpenTextPager {
277 title: "Legacy migration failed".to_string(),
278 content: format!("{err:#}"),
279 }),
280 }
281 }
282 KeyCode::Home => {
283 self.row = 0;
284 ViewAction::None
285 }
286 KeyCode::End => {
287 self.row = self.entries.len().saturating_sub(1);
288 ViewAction::None
289 }
290 _ => ViewAction::None,
291 }
292 }
293
294 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
295 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
296 if self.pending_delete.is_some() {
297 self.pending_delete = None;
298 return ViewAction::None;
299 }
300 let (rows_top, _) = (5u16, 0u16);
301 if mouse.row >= rows_top {
302 let idx = usize::from(mouse.row - rows_top) + self.row.saturating_sub(0);
303 if idx < self.entries.len() {
304 self.row = idx;
305 return ViewAction::None;
306 }
307 }
308 }
309 ViewAction::None
310 }
311
312 fn render(&self, area: Rect, buf: &mut Buffer) {
313 Clear.render(area, buf);
314 Block::default()
315 .style(Style::default().bg(palette::WHALE_BG))
316 .render(area, buf);
317
318 let hints = self.footer_hints();
319 let content = render_modal_footer(area, buf, &hints);
320
321 let chunks = Layout::default()
322 .direction(Direction::Vertical)
323 .constraints([Constraint::Length(4), Constraint::Min(1)])
324 .split(content);
325
326 // Header: name + selected summary.
327 let selected_line = match &self.selected {
328 Some(sel) => format!("Selected: `{}` ({})", sel.name, sel.scope.label()),
329 None => "No Fleet selected — the session uses its own route and the legacy roster."
330 .to_string(),
331 };
332 let mut header = vec![
333 Line::from(vec![
334 Span::styled(
335 "─ Saved Fleets ",
336 Style::default().fg(palette::WHALE_ACTION).bold(),
337 ),
338 Span::styled(
339 "· pick which named Fleet the session uses",
340 Style::default().fg(palette::TEXT_MUTED),
341 ),
342 ]),
343 Line::from(""),
344 Line::from(Span::styled(
345 format!(" {selected_line}"),
346 Style::default().fg(palette::TEXT_SECONDARY),
347 )),
348 ];
349 if self.banner_visible() {
350 header.push(Line::from(vec![Span::styled(
351 format!(
352 " ⚠ {} legacy role profile(s) found — press m to migrate them into a \
353 Fleet (nothing is changed until you do)",
354 self.legacy_profile_count
355 ),
356 Style::default().fg(palette::WHALE_HUMAN),
357 )]));
358 }
359 Paragraph::new(header)
360 .wrap(Wrap { trim: false })
361 .render(chunks[0], buf);
362
363 self.render_rows(chunks[1], buf);
364 }
365 }
366
367 impl FleetListView {
368 fn render_rows(&self, area: Rect, buf: &mut Buffer) {
369 if area.width == 0 || area.height == 0 {
370 return;
371 }
372 if self.entries.is_empty() {
373 Paragraph::new(Line::from(vec![
374 Span::styled(
375 " No saved Fleets yet.",
376 Style::default().fg(palette::TEXT_MUTED),
377 ),
378 Span::styled(
379 " Select a route with /model and /provider, then /fleet save or \
380 /fleet save-as. Editing stays on /fleet setup.",
381 Style::default().fg(palette::TEXT_DIM),
382 ),
383 ]))
384 .render(area, buf);
385 return;
386 }
387
388 let rows_visible = usize::from(area.height).max(1);
389 let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
390
391 let mut lines = Vec::new();
392 for (idx, entry) in self.entries.iter().enumerate() {
393 if idx < scroll || idx >= scroll + rows_visible {
394 continue;
395 }
396 let selected = idx == self.row;
397 let is_selected_fleet = self
398 .selected
399 .as_ref()
400 .is_some_and(|sel| sel.name == entry.name && sel.scope == entry.scope);
401
402 let mut spans = Vec::new();
403 let marker = if is_selected_fleet { "▸ " } else { " " };
404 let base = if selected {
405 Style::default().fg(palette::WHALE_ACTION).bold()
406 } else {
407 Style::default().fg(palette::TEXT_SECONDARY)
408 };
409 spans.push(Span::styled(marker, base));
410 let name = if selected {
411 "» ".to_string()
412 } else {
413 " ".to_string()
414 };
415 spans.push(Span::styled(format!("{name}{}", entry.name), base));
416
417 let scope_badge = match entry.scope {
418 FleetScope::Personal => " [user]",
419 FleetScope::Workspace => " [folder]",
420 };
421 spans.push(Span::styled(
422 scope_badge,
423 Style::default().fg(palette::TEXT_MUTED),
424 ));
425
426 if entry.legacy {
427 spans.push(Span::styled(
428 " legacy format — read-only",
429 Style::default().fg(palette::WHALE_HUMAN),
430 ));
431 } else if let Some(err) = &entry.parse_error {
432 spans.push(Span::styled(
433 format!(" unreadable: {err}"),
434 Style::default().fg(palette::WHALE_HUMAN),
435 ));
436 }
437
438 if is_selected_fleet && !selected {
439 spans.push(Span::styled(
440 " ★",
441 Style::default().fg(palette::WHALE_ACTION),
442 ));
443 }
444
445 if self.pending_delete == Some(idx) {
446 lines.push(Line::from(vec![Span::styled(
447 format!(" Delete `{}` ({})? y/n", entry.name, entry.scope.label()),
448 Style::default().fg(palette::WHALE_HUMAN),
449 )]));
450 } else {
451 lines.push(Line::from(spans));
452 // Second line: source + operator summary (compact).
453 let summary = self.entry_summary(entry);
454 lines.push(Line::from(Span::styled(
455 format!(" {summary}"),
456 Style::default().fg(palette::TEXT_DIM),
457 )));
458 }
459 }
460
461 let text = ratatui::text::Text::from(lines);
462 Paragraph::new(text).render(area, buf);
463 }
464
465 fn entry_summary(&self, entry: &FleetEntry) -> String {
466 if entry.legacy {
467 return "legacy format — open for details".to_string();
468 }
469 // Read the file to summarize operator + members. Parsing errors are
470 // already shown on the row. Paths stay out of the primary row — they
471 // belong in receipts after an explicit write/select/delete.
472 let Ok(text) = std::fs::read_to_string(&entry.path) else {
473 return "unreadable".to_string();
474 };
475 match crate::fleet::store::FleetFile::parse(&text) {
476 Ok(fleet) => {
477 let operator = match &fleet.operator {
478 Some(op) => format!("{}/{}", op.provider, op.model),
479 None => "inherits session route".to_string(),
480 };
481 format!("operator: {operator} · members: {}", fleet.members.len())
482 }
483 Err(err) => format!("unreadable: {err}"),
484 }
485 }
486 }
487
488 fn outcome_to_action(outcome: Option<FleetListOutcome>) -> ViewAction {
489 match outcome {
490 Some(FleetListOutcome::Done { message }) => {
491 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message })
492 }
493 Some(FleetListOutcome::OpenDetail { name, scope }) => {
494 ViewAction::Emit(ViewEvent::FleetListOpenDetailRequested { name, scope })
495 }
496 Some(FleetListOutcome::None) | None => ViewAction::None,
497 }
498 }
499
500 /// How many legacy per-role profile files exist across both scopes.
501 fn legacy_profile_file_count(workspace: &std::path::Path) -> usize {
502 let mut count = 0;
503 if let Ok(dir) = crate::fleet::profile::personal_agent_profile_dir()
504 && let Ok(read) = std::fs::read_dir(dir)
505 {
506 count += read
507 .filter_map(|e| e.ok())
508 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
509 .count();
510 }
511 let ws_dir = workspace.join(".codewhale").join("agents");
512 if let Ok(read) = std::fs::read_dir(ws_dir) {
513 count += read
514 .filter_map(|e| e.ok())
515 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
516 .count();
517 }
518 count
519 }
520
521 #[cfg(test)]
522 mod tests {
523 use super::*;
524 use crate::config::Config;
525 use crate::fleet::store::{FleetFile, save_fleet};
526 use crate::tui::app::{App, TuiOptions};
527 use std::sync::OnceLock;
528
529 fn sealed_home() -> &'static std::path::Path {
530 static HOME: OnceLock<PathBuf> = OnceLock::new();
531 HOME.get_or_init(|| {
532 let dir = tempfile::TempDir::new().expect("temp dir").keep();
533 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
534 dir
535 })
536 }
537
538 fn app_in(workspace: PathBuf) -> App {
539 let options = TuiOptions {
540 ..crate::test_support::test_tui_options(workspace.clone())
541 };
542 let mut app = App::new(options, &Config::default());
543 app.workspace = workspace;
544 app
545 }
546
547 fn sample_fleet(name: &str) -> FleetFile {
548 let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
549 fleet.operator = Some(crate::fleet::store::FleetOperator {
550 provider: "deepseek".to_string(),
551 model: "deepseek-v4-flash".to_string(),
552 reasoning: None,
553 });
554 fleet
555 }
556
557 fn save_in(workspace: &std::path::Path, scope: FleetScope, name: &str) {
558 let fleet = sample_fleet(name);
559 let _ = save_fleet(&fleet, scope, workspace).expect("save");
560 }
561
562 fn key(code: KeyCode) -> KeyEvent {
563 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
564 }
565
566 #[test]
567 fn list_renders_both_scopes_and_marks_selection() {
568 let _lock = crate::test_support::lock_test_env();
569 let prev = std::env::var_os("CODEWHALE_HOME");
570 // SAFETY: serialised by lock_test_env.
571 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
572 let ws = tempfile::TempDir::new().unwrap();
573
574 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
575 save_in(ws.path(), FleetScope::Workspace, "OpenAI Codex");
576 set_selected("OpenAI Codex", FleetScope::Workspace, ws.path()).unwrap();
577
578 let mut app = app_in(ws.path().to_path_buf());
579 let view = FleetListView::new(&app, &Config::default());
580
581 // Both scopes listed under their display names; the workspace one
582 // selected. The shared sealed home may hold other fleets, so assert
583 // by name+scope, never by total count.
584 assert!(
585 view.entries
586 .iter()
587 .any(|e| e.name == "DeepSeek Flash" && e.scope == FleetScope::Personal),
588 "{:?}",
589 view.entries
590 );
591 assert!(
592 view.entries
593 .iter()
594 .any(|e| e.name == "OpenAI Codex" && e.scope == FleetScope::Workspace),
595 "{:?}",
596 view.entries
597 );
598 let selected = view.selected.as_ref().unwrap();
599 assert_eq!(selected.name, "OpenAI Codex");
600 assert_eq!(selected.scope, FleetScope::Workspace);
601
602 // Restore env.
603 // SAFETY: serialised by lock_test_env.
604 unsafe {
605 match prev {
606 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
607 None => std::env::remove_var("CODEWHALE_HOME"),
608 }
609 }
610 let _ = &mut app;
611 }
612
613 #[test]
614 fn select_user_global_writes_receipt_naming_the_file() {
615 let _lock = crate::test_support::lock_test_env();
616 let prev = std::env::var_os("CODEWHALE_HOME");
617 // SAFETY: serialised by lock_test_env.
618 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
619 let ws = tempfile::TempDir::new().unwrap();
620
621 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
622 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
623
624 let action = view.handle_key(key(KeyCode::Char('u')));
625 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
626 panic!("expected FleetStoreChanged, got {action:?}");
627 };
628 assert!(message.contains("DeepSeek Flash"), "{message}");
629 assert!(message.contains("user-global"), "{message}");
630 assert!(
631 message.to_ascii_lowercase().contains("selected"),
632 "{message}"
633 );
634
635 // The selection really persisted, at the personal scope. The
636 // SelectedFleet path is the fleet file the marker names.
637 let sel = selected_fleet(ws.path()).expect("selection");
638 assert_eq!(sel.name, "DeepSeek Flash");
639 assert_eq!(sel.scope, FleetScope::Personal);
640 assert!(
641 sel.path.ends_with("fleets/deepseek-flash.toml"),
642 "{:?}",
643 sel.path
644 );
645
646 // SAFETY: serialised by lock_test_env.
647 unsafe {
648 match prev {
649 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
650 None => std::env::remove_var("CODEWHALE_HOME"),
651 }
652 }
653 }
654
655 #[test]
656 fn delete_requires_confirmation_and_removes_the_file() {
657 let _lock = crate::test_support::lock_test_env();
658 let ws = tempfile::TempDir::new().unwrap();
659
660 save_in(ws.path(), FleetScope::Workspace, "Temp Fleet");
661 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
662 assert_eq!(view.entries.len(), 1);
663 assert_eq!(view.entries[0].name, "Temp Fleet");
664
665 // 'd' arms the confirmation; the file is still there.
666 assert!(matches!(
667 view.handle_key(key(KeyCode::Char('d'))),
668 ViewAction::None
669 ));
670 assert!(
671 list_fleets(ws.path())
672 .iter()
673 .any(|e| e.name == "Temp Fleet")
674 );
675
676 // 'n' cancels.
677 view.handle_key(key(KeyCode::Char('n')));
678 assert!(
679 list_fleets(ws.path())
680 .iter()
681 .any(|e| e.name == "Temp Fleet")
682 );
683
684 // 'd' then 'y' deletes and emits a receipt naming the removed path.
685 view.handle_key(key(KeyCode::Char('d')));
686 let action = view.handle_key(key(KeyCode::Char('y')));
687 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
688 panic!("expected FleetStoreChanged, got {action:?}");
689 };
690 assert!(message.contains("Deleted Fleet `Temp Fleet`"), "{message}");
691 assert!(list_fleets(ws.path()).is_empty());
692 }
693
694 #[test]
695 fn enter_selects_in_the_row_scope_without_opening_detail() {
696 let _lock = crate::test_support::lock_test_env();
697 let prev = std::env::var_os("CODEWHALE_HOME");
698 // SAFETY: serialised by lock_test_env.
699 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
700 let ws = tempfile::TempDir::new().unwrap();
701
702 save_in(ws.path(), FleetScope::Workspace, "Folder Fleet");
703 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
704 let idx = view
705 .entries
706 .iter()
707 .position(|e| e.name == "Folder Fleet")
708 .expect("workspace fleet listed");
709 view.row = idx;
710
711 let action = view.handle_key(key(KeyCode::Enter));
712 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
713 panic!("Enter must select and close, not open detail: {action:?}");
714 };
715 assert!(message.contains("Folder Fleet"), "{message}");
716 assert!(message.contains("folder"), "{message}");
717 let sel = selected_fleet(ws.path()).expect("selection");
718 assert_eq!(sel.name, "Folder Fleet");
719 assert_eq!(sel.scope, FleetScope::Workspace);
720
721 // SAFETY: serialised by lock_test_env.
722 unsafe {
723 match prev {
724 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
725 None => std::env::remove_var("CODEWHALE_HOME"),
726 }
727 }
728 }
729
730 #[test]
731 fn legacy_entry_opens_a_pager_instead_of_editing() {
732 let _lock = crate::test_support::lock_test_env();
733 let prev = std::env::var_os("CODEWHALE_HOME");
734 // SAFETY: serialised by lock_test_env.
735 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
736 let ws = tempfile::TempDir::new().unwrap();
737
738 // A legacy exact fleet file (workflow schema) in the personal dir.
739 std::fs::create_dir_all(sealed_home().join("fleets")).unwrap();
740 std::fs::write(
741 sealed_home().join("fleets/stopship.toml"),
742 r#"schema = "exact"
743 schema_revision = 1
744 name = "stopship"
745 members = []"#,
746 )
747 .unwrap();
748
749 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
750 let idx = view
751 .entries
752 .iter()
753 .position(|e| e.name == "stopship")
754 .expect("legacy entry listed");
755 view.row = idx;
756 let entry = view.selected_entry().expect("legacy entry listed");
757 assert!(entry.legacy);
758
759 let action = view.handle_key(key(KeyCode::Enter));
760 assert!(
761 matches!(action, ViewAction::Emit(ViewEvent::OpenTextPager { .. })),
762 "legacy entry must open a read-only pager: {action:?}"
763 );
764
765 // SAFETY: serialised by lock_test_env.
766 unsafe {
767 match prev {
768 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
769 None => std::env::remove_var("CODEWHALE_HOME"),
770 }
771 }
772 }
773
774 #[test]
775 fn migration_banner_appears_only_when_needed_and_migrates() {
776 let _lock = crate::test_support::lock_test_env();
777 let prev = std::env::var_os("CODEWHALE_HOME");
778 // SAFETY: serialised by lock_test_env.
779 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
780 let ws = tempfile::TempDir::new().unwrap();
781
782 // No legacy profiles: no banner.
783 let view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
784 assert!(!view.banner_visible());
785
786 // A workspace legacy role profile.
787 let agents = ws.path().join(".codewhale/agents");
788 std::fs::create_dir_all(&agents).unwrap();
789 std::fs::write(
790 agents.join("scout.toml"),
791 r#"id = "scout"
792 role_hint = "scout"
793 model = "deepseek-v4-flash"
794 provider = "deepseek"
795 "#,
796 )
797 .unwrap();
798
799 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
800 assert!(
801 view.banner_visible(),
802 "banner must show with legacy profiles"
803 );
804
805 let action = view.handle_key(key(KeyCode::Char('m')));
806 assert!(
807 matches!(action, ViewAction::Emit(ViewEvent::OpenTextPager { .. })),
808 "migration must open the receipt pager: {action:?}"
809 );
810
811 // The Default fleet now exists and is the user-global selection.
812 let entries = list_fleets(ws.path());
813 assert!(
814 entries.iter().any(|e| e.name == "Default" && !e.legacy),
815 "{entries:?}"
816 );
817 let sel = selected_fleet(ws.path()).expect("selection after migration");
818 assert_eq!(sel.name, "Default");
819
820 // SAFETY: serialised by lock_test_env.
821 unsafe {
822 match prev {
823 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
824 None => std::env::remove_var("CODEWHALE_HOME"),
825 }
826 }
827 }
828 }
829
829 lines RUST