fix: require confirmation before Ctrl-X shutdown
This commit is contained in:
@@ -284,6 +284,9 @@ pub struct App {
|
|||||||
/// records the instant; a second press within the timeout exits the
|
/// records the instant; a second press within the timeout exits the
|
||||||
/// TUI (the Worker itself stays alive).
|
/// TUI (the Worker itself stays alive).
|
||||||
pub quit_confirm: Option<std::time::Instant>,
|
pub quit_confirm: Option<std::time::Instant>,
|
||||||
|
/// Independent 2-tap guard for `Ctrl-X` when the Worker is idle or
|
||||||
|
/// stopped. A second press within the timeout shuts down the Worker.
|
||||||
|
pub shutdown_confirm: Option<std::time::Instant>,
|
||||||
/// Full display history in render order.
|
/// Full display history in render order.
|
||||||
pub blocks: Vec<Block>,
|
pub blocks: Vec<Block>,
|
||||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||||
@@ -373,6 +376,7 @@ impl App {
|
|||||||
command_completion_selected: None,
|
command_completion_selected: None,
|
||||||
quit: false,
|
quit: false,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
|
shutdown_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
run_error_messages: Vec::new(),
|
run_error_messages: Vec::new(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -566,6 +570,7 @@ impl App {
|
|||||||
self.run_started_at = Some(Instant::now());
|
self.run_started_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
self.quit_confirm = None;
|
self.quit_confirm = None;
|
||||||
|
self.shutdown_confirm = None;
|
||||||
} else {
|
} else {
|
||||||
self.run_started_at = None;
|
self.run_started_at = None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -824,13 +824,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||||
KeyCode::Char('x') if ctrl => Some(match app.worker_status {
|
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
|
||||||
WorkerStatus::Running | WorkerStatus::Paused => {
|
|
||||||
app.clear_queued_inputs();
|
|
||||||
Some(Method::Cancel)
|
|
||||||
}
|
|
||||||
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
|
|
||||||
}),
|
|
||||||
KeyCode::Char('d') if ctrl => {
|
KeyCode::Char('d') if ctrl => {
|
||||||
app.quit = true;
|
app.quit = true;
|
||||||
Some(None)
|
Some(None)
|
||||||
@@ -1087,6 +1081,33 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
|
|
||||||
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Running / Paused → send `Method::Cancel` immediately.
|
||||||
|
/// Idle / Stopped → 2-tap to shut down the Worker.
|
||||||
|
fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
|
||||||
|
if matches!(
|
||||||
|
app.worker_status,
|
||||||
|
WorkerStatus::Running | WorkerStatus::Paused
|
||||||
|
) {
|
||||||
|
app.shutdown_confirm = None;
|
||||||
|
app.clear_queued_inputs();
|
||||||
|
return Some(Method::Cancel);
|
||||||
|
}
|
||||||
|
if let Some(pressed_at) = app.shutdown_confirm
|
||||||
|
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
|
||||||
|
{
|
||||||
|
app.shutdown_confirm = None;
|
||||||
|
return Some(Method::Shutdown);
|
||||||
|
}
|
||||||
|
app.shutdown_confirm = Some(std::time::Instant::now());
|
||||||
|
app.flash_actionbar_notice(
|
||||||
|
"Press Ctrl-X again within 3 s to shut down the Worker.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
CONFIRM_TIMEOUT,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Running → send `Method::Pause`.
|
/// Running → send `Method::Pause`.
|
||||||
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
||||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
@@ -1472,15 +1493,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_x_shutdown_while_idle_is_unchanged() {
|
fn ctrl_x_requires_confirmation_before_shutdown_while_idle() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
let ctrl_x = || KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, ctrl_x()).is_none());
|
||||||
|
assert!(app.shutdown_confirm.is_some());
|
||||||
|
let notice = app
|
||||||
|
.current_actionbar_notice(std::time::Instant::now())
|
||||||
|
.expect("first Ctrl-X should arm shutdown confirmation");
|
||||||
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
|
assert!(notice.text.contains("Ctrl-X"));
|
||||||
|
assert!(notice.text.contains("shut down the Worker"));
|
||||||
|
assert!(!has_alert(&app, "shut down the Worker"));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, ctrl_x()),
|
||||||
|
Some(Method::Shutdown)
|
||||||
|
));
|
||||||
|
assert!(app.shutdown_confirm.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_and_ctrl_x_confirmations_do_not_authorize_each_other() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_worker_status(WorkerStatus::Idle);
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
|
||||||
let shutdown = handle_key(
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(app.quit_confirm.is_some());
|
||||||
|
assert!(app.shutdown_confirm.is_none());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(matches!(shutdown, Some(Method::Shutdown)));
|
assert!(!app.quit);
|
||||||
|
assert!(app.shutdown_confirm.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2196,12 +2255,17 @@ mod tests {
|
|||||||
handle_key(&mut app, key(KeyCode::Tab));
|
handle_key(&mut app, key(KeyCode::Tab));
|
||||||
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
||||||
|
|
||||||
let method = handle_key(
|
let first = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
let second = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(method, Some(Method::Shutdown)));
|
assert!(first.is_none());
|
||||||
|
assert!(matches!(second, Some(Method::Shutdown)));
|
||||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user