From 699290ccb1ed3f74ef4412a95d13de628d01bc6e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 29 Jul 2026 19:15:57 +0900 Subject: [PATCH] cli: support panel restore list --- crates/tui/src/dashboard/mod.rs | 51 +++++++++----- crates/tui/src/dashboard/tests.rs | 5 +- crates/tui/src/lib.rs | 4 +- crates/tui/src/worker_list.rs | 33 +++++++++ crates/yoi/src/main.rs | 113 +++++++++++++++++++++--------- 5 files changed, 150 insertions(+), 56 deletions(-) diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs index 55f2a220..d72c422d 100644 --- a/crates/tui/src/dashboard/mod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -118,8 +118,11 @@ pub(crate) enum DashboardOutcome { Open(OpenWorkerRequest), } -pub(crate) async fn launch(runtime_command: WorkerRuntimeCommand) -> Result<(), Box> { - let mut app = load_app(runtime_command.clone()).await?; +pub(crate) async fn launch( + runtime_command: WorkerRuntimeCommand, + include_stopped: bool, +) -> Result<(), Box> { + let mut app = load_app(runtime_command.clone(), include_stopped).await?; let mut terminal = crate::console::enter_dashboard_fullscreen()?; loop { match run_loop(&mut terminal, &mut app).await? { @@ -174,8 +177,9 @@ pub(crate) struct OpenWorkerRequest { pub(crate) async fn load_app( runtime_command: WorkerRuntimeCommand, + include_stopped: bool, ) -> Result { - Ok(DashboardApp::loading(runtime_command)) + Ok(DashboardApp::loading(runtime_command, include_stopped)) } async fn run_loop( @@ -222,14 +226,14 @@ async fn run_loop( } if let Some(mode) = deferred_enter_reload.take() { - if pending_reload.start(mode) { + if pending_reload.start(mode, app.include_stopped) { app.refreshing = true; } } let now = Instant::now(); if now >= next_poll { - pending_reload.start(OrchestratorLifecycleMode::Observe); + pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped); next_poll = now + DASHBOARD_POLL_INTERVAL; continue; } @@ -275,7 +279,8 @@ async fn run_loop( terminal.draw(|f| render::draw(f, app))?; let result = dispatch_ticket_action(request).await; app.finish_ticket_action_dispatch(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe) { + if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) + { app.refreshing = true; } next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; @@ -311,7 +316,8 @@ async fn run_loop( } Err(error) => app.finish_ready_ticket_planning_return_error(error), } - if pending_reload.start(OrchestratorLifecycleMode::Observe) { + if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) + { app.refreshing = true; } next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; @@ -328,7 +334,8 @@ async fn run_loop( terminal.draw(|f| render::draw(f, app))?; let result = launch_intake_with_handoff(request).await; app.finish_intake_launch(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe) { + if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) + { app.refreshing = true; } next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; @@ -345,7 +352,8 @@ async fn run_loop( terminal.draw(|f| render::draw(f, app))?; let result = dispatch_companion_message(request).await; app.finish_companion_send(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe) { + if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) + { app.refreshing = true; } next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; @@ -366,7 +374,7 @@ struct PendingReload { } impl PendingReload { - fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode) -> bool { + fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode, include_stopped: bool) -> bool { if self.handle.is_some() { return false; } @@ -382,7 +390,7 @@ impl PendingReload { self.handle = Some(tokio::spawn(async move { #[cfg(feature = "e2e-test")] crate::e2e_observer::hold_background_task_if_requested("reload").await; - load_dashboard_snapshot(None, lifecycle_mode).await + load_dashboard_snapshot(None, lifecycle_mode, include_stopped).await })); true } @@ -1219,6 +1227,7 @@ pub(crate) struct DashboardApp { refreshing: bool, enter_reload: Option, runtime_command: WorkerRuntimeCommand, + include_stopped: bool, last_companion_lifecycle_failure: Option, last_orchestrator_lifecycle_failure: Option, orchestrator_work_set: OrchestratorWorkSet, @@ -1228,7 +1237,7 @@ pub(crate) struct DashboardApp { } impl DashboardApp { - fn loading(runtime_command: WorkerRuntimeCommand) -> Self { + fn loading(runtime_command: WorkerRuntimeCommand, include_stopped: bool) -> Self { let workspace_root = current_workspace_root(); let mut panel = WorkspacePanelViewModel::empty(&workspace_root); panel @@ -1257,6 +1266,7 @@ impl DashboardApp { runtime_command: runtime_command.clone(), }), runtime_command, + include_stopped, last_companion_lifecycle_failure: None, last_orchestrator_lifecycle_failure: None, orchestrator_work_set: OrchestratorWorkSet::default(), @@ -2511,6 +2521,7 @@ enum OrchestratorLifecycleMode { async fn load_dashboard_snapshot( selected_name: Option, lifecycle_mode: OrchestratorLifecycleMode, + include_stopped: bool, ) -> Result { let workspace_root = current_workspace_root(); #[cfg(feature = "e2e-test")] @@ -2524,7 +2535,8 @@ async fn load_dashboard_snapshot( #[cfg(feature = "e2e-test")] let source_started = Instant::now(); - let mut list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES).await?; + let mut list = + load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?; #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { source: "pod_metadata_status_probe.initial", @@ -2564,7 +2576,7 @@ async fn load_dashboard_snapshot( if companion.reload_workers { #[cfg(feature = "e2e-test")] let source_started = Instant::now(); - list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES).await?; + list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?; #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { source: "pod_metadata_status_probe.after_companion_reload", @@ -2622,7 +2634,7 @@ async fn load_dashboard_snapshot( if orchestrator.reload_workers { #[cfg(feature = "e2e-test")] let source_started = Instant::now(); - list = load_worker_list(list_selected_name, MAX_ENTRIES).await?; + list = load_worker_list(list_selected_name, MAX_ENTRIES, include_stopped).await?; #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { source: "pod_metadata_status_probe.after_orchestrator_reload", @@ -3420,6 +3432,7 @@ fn existing_ticket_claim_notice( async fn load_worker_list( selected_name: Option, max_entries: usize, + include_stopped: bool, ) -> Result { let store_dir = default_store_dir()?; let store = FsStore::new(&store_dir)?; @@ -3429,14 +3442,18 @@ async fn load_worker_list( let live = read_reachable_live_worker_infos(&store) .await .unwrap_or_default(); - Ok(WorkerList::from_workspace_sources( + let mut list = WorkerList::from_workspace_sources( WorkerVisibilitySource::ResumePicker, stored, live, selected_name, max_entries, ¤t_workspace_root(), - )) + ); + if !include_stopped { + list.retain_live_entries(); + } + Ok(list) } #[derive(Debug, Clone)] diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index 9d7a2327..7bd677c7 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -2329,7 +2329,7 @@ fn dashboard_open_failure_keeps_composer_and_sets_notice() { #[test] fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() { - let app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi")); + let app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false); assert!(app.panel.rows.is_empty()); assert!( @@ -3241,6 +3241,7 @@ fn app_with_panel(list: WorkerList, panel: WorkspacePanelViewModel) -> Dashboard refreshing: false, enter_reload: None, runtime_command: WorkerRuntimeCommand::for_executable("/tmp/yoi"), + include_stopped: false, last_companion_lifecycle_failure, last_orchestrator_lifecycle_failure, orchestrator_work_set: OrchestratorWorkSet::default(), @@ -3392,7 +3393,7 @@ fn section_names<'a>(list: &'a WorkerList, section: &DashboardSection) -> Vec<&' #[test] fn ticket_action_error_records_f2_diagnostic_details() { - let mut app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi")); + let mut app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false); let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs"; app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string()))); diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 0236fcfb..425a7c29 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -179,8 +179,8 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - LaunchMode::Panel { .. } => match target.dashboard() { - Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await, + LaunchMode::Panel { include_stopped } => match target.dashboard() { + Ok(dashboard) => dashboard::launch(dashboard.runtime_command, include_stopped).await, Err(e) => Err(Box::new(e) as Box), }, }; diff --git a/crates/tui/src/worker_list.rs b/crates/tui/src/worker_list.rs index 14a96d49..e94078a3 100644 --- a/crates/tui/src/worker_list.rs +++ b/crates/tui/src/worker_list.rs @@ -126,6 +126,17 @@ impl WorkerList { self.selected_name = self.entries.get(index).map(|entry| entry.name.clone()); } + pub(crate) fn retain_live_entries(&mut self) { + self.entries.retain(|entry| entry.live.is_some()); + if !self + .selected_name + .as_ref() + .is_some_and(|selected| self.entries.iter().any(|entry| entry.name == *selected)) + { + self.selected_name = self.entries.first().map(|entry| entry.name.clone()); + } + } + pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> { let index = self.selected_index(); self.entries.get(index) @@ -736,6 +747,28 @@ mod tests { ); } + #[test] + fn retain_live_entries_removes_stored_only_rows_and_reselects() { + let dir = tempdir().unwrap(); + let store = FsStore::new(dir.path()).unwrap(); + let session_id = new_session_id(); + let segment_id = new_segment_id(); + append_start(&store, session_id, segment_id, 10); + let mut list = WorkerList::from_sources( + SOURCE, + vec![metadata_info(&store, "stored", session_id, segment_id)], + vec![live_info("live", WorkerStatus::Idle)], + Some("stored".to_string()), + 10, + ); + + list.retain_live_entries(); + + assert_eq!(list.entries.len(), 1); + assert_eq!(list.entries[0].name, "live"); + assert_eq!(list.selected_entry().unwrap().name, "live"); + } + #[test] fn stored_only_row_can_restore_and_open_but_not_direct_send() { let dir = tempdir().unwrap(); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 26c3998d..80dcd26f 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -20,7 +20,7 @@ use cli_connection::{ resolve_backend_cli_connection, resolve_connection_aware_cli_connection, resolve_local_cli_connection, }; -use client::{BackendAuthTarget, Target, start_device_login, wait_for_device_login}; +use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; use memory_lint::{LintCliOptions, LintStatus}; use serde::{Deserialize, Serialize}; use session_store::SegmentId; @@ -388,19 +388,25 @@ fn parse_args_slice_with_connection_resolver( return Ok(Mode::Mcp(mcp_cli)); } "panel" => { - let workspace_root = parse_panel_workspace(&args[1..])?; + let panel_options = parse_panel_args(&args[1..])?; let target = resolve_tui_target( connection_resolver, CliCommand::Panel, &target_selection, - &workspace_root, + &panel_options.workspace_root, )?; + if panel_options.include_stopped && target.kind() == TargetKind::Backend { + return Err(ParseError( + "yoi panel -r is only supported for local targets; Backend panel restore UI is not implemented" + .to_string(), + )); + } return Ok(Mode::Tui { target, mode: LaunchMode::Panel { - include_stopped: false, + include_stopped: panel_options.include_stopped, }, - workspace_root, + workspace_root: panel_options.workspace_root, }); } "keys" => { @@ -1554,33 +1560,48 @@ fn mcp_usage() -> &'static str { "usage: yoi mcp list [--workspace PATH] [--profile REF] [--json]\n yoi mcp show [--workspace PATH] [--profile REF] [--json]\n yoi mcp tools [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp resources [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp prompts [server] [--workspace PATH] [--profile REF] [--json]" } -fn parse_panel_workspace(args: &[String]) -> Result { - if let Some(arg) = args.iter().find(|arg| is_backend_target_option(arg)) { - return Err(backend_target_option_error_for_local_command( - CliCommand::Panel, - arg, - )); - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PanelCliOptions { + workspace_root: PathBuf, + include_stopped: bool, +} - match args { - [] => std::env::current_dir() - .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))), - [flag, value] if flag == "--workspace" => Ok(PathBuf::from(value)), - [flag] if flag.starts_with("--workspace=") => { - let value = flag.trim_start_matches("--workspace="); - if value.is_empty() { - Err(ParseError("--workspace requires a value".to_string())) - } else { - Ok(PathBuf::from(value)) +fn parse_panel_args(args: &[String]) -> Result { + let mut workspace_root: Option = None; + let mut include_stopped = false; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--workspace" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace requires a path".to_string()))?; + workspace_root = Some(PathBuf::from(value)); + i += 2; + } + arg if arg.starts_with("--workspace=") => { + let value = arg.trim_start_matches("--workspace="); + if value.is_empty() { + return Err(ParseError("--workspace requires a path".to_string())); + } + workspace_root = Some(PathBuf::from(value)); + i += 1; + } + "-r" | "--stopped" | "--restoreable" => { + include_stopped = true; + i += 1; + } + other => { + return Err(ParseError(format!( + "unknown panel option `{other}`; usage: yoi [TARGET] panel [-r|--stopped] [--workspace ]" + ))); } } - [flag] if flag == "--workspace" => { - Err(ParseError("--workspace requires a value".to_string())) - } - _ => Err(ParseError( - "yoi panel accepts only --workspace ".to_string(), - )), } + Ok(PanelCliOptions { + workspace_root: workspace_root.unwrap_or(current_dir()?), + include_stopped, + }) } fn parse_session_id(value: &str) -> Result { @@ -1595,7 +1616,7 @@ Usage: yoi [TARGET] [CONSOLE_OPTIONS] yoi [TARGET] workers [-r|--stopped] [--workspace ] [--runtime-id ] yoi [TARGET] resume [--workspace |--all] [--runtime-id ] - yoi [TARGET] panel [--workspace ] + yoi [TARGET] panel [-r|--stopped] [--workspace ] yoi [--backend ] login [--no-wait] yoi [OPTIONS] @@ -1622,6 +1643,7 @@ Connection-aware commands: yoi workers -r Include stopped Workers. --restoreable is accepted as a legacy alias. yoi resume Open the Worker picker with stopped Workers included. yoi panel Open the dashboard/panel TUI for the selected target. + yoi panel -r Local only: include stopped/restorable Worker rows. Console options: --workspace Local workspace root for local Console/Worker lists (defaults to cwd) @@ -1813,12 +1835,6 @@ backend = "shared" #[test] fn parse_local_only_commands_reject_backend_target_options() { - let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err(); - assert_eq!( - err.to_string(), - "yoi panel uses a local connection target and cannot accept Backend target option `--runtime-id`" - ); - let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err(); assert_eq!( err.to_string(), @@ -2418,6 +2434,33 @@ backend = "shared" } } + #[test] + fn parse_panel_stopped_mode() { + for flag in ["-r", "--stopped", "--restoreable"] { + match parse_args_from(["panel", flag, "--workspace", "/tmp/other-workspace"]).unwrap() { + Mode::Tui { + mode: + LaunchMode::Panel { + include_stopped: true, + }, + workspace_root, + .. + } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), + _ => panic!("expected Panel stopped mode for {flag}"), + } + } + } + + #[test] + fn parse_backend_panel_stopped_is_not_supported() { + let err = + parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi panel -r is only supported for local targets; Backend panel restore UI is not implemented" + ); + } + #[test] fn parse_dashboard_word_is_not_an_alias_or_worker_name() { let err = parse_args_from(["dashboard"]).unwrap_err();