use crate::ai_command_runner::AiCommandRunner;
use crate::config::OrchestratorConfig;
use crate::error::Result;
use crate::tui::events::{LogEntry, OrchestratorEvent, TuiCommand};
use crate::tui::state::AppState;
use crate::tui::types::{AppExecutionMode, ModalState, StopMode};
use crate::vcs::VcsResult;
use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::DefaultTerminal;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::debug;
use super::terminal::{execute_worktree_command, suspend_terminal_and_execute_sync};
use super::worktrees::load_worktrees_with_conflict_check;
#[async_trait]
trait WorktreePlusRuntime {
async fn check_git_repo(&self, repo_root: &Path) -> VcsResult<bool>;
async fn generate_unique_branch_name(
&self,
repo_root: &Path,
prefix: &str,
max_attempts: u32,
) -> VcsResult<String>;
async fn worktree_add(
&self,
repo_root: &Path,
worktree_path: &str,
branch_name: &str,
base_commit: &str,
) -> VcsResult<()>;
async fn validate_worktree_command_cwd(
&self,
repo_root: &Path,
worktree_path: &Path,
) -> VcsResult<()>;
async fn run_worktree_setup(&self, repo_root: &Path, worktree_path: &Path) -> VcsResult<()>;
async fn worktree_remove_after_setup_failure(
&self,
repo_root: &Path,
worktree_path: &str,
) -> VcsResult<()>;
async fn execute_worktree_command(
&self,
terminal: &mut DefaultTerminal,
command: &str,
worktree_path: &Path,
ai_runner: &AiCommandRunner,
app: &mut AppState,
) -> Result<()>;
}
struct ProductionWorktreePlusRuntime;
#[async_trait]
impl WorktreePlusRuntime for ProductionWorktreePlusRuntime {
async fn check_git_repo(&self, repo_root: &Path) -> VcsResult<bool> {
crate::vcs::git::commands::check_git_repo(repo_root).await
}
async fn generate_unique_branch_name(
&self,
repo_root: &Path,
prefix: &str,
max_attempts: u32,
) -> VcsResult<String> {
crate::vcs::git::commands::generate_unique_branch_name(repo_root, prefix, max_attempts)
.await
}
async fn worktree_add(
&self,
repo_root: &Path,
worktree_path: &str,
branch_name: &str,
base_commit: &str,
) -> VcsResult<()> {
crate::vcs::git::commands::worktree_add(repo_root, worktree_path, branch_name, base_commit)
.await
}
async fn validate_worktree_command_cwd(
&self,
repo_root: &Path,
worktree_path: &Path,
) -> VcsResult<()> {
crate::vcs::git::commands::validate_worktree_command_cwd(repo_root, worktree_path).await
}
async fn run_worktree_setup(&self, repo_root: &Path, worktree_path: &Path) -> VcsResult<()> {
crate::vcs::git::commands::run_worktree_setup(repo_root, worktree_path)
.await
.map(|_report| ())
}
async fn worktree_remove_after_setup_failure(
&self,
repo_root: &Path,
worktree_path: &str,
) -> VcsResult<()> {
crate::vcs::git::commands::worktree_remove_with_options(
repo_root,
worktree_path,
crate::vcs::git::commands::WorktreeRemoveOptions {
skip_teardown: true,
},
)
.await
}
async fn execute_worktree_command(
&self,
terminal: &mut DefaultTerminal,
command: &str,
worktree_path: &Path,
ai_runner: &AiCommandRunner,
app: &mut AppState,
) -> Result<()> {
execute_worktree_command(terminal, command, worktree_path, ai_runner, app).await
}
}
pub struct KeyEventContext<'a> {
pub app: &'a mut AppState,
pub terminal: &'a mut DefaultTerminal,
pub repo_root: &'a Path,
pub config: &'a OrchestratorConfig,
pub worktree_base_dir: &'a Path,
pub tx: &'a mpsc::Sender<OrchestratorEvent>,
pub cmd_tx: &'a mpsc::Sender<TuiCommand>,
pub ai_runner: &'a AiCommandRunner,
pub supervisor: &'a Arc<crate::tui::run_supervisor::TuiRunSupervisor>,
}
fn request_local_tui_quit(
app: &mut AppState,
supervisor: &crate::tui::run_supervisor::TuiRunSupervisor,
) {
app.should_quit = true;
if let Some(cancel) = supervisor.cancel_token() {
cancel.cancel();
app.add_log(LogEntry::warn(
"Quit requested: cancelling local orchestration before TUI shutdown",
));
}
}
pub async fn handle_tab_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
let new_view = match ctx.app.view_mode {
ViewMode::Changes => ViewMode::Worktrees,
ViewMode::Worktrees => ViewMode::Changes,
};
if new_view == ViewMode::Worktrees {
let load_tx = ctx.tx.clone();
let load_repo_root = ctx.repo_root.to_path_buf();
tokio::spawn(async move {
match load_worktrees_with_conflict_check(&load_repo_root).await {
Ok(worktrees) => {
let _ = load_tx
.send(OrchestratorEvent::WorktreesRefreshed { worktrees })
.await;
}
Err(e) => {
let _ = load_tx
.send(OrchestratorEvent::Log(LogEntry::error(format!(
"Failed to load worktrees: {}",
e
))))
.await;
}
}
});
}
ctx.app.view_mode = new_view;
Ok(())
}
pub fn handle_cursor_movement(app: &mut AppState, is_up: bool) {
use crate::tui::types::ViewMode;
match app.view_mode {
ViewMode::Changes => {
if is_up {
app.cursor_up()
} else {
app.cursor_down()
}
}
ViewMode::Worktrees => {
if is_up {
app.worktree_cursor_up()
} else {
app.worktree_cursor_down()
}
}
}
}
pub async fn handle_editor_launch(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
let view_mode = ctx.app.view_mode;
let change_id = if !ctx.app.changes.is_empty() && ctx.app.cursor_index < ctx.app.changes.len() {
Some(ctx.app.changes[ctx.app.cursor_index].id.clone())
} else {
None
};
let worktree_path = ctx.app.get_selected_worktree_path();
if view_mode == ViewMode::Worktrees && ctx.app.suppress_if_selected_worktree_deleting() {
ctx.app.add_log(LogEntry::warn(
"Editor ignored: worktree is already being deleted",
));
return Ok(());
}
suspend_terminal_and_execute_sync(ctx.terminal, || {
match view_mode {
ViewMode::Changes => {
if let Some(id) = change_id {
if let Err(e) = crate::tui::utils::launch_editor_for_change(&id) {
eprintln!("Failed to launch editor: {}", e);
}
}
}
ViewMode::Worktrees => {
if let Some(path) = worktree_path {
if let Err(e) = crate::tui::utils::launch_editor_in_dir(&path) {
eprintln!("Failed to launch editor: {}", e);
}
}
}
}
Ok(())
})
}
pub async fn handle_merge_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
debug!("M key pressed: view_mode={:?}", ctx.app.view_mode);
match ctx.app.view_mode {
ViewMode::Changes => {
debug!("M key (Changes view): attempting resolve_merge");
if let Some(cmd) = ctx.app.resolve_merge() {
debug!("M key (Changes view): sending command {:?}", cmd);
let _ = ctx.cmd_tx.send(cmd).await;
} else {
debug!("M key (Changes view): resolve_merge returned None");
}
}
ViewMode::Worktrees => {
debug!("M key (Worktrees view): attempting request_merge_worktree_branch");
if let Some(cmd) = ctx.app.request_merge_worktree_branch() {
debug!("M key (Worktrees view): sending command {:?}", cmd);
let _ = ctx.cmd_tx.send(cmd).await;
} else {
debug!("M key (Worktrees view): request_merge_worktree_branch returned None");
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EscStopAction {
RequestGracefulStop,
RequestImmediateStop,
None,
}
pub(crate) fn esc_stop_action(
mode: &AppExecutionMode,
stop_mode: &StopMode,
persistent_scheduler_idle: bool,
) -> EscStopAction {
match mode {
AppExecutionMode::Running => EscStopAction::RequestGracefulStop,
AppExecutionMode::Select if persistent_scheduler_idle => EscStopAction::RequestGracefulStop,
AppExecutionMode::Stopping if *stop_mode == StopMode::ImmediatePending => {
EscStopAction::None
}
AppExecutionMode::Stopping => EscStopAction::RequestImmediateStop,
_ => EscStopAction::None,
}
}
pub(crate) async fn handle_esc_key_inner(app: &mut AppState, cmd_tx: &mpsc::Sender<TuiCommand>) {
match esc_stop_action(
&app.execution_mode,
&app.stop_mode,
app.persistent_scheduler_idle,
) {
EscStopAction::RequestGracefulStop => {
app.stop_mode = StopMode::GracefulPending;
let _ = cmd_tx.send(TuiCommand::Stop).await;
}
EscStopAction::RequestImmediateStop => {
app.stop_mode = StopMode::ImmediatePending;
let _ = cmd_tx.send(TuiCommand::ForceStop).await;
}
EscStopAction::None => {}
}
}
pub async fn handle_esc_key(ctx: &mut KeyEventContext<'_>) {
handle_esc_key_inner(ctx.app, ctx.cmd_tx).await;
}
fn handle_start_key_inner(app: &mut AppState) -> Option<TuiCommand> {
if app.execution_mode == AppExecutionMode::Stopping {
return Some(TuiCommand::CancelStop);
}
Some(TuiCommand::StartProcessing(Vec::new()))
}
pub fn handle_start_key(ctx: &mut KeyEventContext<'_>) -> Option<TuiCommand> {
handle_start_key_inner(ctx.app)
}
pub async fn handle_enter_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
if ctx.app.view_mode != ViewMode::Worktrees {
if ctx.app.open_error_details_popup() {
return Ok(());
}
ctx.app
.add_log(LogEntry::warn("Enter ignored: not in Worktrees view"));
return Ok(());
}
let Some(worktree_path_str) = ctx.app.get_selected_worktree_path() else {
ctx.app
.add_log(LogEntry::warn("Enter ignored: no worktree selected"));
return Ok(());
};
if ctx.app.suppress_if_selected_worktree_deleting() {
ctx.app.add_log(LogEntry::warn(
"Enter ignored: worktree is already being deleted",
));
return Ok(());
}
let Some(template) = ctx.config.get_worktree_command().map(str::to_string) else {
ctx.app.add_log(LogEntry::warn(
"Enter ignored: worktree_command not configured",
));
return Ok(());
};
let Some(repo_root_str) = ctx.repo_root.to_str() else {
ctx.app.add_log(LogEntry::error(
"Failed to resolve repo root path".to_string(),
));
return Ok(());
};
let command =
OrchestratorConfig::expand_worktree_command(&template, &worktree_path_str, repo_root_str);
ctx.app.add_log(LogEntry::info(format!(
"Running worktree command in {}",
worktree_path_str
)));
let worktree_path = Path::new(&worktree_path_str);
execute_worktree_command(
ctx.terminal,
&command,
worktree_path,
ctx.ai_runner,
ctx.app,
)
.await
}
pub async fn handle_plus_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
handle_plus_key_with_runtime(ctx, &ProductionWorktreePlusRuntime).await
}
struct PreparedWorktreeCommand {
command: String,
worktree_path: PathBuf,
}
async fn handle_plus_key_with_runtime(
ctx: &mut KeyEventContext<'_>,
runtime: &dyn WorktreePlusRuntime,
) -> Result<()> {
let Some(prepared) = prepare_plus_worktree_command(
ctx.app,
ctx.config,
ctx.repo_root,
ctx.worktree_base_dir,
runtime,
)
.await
else {
return Ok(());
};
runtime
.execute_worktree_command(
ctx.terminal,
&prepared.command,
&prepared.worktree_path,
ctx.ai_runner,
ctx.app,
)
.await
}
async fn prepare_plus_worktree_command(
app: &mut AppState,
config: &OrchestratorConfig,
repo_root: &Path,
worktree_base_dir: &Path,
runtime: &dyn WorktreePlusRuntime,
) -> Option<PreparedWorktreeCommand> {
use crate::tui::types::ViewMode;
if app.view_mode != ViewMode::Worktrees {
return None;
}
let template = config.get_worktree_command().map(str::to_string)?;
let is_git_repo = match runtime.check_git_repo(repo_root).await {
Ok(is_repo) => is_repo,
Err(err) => {
app.add_log(LogEntry::error(format!(
"Failed to check git repo: {}",
err
)));
return None;
}
};
if !super::worktrees::should_trigger_worktree_command(config, is_git_repo) {
return None;
}
if let Err(err) = std::fs::create_dir_all(worktree_base_dir) {
app.add_log(LogEntry::error(format!(
"Failed to prepare worktree base dir: {}",
err
)));
return None;
}
let worktree_path = super::worktrees::build_worktree_path(worktree_base_dir);
let Some(worktree_path_str) = worktree_path.to_str() else {
app.add_log(LogEntry::error(
"Failed to resolve worktree path".to_string(),
));
return None;
};
let Some(repo_root_str) = repo_root.to_str() else {
app.add_log(LogEntry::error(
"Failed to resolve repo root path".to_string(),
));
return None;
};
let branch_name = match runtime
.generate_unique_branch_name(repo_root, "ws-session", 10)
.await
{
Ok(name) => name,
Err(err) => {
app.add_log(LogEntry::error(format!(
"Failed to generate unique branch name: {}",
err
)));
return None;
}
};
if let Err(err) = runtime
.worktree_add(repo_root, worktree_path_str, &branch_name, "HEAD")
.await
{
app.add_log(LogEntry::error(format!(
"Failed to create worktree: {}",
err
)));
return None;
}
app.add_log(LogEntry::info(format!(
"Created worktree with branch '{}' at {}",
branch_name, worktree_path_str
)));
if !validate_plus_worktree_cwd(app, runtime, repo_root, &worktree_path, "after create").await {
return None;
}
if let Err(err) = runtime.run_worktree_setup(repo_root, &worktree_path).await {
app.add_log(LogEntry::error(format!(
"Failed to run worktree setup for {}: {}",
worktree_path.display(),
err
)));
app.add_log(LogEntry::warn(format!(
"Cleaning up worktree after setup failure: {}",
worktree_path.display()
)));
match runtime
.worktree_remove_after_setup_failure(repo_root, worktree_path_str)
.await
{
Ok(()) => app.add_log(LogEntry::info(format!(
"Cleaned up worktree after setup failure: {}",
worktree_path.display()
))),
Err(cleanup_err) => app.add_log(LogEntry::error(format!(
"Failed to cleanup worktree after setup failure at {}: {}",
worktree_path.display(),
cleanup_err
))),
}
return None;
}
if !validate_plus_worktree_cwd(app, runtime, repo_root, &worktree_path, "after setup").await {
return None;
}
if !validate_plus_worktree_cwd(
app,
runtime,
repo_root,
&worktree_path,
"before command launch",
)
.await
{
return None;
}
let command =
OrchestratorConfig::expand_worktree_command(&template, worktree_path_str, repo_root_str);
app.add_log(LogEntry::info(format!(
"Running worktree command in {}",
worktree_path_str
)));
Some(PreparedWorktreeCommand {
command,
worktree_path,
})
}
async fn validate_plus_worktree_cwd(
app: &mut AppState,
runtime: &dyn WorktreePlusRuntime,
repo_root: &Path,
worktree_path: &Path,
phase: &str,
) -> bool {
match runtime
.validate_worktree_command_cwd(repo_root, worktree_path)
.await
{
Ok(()) => true,
Err(err) => {
app.add_log(LogEntry::error(format!(
"Suppressing worktree command launch: invalid cwd {} during {}: {}",
worktree_path.display(),
phase,
err
)));
false
}
}
}
pub(crate) fn handle_warning_popup_key(app: &mut AppState, key: KeyEvent) -> bool {
if app.warning_popup.is_none() {
return false;
}
match key.code {
KeyCode::Esc => {
app.clear_warning_popup();
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_warning_popup(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_warning_popup(1);
}
KeyCode::PageUp => {
app.scroll_warning_popup(-5);
}
KeyCode::PageDown => {
app.scroll_warning_popup(5);
}
_ => {}
}
true
}
pub(crate) fn handle_error_details_popup_key(app: &mut AppState, key: KeyEvent) -> bool {
if app.error_details_popup.is_none() {
return false;
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return false;
}
match key.code {
KeyCode::Esc => {
app.close_error_details_popup();
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_error_details_popup(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_error_details_popup(1);
}
KeyCode::PageUp => {
app.scroll_error_details_popup(-5);
}
KeyCode::PageDown => {
app.scroll_error_details_popup(5);
}
KeyCode::Char('c') if key.modifiers.is_empty() => {
app.copy_error_details();
}
_ => {}
}
true
}
pub(crate) async fn handle_modal_key(key: KeyEvent, ctx: &mut KeyEventContext<'_>) -> bool {
let Some(modal) = ctx.app.modal.clone() else {
return false;
};
match modal {
ModalState::QrPopup => {
ctx.app.hide_qr_popup();
}
ModalState::ConfirmForceKill { .. } => match (key.code, key.modifiers) {
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
if let Some(cmd) = ctx.app.confirm_force_kill() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.cancel_force_kill();
}
_ => {}
},
ModalState::ConfirmWorktreeDelete { .. } => match (key.code, key.modifiers) {
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
if let Some(cmd) = ctx.app.confirm_worktree_action_delete() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.cancel_worktree_action();
}
(KeyCode::Char('s'), _) | (KeyCode::Char('S'), _) => {
if let Some(cmd) = ctx.app.confirm_worktree_action_delete_with_options(true) {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
_ => {}
},
ModalState::ConfirmDirtyDiscard { .. } => match (key.code, key.modifiers) {
(KeyCode::Char('X'), _) => {
if let Some(cmd) = ctx.app.confirm_dirty_discard() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.cancel_worktree_action();
}
_ => {}
},
ModalState::ConfirmAheadDiscard { .. } => match (key.code, key.modifiers) {
(KeyCode::Char('X'), _) => {
if let Some(cmd) = ctx.app.confirm_ahead_discard() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.cancel_worktree_action();
}
_ => {}
},
}
true
}
pub(crate) fn handle_bulk_toggle_key(app: &mut AppState) -> Vec<TuiCommand> {
use crate::tui::types::ViewMode;
if app.view_mode != ViewMode::Changes {
return Vec::new();
}
app.toggle_all_marks()
}
pub(crate) fn handle_selected_proposal_log_filter_key(app: &mut AppState) {
use crate::tui::types::ViewMode;
if app.view_mode != ViewMode::Changes {
return;
}
app.toggle_selected_proposal_log_filter();
}
pub async fn handle_key_event(
key: KeyEvent,
ctx: &mut KeyEventContext<'_>,
) -> Result<Option<TuiCommand>> {
ctx.app.warning_message = None;
if handle_warning_popup_key(ctx.app, key) {
return Ok(None);
}
if handle_error_details_popup_key(ctx.app, key) {
return Ok(None);
}
if handle_modal_key(key, ctx).await {
return Ok(None);
}
let mut cmd_to_start: Option<TuiCommand> = None;
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
request_local_tui_quit(ctx.app, ctx.supervisor);
}
(KeyCode::Tab, _) => {
handle_tab_key(ctx).await?;
}
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
handle_cursor_movement(ctx.app, true);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
handle_cursor_movement(ctx.app, false);
}
(KeyCode::Char(' '), _) => {
ctx.app.toggle_selection();
}
(KeyCode::Char('x'), _) => {
for cmd in handle_bulk_toggle_key(ctx.app) {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('e'), _) => {
handle_editor_launch(ctx).await?;
}
(KeyCode::Char('m'), _) | (KeyCode::Char('M'), _) => {
handle_merge_key(ctx).await?;
}
(KeyCode::Char('d'), _) => {
use crate::tui::types::ViewMode;
match ctx.app.view_mode {
ViewMode::Worktrees => {
ctx.app.request_worktree_delete_from_list();
}
ViewMode::Changes => {
ctx.app.dismiss_merged_row();
}
}
}
(KeyCode::Char('D'), _) => {
use crate::tui::types::ViewMode;
match ctx.app.view_mode {
ViewMode::Worktrees => {
ctx.app.request_worktree_delete_from_list();
}
ViewMode::Changes => {
ctx.app.dismiss_all_merged_rows();
}
}
}
(KeyCode::Esc, _) => {
handle_esc_key(ctx).await;
}
_ if ctx.app.tui_config.matches_start_key(&key) => {
cmd_to_start = handle_start_key(ctx);
}
(KeyCode::PageUp, _) => {
ctx.app.scroll_logs_up(5);
}
(KeyCode::PageDown, _) => {
ctx.app.scroll_logs_down(5);
}
(KeyCode::Home, _) => {
ctx.app.scroll_logs_to_top();
}
(KeyCode::End, _) => {
ctx.app.scroll_logs_to_bottom();
}
(KeyCode::Enter, _) => {
handle_enter_key(ctx).await?;
}
(KeyCode::Char('+'), _) => {
handle_plus_key(ctx).await?;
}
(KeyCode::Char('w'), _) if ctx.app.web_url.is_some() => {
ctx.app.show_qr_popup();
}
(KeyCode::Char('l'), _) => {
use crate::tui::types::ViewMode;
if ctx.app.view_mode == ViewMode::Changes {
ctx.app.toggle_logs_panel();
}
}
(KeyCode::Char('f'), _) => {
handle_selected_proposal_log_filter_key(ctx.app);
}
(KeyCode::Char('K'), _) => {
ctx.app.request_force_kill_confirmation();
}
_ => {}
}
Ok(cmd_to_start)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::tui::config::TuiConfig;
use crate::tui::types::ViewMode;
use crossterm::event::KeyCode;
use std::sync::atomic::Ordering;
fn create_test_change(id: &str) -> Change {
Change {
id: id.to_string(),
completed_tasks: 0,
total_tasks: 1,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn idle_supervisor() -> Arc<crate::tui::run_supervisor::TuiRunSupervisor> {
let (tx, _rx) = mpsc::channel(1);
let state = Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(Vec::new(), 1),
));
Arc::new(crate::tui::run_supervisor::TuiRunSupervisor::new(
PathBuf::from("."),
OrchestratorConfig::default(),
Arc::new(crate::events::EventDispatcher::new(
state.clone(),
vec![Arc::new(crate::tui::events::TuiEventSink::new(tx))],
)),
crate::tui::queue::DynamicQueue::new(),
state,
Arc::new(std::sync::atomic::AtomicUsize::new(0)),
crate::parallel::PostArchiveAction::MergeToBase,
None,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
))
}
const LOG_VIEWPORT_WIDTH: usize = 60;
const LOG_VIEWPORT_HEIGHT: usize = 7;
fn log_nav_app(entries: usize) -> AppState {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.logs.clear();
app.set_log_viewport(LOG_VIEWPORT_WIDTH, LOG_VIEWPORT_HEIGHT);
for index in 0..entries {
app.add_log(LogEntry::info(format!("log {index}")));
}
app
}
fn visible_log_text(app: &AppState) -> Vec<String> {
let lines = app.log_display_lines();
let start = app.log_start_line(&lines);
let end = lines.len().min(start + app.log_viewport.visible_height());
lines[start..end]
.iter()
.map(|line| line.text.clone())
.collect()
}
#[test]
fn log_navigation_state_methods_preserve_existing_key_semantics() {
let mut app = log_nav_app(12);
assert_eq!(app.log_anchor, None);
assert!(app.log_auto_scroll);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 7");
app.scroll_logs_up(5);
assert!(!app.log_auto_scroll);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 2");
app.scroll_logs_down(5);
assert_eq!(app.log_anchor, None);
assert!(app.log_auto_scroll);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 7");
app.scroll_logs_to_top();
assert!(!app.log_auto_scroll);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 0");
app.scroll_logs_to_bottom();
assert_eq!(app.log_anchor, None);
assert!(app.log_auto_scroll);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 7");
assert!(app.logs_panel_enabled);
app.toggle_logs_panel();
assert!(!app.logs_panel_enabled);
app.toggle_logs_panel();
assert!(app.logs_panel_enabled);
}
fn oversized_entry_app() -> AppState {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.logs.clear();
app.set_log_viewport(LOG_VIEWPORT_WIDTH, LOG_VIEWPORT_HEIGHT);
let message: String = (0..1000)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect();
app.add_log(LogEntry::info(message));
app
}
#[test]
fn page_keys_move_inside_one_entry_taller_than_the_viewport() {
let mut app = oversized_entry_app();
let total = app.log_display_lines().len();
assert_eq!(total, 18);
assert!(total > app.log_viewport.visible_height());
assert_eq!(app.log_start_line(&app.log_display_lines()), 13);
app.scroll_logs_to_top();
assert_eq!(app.log_start_line(&app.log_display_lines()), 0);
assert_eq!(app.log_anchor.unwrap().source_byte_offset, 0);
assert!(!app.log_auto_scroll);
app.scroll_logs_down(5);
assert_eq!(app.log_start_line(&app.log_display_lines()), 5);
assert!(!app.log_auto_scroll);
app.scroll_logs_up(5);
assert_eq!(app.log_start_line(&app.log_display_lines()), 0);
app.scroll_logs_down(total);
assert!(app.log_auto_scroll);
assert_eq!(app.log_anchor, None);
}
#[tokio::test]
async fn routed_page_keys_navigate_display_lines_without_changing_assignments() {
let mut app = oversized_entry_app();
route_key_event(&mut app, key(KeyCode::Home)).await;
assert_eq!(app.log_start_line(&app.log_display_lines()), 0);
route_key_event(&mut app, key(KeyCode::PageDown)).await;
assert_eq!(app.log_start_line(&app.log_display_lines()), 5);
route_key_event(&mut app, key(KeyCode::PageUp)).await;
assert_eq!(app.log_start_line(&app.log_display_lines()), 0);
route_key_event(&mut app, key(KeyCode::End)).await;
assert!(app.log_auto_scroll);
assert_eq!(app.log_start_line(&app.log_display_lines()), 13);
}
#[test]
fn anchor_survives_a_width_change_and_stays_on_the_same_source_content() {
let mut app = oversized_entry_app();
app.scroll_logs_to_top();
app.scroll_logs_down(3);
let anchored_offset = app.log_anchor.unwrap().source_byte_offset;
assert_eq!(anchored_offset, 49 + 2 * 58);
app.set_log_viewport(120, LOG_VIEWPORT_HEIGHT);
let lines = app.log_display_lines();
let start = app.log_start_line(&lines);
let line = &lines[start];
assert!(line.source_byte_offset <= anchored_offset);
assert!(anchored_offset < line.source_byte_offset + line.text.len());
assert!(!app.log_auto_scroll);
}
#[test]
fn appending_logs_keeps_a_manual_anchor_and_a_stale_one_clamps() {
let mut app = log_nav_app(12);
app.scroll_logs_to_top();
let anchor = app.log_anchor.unwrap();
app.add_log(LogEntry::info("newest"));
assert_eq!(
app.log_anchor,
Some(anchor),
"append must not move a manual anchor"
);
assert_eq!(visible_log_text(&app).first().unwrap(), "log 0");
assert!(!app.log_auto_scroll);
app.log_anchor = Some(crate::tui::state::log_logic::LogViewAnchor {
entry_seq: u64::MAX - 1,
source_byte_offset: 0,
});
let lines = app.log_display_lines();
assert_eq!(
app.log_start_line(&lines),
lines
.len()
.saturating_sub(app.log_viewport.visible_height())
);
assert!(
!app.log_auto_scroll,
"clamping must not re-enable auto-scroll"
);
}
fn log_filter_app() -> AppState {
let mut app = AppState::new(vec![
create_test_change("alpha"),
create_test_change("beta"),
]);
app.logs.clear();
app.add_log(LogEntry::info("alpha apply").with_change_id("alpha"));
app.add_log(LogEntry::info("beta apply").with_change_id("beta"));
app.add_log(LogEntry::info("global orchestration"));
app
}
#[test]
fn f_key_toggles_selected_proposal_log_filter_in_changes_view() {
let mut app = log_filter_app();
assert!(!app.selected_proposal_log_filter);
handle_selected_proposal_log_filter_key(&mut app);
assert!(app.selected_proposal_log_filter);
assert_eq!(app.selected_proposal_log_filter_target(), Some("alpha"));
handle_selected_proposal_log_filter_key(&mut app);
assert!(!app.selected_proposal_log_filter);
}
#[test]
fn f_key_does_not_change_execution_marks_cursor_or_log_panel_visibility() {
let mut app = log_filter_app();
app.changes[0].selected = true;
app.changes[1].selected = false;
let statuses_before: Vec<String> = app
.changes
.iter()
.map(|c| c.display_status_cache.clone())
.collect();
handle_selected_proposal_log_filter_key(&mut app);
assert!(app.changes[0].selected);
assert!(!app.changes[1].selected);
assert_eq!(app.cursor_index, 0);
assert!(app.logs_panel_enabled);
assert_eq!(app.logs.len(), 3);
let statuses_after: Vec<String> = app
.changes
.iter()
.map(|c| c.display_status_cache.clone())
.collect();
assert_eq!(statuses_before, statuses_after);
}
#[test]
fn f_key_is_ignored_in_worktrees_view() {
let mut app = log_filter_app();
app.view_mode = ViewMode::Worktrees;
handle_selected_proposal_log_filter_key(&mut app);
assert!(!app.selected_proposal_log_filter);
}
#[test]
fn f_key_leaves_existing_log_panel_and_scroll_keys_intact() {
let mut app = log_filter_app();
app.toggle_logs_panel();
assert!(!app.logs_panel_enabled);
handle_selected_proposal_log_filter_key(&mut app);
assert!(!app.logs_panel_enabled);
app.scroll_logs_up(5);
assert!(!app.log_auto_scroll);
handle_selected_proposal_log_filter_key(&mut app);
assert_eq!(app.log_anchor, None);
assert!(app.log_auto_scroll);
}
#[test]
fn configured_start_key_matches_default_and_custom_bindings() {
let mut app = AppState::new(vec![create_test_change("run-me")]);
app.changes[0].selected = true;
assert!(app.tui_config.matches_start_key(&key(KeyCode::F(5))));
assert!(app.tui_config.matches_start_key(&key(KeyCode::Char('!'))));
let custom = TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap();
app.set_tui_config(custom);
assert!(app.tui_config.matches_start_key(&key(KeyCode::F(5))));
assert!(app.tui_config.matches_start_key(&key(KeyCode::Char('!'))));
assert!(!app.tui_config.matches_start_key(&key(KeyCode::Char('x'))));
}
#[test]
fn configured_start_key_triggers_same_command_as_f5() {
let custom = TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap();
let mut f5_app = AppState::new(vec![create_test_change("run-me")]);
f5_app.set_tui_config(custom.clone());
f5_app.changes[0].selected = true;
let f5_command = if f5_app.tui_config.matches_start_key(&key(KeyCode::F(5))) {
handle_start_key_inner(&mut f5_app)
} else {
None
};
let mut bang_app = AppState::new(vec![create_test_change("run-me")]);
bang_app.set_tui_config(custom);
bang_app.changes[0].selected = true;
let bang_command = if bang_app
.tui_config
.matches_start_key(&key(KeyCode::Char('!')))
{
handle_start_key_inner(&mut bang_app)
} else {
None
};
assert_eq!(format!("{:?}", f5_command), format!("{:?}", bang_command));
assert!(matches!(
bang_command,
Some(TuiCommand::StartProcessing(ids)) if ids.is_empty()
));
}
#[test]
fn start_key_never_emits_resolve_merge_for_the_row_under_the_cursor() {
let mut app = AppState::new(vec![
create_test_change("merge-wait"),
create_test_change("run-me"),
]);
app.execution_mode = AppExecutionMode::Select;
app.cursor_index = 0;
app.changes[0].display_status_cache = "merge wait".to_string();
app.changes[1].selected = true;
let command = handle_start_key_inner(&mut app);
assert!(matches!(
command,
Some(TuiCommand::StartProcessing(ids)) if ids.is_empty()
));
assert_eq!(app.changes[0].display_status_cache, "merge wait");
}
#[test]
fn start_key_emits_one_command_per_mode_class() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = AppState::new(vec![create_test_change("a")]);
app.execution_mode = mode;
app.set_resolving("__active__");
assert!(
matches!(
handle_start_key_inner(&mut app),
Some(TuiCommand::StartProcessing(ref ids)) if ids.is_empty()
),
"{mode:?} must delegate start to the shared service"
);
assert!(app.warning_message.is_none());
}
let mut stopping = AppState::new(vec![create_test_change("a")]);
stopping.execution_mode = AppExecutionMode::Stopping;
assert!(matches!(
handle_start_key_inner(&mut stopping),
Some(TuiCommand::CancelStop)
));
}
#[test]
fn warning_popup_scroll_keys_do_not_move_underlying_cursor_or_close_popup() {
let mut app = AppState::new(vec![create_test_change("a"), create_test_change("b")]);
app.show_warning_popup("warning", "line 1\nline 2\nline 3");
let cursor_before = app.cursor_index;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Down)));
assert_eq!(app.cursor_index, cursor_before);
assert_eq!(app.warning_popup_scroll, 1);
assert!(app.warning_popup.is_some());
}
#[test]
fn warning_popup_close_key_clears_popup_and_resets_scroll() {
let mut app = AppState::new(vec![create_test_change("a")]);
app.show_warning_popup("warning", "diagnostic");
app.warning_popup_scroll = 7;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Esc)));
assert!(app.warning_popup.is_none());
assert_eq!(app.warning_popup_scroll, 0);
}
#[test]
fn warning_popup_ignores_non_popup_keys_without_underlying_action() {
let mut app = AppState::new(vec![create_test_change("a"), create_test_change("b")]);
app.show_warning_popup("warning", "diagnostic");
let cursor_before = app.cursor_index;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Char('x'))));
assert_eq!(app.cursor_index, cursor_before);
assert_eq!(app.warning_popup_scroll, 0);
assert!(app.warning_popup.is_some());
}
struct StubPlusRuntime {
is_git_repo: bool,
branch_name: String,
fail_validation_at: Option<usize>,
setup_error: Option<String>,
cleanup_error: Option<String>,
worktree_add_calls: std::sync::atomic::AtomicUsize,
validation_calls: std::sync::atomic::AtomicUsize,
setup_calls: std::sync::atomic::AtomicUsize,
cleanup_calls: std::sync::atomic::AtomicUsize,
execute_calls: std::sync::atomic::AtomicUsize,
}
impl StubPlusRuntime {
fn new() -> Self {
Self {
is_git_repo: true,
branch_name: "ws-session-test".to_string(),
fail_validation_at: None,
setup_error: None,
cleanup_error: None,
worktree_add_calls: std::sync::atomic::AtomicUsize::new(0),
validation_calls: std::sync::atomic::AtomicUsize::new(0),
setup_calls: std::sync::atomic::AtomicUsize::new(0),
cleanup_calls: std::sync::atomic::AtomicUsize::new(0),
execute_calls: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait]
impl WorktreePlusRuntime for StubPlusRuntime {
async fn check_git_repo(&self, _repo_root: &Path) -> VcsResult<bool> {
Ok(self.is_git_repo)
}
async fn generate_unique_branch_name(
&self,
_repo_root: &Path,
_prefix: &str,
_max_attempts: u32,
) -> VcsResult<String> {
Ok(self.branch_name.clone())
}
async fn worktree_add(
&self,
_repo_root: &Path,
worktree_path: &str,
_branch_name: &str,
_base_commit: &str,
) -> VcsResult<()> {
self.worktree_add_calls.fetch_add(1, Ordering::SeqCst);
std::fs::create_dir_all(worktree_path).unwrap();
Ok(())
}
async fn validate_worktree_command_cwd(
&self,
_repo_root: &Path,
_worktree_path: &Path,
) -> VcsResult<()> {
let call = self.validation_calls.fetch_add(1, Ordering::SeqCst) + 1;
if self.fail_validation_at == Some(call) {
return Err(crate::vcs::VcsError::git_command(format!(
"validation failed at call {call}"
)));
}
Ok(())
}
async fn run_worktree_setup(
&self,
_repo_root: &Path,
_worktree_path: &Path,
) -> VcsResult<()> {
self.setup_calls.fetch_add(1, Ordering::SeqCst);
if let Some(message) = &self.setup_error {
return Err(crate::vcs::VcsError::git_command(message.clone()));
}
Ok(())
}
async fn worktree_remove_after_setup_failure(
&self,
_repo_root: &Path,
_worktree_path: &str,
) -> VcsResult<()> {
self.cleanup_calls.fetch_add(1, Ordering::SeqCst);
if let Some(message) = &self.cleanup_error {
return Err(crate::vcs::VcsError::git_command(message.clone()));
}
Ok(())
}
async fn execute_worktree_command(
&self,
_terminal: &mut DefaultTerminal,
_command: &str,
_worktree_path: &Path,
_ai_runner: &AiCommandRunner,
_app: &mut AppState,
) -> Result<()> {
self.execute_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
fn plus_config(template: &str) -> OrchestratorConfig {
OrchestratorConfig {
worktree_command: Some(template.to_string()),
..Default::default()
}
}
fn worktrees_app() -> AppState {
let mut app = AppState::new(vec![]);
app.view_mode = crate::tui::types::ViewMode::Worktrees;
app
}
#[tokio::test]
async fn plus_prepare_suppresses_setup_when_created_worktree_validation_fails() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let config = plus_config("true");
let runtime = StubPlusRuntime {
fail_validation_at: Some(1),
..StubPlusRuntime::new()
};
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&temp_dir.path().join("worktrees"),
&runtime,
)
.await;
assert!(prepared.is_none());
assert_eq!(runtime.worktree_add_calls.load(Ordering::SeqCst), 1);
assert_eq!(runtime.setup_calls.load(Ordering::SeqCst), 0);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 0);
assert!(app.logs.iter().any(|entry| {
entry
.message
.contains("Suppressing worktree command launch: invalid cwd")
&& entry.message.contains("after create")
}));
}
#[tokio::test]
async fn plus_prepare_suppresses_command_when_worktree_invalid_after_setup() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let config = plus_config("true");
let runtime = StubPlusRuntime {
fail_validation_at: Some(2),
..StubPlusRuntime::new()
};
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&temp_dir.path().join("worktrees"),
&runtime,
)
.await;
assert!(prepared.is_none());
assert_eq!(runtime.setup_calls.load(Ordering::SeqCst), 1);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 0);
assert!(app.logs.iter().any(|entry| {
entry
.message
.contains("Suppressing worktree command launch: invalid cwd")
&& entry.message.contains("after setup")
}));
}
#[tokio::test]
async fn plus_prepare_suppresses_command_when_worktree_invalid_before_launch() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let config = plus_config("true");
let runtime = StubPlusRuntime {
fail_validation_at: Some(3),
..StubPlusRuntime::new()
};
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&temp_dir.path().join("worktrees"),
&runtime,
)
.await;
assert!(prepared.is_none());
assert_eq!(runtime.setup_calls.load(Ordering::SeqCst), 1);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 0);
assert!(app.logs.iter().any(|entry| {
entry
.message
.contains("Suppressing worktree command launch: invalid cwd")
&& entry.message.contains("before command launch")
}));
}
#[tokio::test]
async fn plus_prepare_logs_setup_failure_cleanup_and_suppresses_command() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let config = plus_config("true");
let runtime = StubPlusRuntime {
setup_error: Some("setup exploded".to_string()),
..StubPlusRuntime::new()
};
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&temp_dir.path().join("worktrees"),
&runtime,
)
.await;
assert!(prepared.is_none());
assert_eq!(runtime.cleanup_calls.load(Ordering::SeqCst), 1);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 0);
assert!(app.logs.iter().any(|entry| entry
.message
.contains("Failed to run worktree setup")
&& entry.message.contains("setup exploded")));
assert!(app.logs.iter().any(|entry| entry
.message
.contains("Cleaning up worktree after setup failure")));
assert!(app.logs.iter().any(|entry| entry
.message
.contains("Cleaned up worktree after setup failure")));
}
async fn init_plus_git_repo(repo: &Path) {
let init = tokio::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(repo)
.output()
.await
.unwrap();
assert!(init.status.success(), "git init failed: {init:?}");
tokio::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(repo)
.output()
.await
.unwrap();
tokio::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(repo)
.output()
.await
.unwrap();
std::fs::write(repo.join("README.md"), "test").unwrap();
tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.await
.unwrap();
let commit = tokio::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(repo)
.output()
.await
.unwrap();
assert!(commit.status.success(), "git commit failed: {commit:?}");
}
#[tokio::test]
async fn plus_prepare_returns_expanded_command_for_valid_worktree() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let config = plus_config("tmux new-window -n wt -c {workspace_dir} -- echo {repo_root}");
let runtime = StubPlusRuntime::new();
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&temp_dir.path().join("worktrees"),
&runtime,
)
.await
.expect("valid worktree should prepare command launch");
assert_eq!(runtime.validation_calls.load(Ordering::SeqCst), 3);
assert_eq!(runtime.setup_calls.load(Ordering::SeqCst), 1);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 0);
assert_eq!(
prepared.worktree_path.parent().unwrap(),
temp_dir.path().join("worktrees")
);
assert!(prepared.command.contains("tmux new-window -n wt -c"));
assert!(prepared
.command
.contains(prepared.worktree_path.to_str().unwrap()));
assert!(prepared.command.contains(temp_dir.path().to_str().unwrap()));
assert!(app
.logs
.iter()
.any(|entry| entry.message.contains("Running worktree command in")));
}
fn test_ai_runner() -> AiCommandRunner {
let queue_config = crate::command_queue::CommandQueueConfig {
acceptance_max_runtime_secs:
crate::config::defaults::DEFAULT_ACCEPTANCE_MAX_RUNTIME_SECS,
stagger_delay_ms: 0,
max_retries: 0,
retry_delay_ms: 0,
retry_error_patterns: Vec::new(),
retry_if_duration_under_secs: 0,
inactivity_timeout_secs: 0,
inactivity_kill_grace_secs: 0,
inactivity_timeout_max_retries: 0,
strict_process_cleanup: true,
max_runtime_secs: 0,
};
AiCommandRunner::new(queue_config, Arc::new(tokio::sync::Mutex::new(None)))
}
#[tokio::test]
async fn plus_handle_invokes_command_runner_boundary_for_valid_worktree() {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut app = worktrees_app();
let mut terminal = ratatui::Terminal::with_options(
ratatui::backend::CrosstermBackend::new(std::io::stdout()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 24)),
},
)
.unwrap();
let config = plus_config("echo {workspace_dir}");
let (tx, _rx) = mpsc::channel(1);
let (cmd_tx, _cmd_rx) = mpsc::channel(1);
let ai_runner = test_ai_runner();
let runtime = StubPlusRuntime::new();
let worktree_base_dir = temp_dir.path().join("worktrees");
let supervisor = idle_supervisor();
let mut ctx = KeyEventContext {
app: &mut app,
terminal: &mut terminal,
repo_root: temp_dir.path(),
config: &config,
worktree_base_dir: &worktree_base_dir,
tx: &tx,
cmd_tx: &cmd_tx,
ai_runner: &ai_runner,
supervisor: &supervisor,
};
handle_plus_key_with_runtime(&mut ctx, &runtime)
.await
.unwrap();
assert_eq!(runtime.validation_calls.load(Ordering::SeqCst), 3);
assert_eq!(runtime.execute_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn plus_prepare_with_production_runtime_creates_registered_materialized_worktree() {
let temp_dir = tempfile::TempDir::new().unwrap();
init_plus_git_repo(temp_dir.path()).await;
let mut app = worktrees_app();
let config = plus_config("printf ok");
let worktree_base_dir = temp_dir.path().join("worktrees");
let prepared = prepare_plus_worktree_command(
&mut app,
&config,
temp_dir.path(),
&worktree_base_dir,
&ProductionWorktreePlusRuntime,
)
.await
.expect("valid Git fixture should prepare command launch");
assert!(prepared.worktree_path.exists());
assert!(prepared.worktree_path.join(".git").exists());
assert!(prepared.worktree_path.join("README.md").exists());
assert_eq!(prepared.command, "printf ok");
crate::vcs::git::commands::validate_worktree_command_cwd(
temp_dir.path(),
&prepared.worktree_path,
)
.await
.unwrap();
let _ = crate::vcs::git::commands::worktree_remove(
temp_dir.path(),
prepared.worktree_path.to_str().unwrap(),
)
.await;
}
fn bulk_toggle_app(rows: &[(&str, &str, bool)]) -> AppState {
let changes = rows
.iter()
.map(|(id, _, _)| create_test_change(id))
.collect();
let mut app = AppState::new(changes);
app.execution_mode = AppExecutionMode::Running;
for (index, (_, status, selected)) in rows.iter().enumerate() {
app.changes[index].display_status_cache = status.to_string();
app.changes[index].selected = *selected;
}
app
}
#[test]
fn bulk_toggle_key_marks_every_non_terminal_row_without_queue_effects() {
let mut app = bulk_toggle_app(&[
("active", "applying", false),
("rejected", "rejected", false),
("eligible", "not queued", false),
]);
let commands = handle_bulk_toggle_key(&mut app);
assert!(
commands.is_empty(),
"a bulk mark emits no command: {commands:?}"
);
assert!(app.changes[0].selected, "an active row is a mark target");
assert!(!app.changes[1].selected, "a terminal row is not");
assert!(app.changes[2].selected);
assert!(
app.warning_message.is_none(),
"a skipped terminal row is not a refusal: {:?}",
app.warning_message
);
assert!(app
.logs
.iter()
.any(|entry| entry.message == "Toggled all: 2 marked change(s)"));
}
#[test]
fn bulk_toggle_key_with_only_terminal_rows_is_silent() {
let mut app = bulk_toggle_app(&[
("archived", "archived", false),
("rejected", "rejected", true),
]);
let commands = handle_bulk_toggle_key(&mut app);
assert!(commands.is_empty());
assert!(!app.changes[0].selected, "terminal rows must not change");
assert!(app.changes[1].selected, "terminal rows must not change");
assert!(
app.warning_message.is_none(),
"a terminal-only list refuses nothing: {:?}",
app.warning_message
);
assert!(!app
.logs
.iter()
.any(|entry| entry.message.contains("Toggled all")));
}
#[test]
fn bulk_toggle_key_is_ignored_outside_changes_view() {
let mut app = bulk_toggle_app(&[("eligible", "not queued", false)]);
app.view_mode = ViewMode::Worktrees;
let commands = handle_bulk_toggle_key(&mut app);
assert!(commands.is_empty());
assert!(!app.changes[0].selected);
assert!(app.warning_message.is_none());
}
#[tokio::test]
async fn ctrl_c_quit_cancels_local_orchestrator_token() {
use crate::orchestration::run_control::RunSchedulerPort;
let mut app = AppState::new(vec![create_test_change("change-a")]);
let supervisor = idle_supervisor();
supervisor
.start_run(Vec::new(), false)
.await
.expect("an idle supervisor accepts a launch");
let token = supervisor.cancel_token().expect("a live run owns a token");
request_local_tui_quit(&mut app, &supervisor);
assert!(app.should_quit);
assert!(token.is_cancelled());
assert!(app
.logs
.iter()
.any(|entry| entry.message.contains("cancelling local orchestration")));
let (handle, _, _) = supervisor.take_run();
if let Some(handle) = handle {
handle.abort();
}
}
#[test]
fn ctrl_c_quit_without_local_orchestrator_only_sets_quit() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
request_local_tui_quit(&mut app, &idle_supervisor());
assert!(app.should_quit);
}
#[test]
fn idle_parallel_stop_second_esc_requests_the_shared_immediate_stop_command() {
assert_eq!(
esc_stop_action(&AppExecutionMode::Running, &StopMode::None, false),
EscStopAction::RequestGracefulStop
);
assert_eq!(
esc_stop_action(
&AppExecutionMode::Stopping,
&StopMode::GracefulPending,
false
),
EscStopAction::RequestImmediateStop
);
}
#[test]
fn idle_parallel_stop_repeated_esc_does_not_duplicate_the_stop_request() {
assert_eq!(
esc_stop_action(
&AppExecutionMode::Stopping,
&StopMode::ImmediatePending,
false
),
EscStopAction::None
);
assert_eq!(
esc_stop_action(&AppExecutionMode::Stopped, &StopMode::None, false),
EscStopAction::None
);
}
#[tokio::test]
async fn idle_parallel_stop_second_esc_routes_through_force_stop_command() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Stopping;
app.stop_mode = StopMode::GracefulPending;
let (cmd_tx, mut cmd_rx) = mpsc::channel(4);
handle_esc_key_inner(&mut app, &cmd_tx).await;
assert!(
matches!(cmd_rx.try_recv(), Ok(TuiCommand::ForceStop)),
"the second Esc must route through the shared stop command"
);
assert_eq!(app.stop_mode, StopMode::ImmediatePending);
assert_eq!(
app.execution_mode,
AppExecutionMode::Stopping,
"the key path must not apply terminal stop effects itself"
);
assert!(
!app.logs
.iter()
.any(|entry| entry.message.contains("Force stopped")),
"the key path must not claim a force stop from AppExecutionMode::Stopping alone"
);
assert!(
!app.logs
.iter()
.any(|entry| entry.message.contains("Processing stopped")),
"terminal stop reporting is owned by the Stopped transition"
);
}
#[tokio::test]
async fn idle_parallel_stop_repeated_esc_sends_one_stop_command() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Stopping;
app.stop_mode = StopMode::GracefulPending;
let (cmd_tx, mut cmd_rx) = mpsc::channel(4);
handle_esc_key_inner(&mut app, &cmd_tx).await;
handle_esc_key_inner(&mut app, &cmd_tx).await;
assert!(matches!(cmd_rx.try_recv(), Ok(TuiCommand::ForceStop)));
assert!(
cmd_rx.try_recv().is_err(),
"a repeated Esc must not enqueue a second cancellation request"
);
}
#[tokio::test]
async fn idle_parallel_stop_first_esc_keeps_graceful_stop_contract() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
let (cmd_tx, mut cmd_rx) = mpsc::channel(4);
handle_esc_key_inner(&mut app, &cmd_tx).await;
assert_eq!(app.stop_mode, StopMode::GracefulPending);
assert!(matches!(cmd_rx.try_recv(), Ok(TuiCommand::Stop)));
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"the key path must not apply the stop transition itself"
);
}
const HIGH_IMPACT_KEYS: [KeyCode; 10] = [
KeyCode::Char('x'),
KeyCode::Char(' '),
KeyCode::Down,
KeyCode::Up,
KeyCode::Char('j'),
KeyCode::Char('K'),
KeyCode::Char('M'),
KeyCode::Esc,
KeyCode::F(5),
KeyCode::Tab,
];
#[derive(Debug, PartialEq)]
struct UnderlyingState {
execution_mode: AppExecutionMode,
stop_mode: StopMode,
cursor_index: usize,
view_mode: ViewMode,
marks: Vec<bool>,
statuses: Vec<String>,
}
impl UnderlyingState {
fn capture(app: &AppState) -> Self {
Self {
execution_mode: app.execution_mode,
stop_mode: app.stop_mode.clone(),
cursor_index: app.cursor_index,
view_mode: app.view_mode,
marks: app.changes.iter().map(|c| c.selected).collect(),
statuses: app
.changes
.iter()
.map(|c| c.display_status_cache.clone())
.collect(),
}
}
}
fn routing_app() -> AppState {
let mut app = AppState::new(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].set_display_status_cache("applying");
app.changes[1].set_display_status_cache("not queued");
app.web_url = Some("http://127.0.0.1:8080".to_string());
app
}
fn test_terminal() -> DefaultTerminal {
ratatui::Terminal::with_options(
ratatui::backend::CrosstermBackend::new(std::io::stdout()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 24)),
},
)
.unwrap()
}
async fn route_key(app: &mut AppState, code: KeyCode) -> Vec<TuiCommand> {
route_key_event(app, key(code)).await
}
async fn route_key_event(app: &mut AppState, event: KeyEvent) -> Vec<TuiCommand> {
let temp_dir = tempfile::TempDir::new().unwrap();
let mut terminal = test_terminal();
let config = OrchestratorConfig::default();
let (tx, _rx) = mpsc::channel(8);
let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
let ai_runner = test_ai_runner();
let supervisor = idle_supervisor();
let worktree_base_dir = temp_dir.path().join("worktrees");
let mut ctx = KeyEventContext {
app,
terminal: &mut terminal,
repo_root: temp_dir.path(),
config: &config,
worktree_base_dir: &worktree_base_dir,
tx: &tx,
cmd_tx: &cmd_tx,
ai_runner: &ai_runner,
supervisor: &supervisor,
};
handle_key_event(event, &mut ctx).await.unwrap();
let mut commands = Vec::new();
while let Ok(command) = cmd_rx.try_recv() {
commands.push(command);
}
commands
}
#[tokio::test]
async fn warning_popup_owns_input_before_the_typed_modal() {
let mut app = routing_app();
app.show_qr_popup();
app.show_warning_popup("warning", "line 1\nline 2\nline 3");
let commands = route_key(&mut app, KeyCode::Down).await;
assert!(commands.is_empty());
assert!(app.warning_popup.is_some());
assert_eq!(app.warning_popup_scroll, 1);
assert_eq!(
app.modal,
Some(ModalState::QrPopup),
"the interaction modal must not process a warning-popup key"
);
assert_eq!(app.execution_mode, AppExecutionMode::Running);
}
#[tokio::test]
async fn every_overlay_consumes_high_impact_keys_without_touching_the_view() {
type OpenOverlay = fn(&mut AppState);
let overlays: Vec<(&str, OpenOverlay)> = vec![
("qr", |app: &mut AppState| app.show_qr_popup()),
("worktree-delete", |app: &mut AppState| {
app.modal = Some(ModalState::ConfirmWorktreeDelete {
path: PathBuf::from("/tmp/wt-a"),
branch: "change-b".to_string(),
});
}),
("force-kill", |app: &mut AppState| {
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
}),
("warning-popup", |app: &mut AppState| {
app.show_warning_popup("warning", "diagnostic")
}),
("error-details", |app: &mut AppState| {
app.changes[1].set_error_message_cache("boom".to_string());
app.cursor_index = 1;
assert!(app.open_error_details_popup());
}),
];
for (name, open) in overlays {
for code in HIGH_IMPACT_KEYS {
let mut app = routing_app();
open(&mut app);
let before = UnderlyingState::capture(&app);
let commands = route_key(&mut app, code).await;
assert!(
commands.is_empty(),
"{name} overlay leaked {code:?} as a command"
);
assert_eq!(
UnderlyingState::capture(&app),
before,
"{name} overlay leaked {code:?} into the underlying view"
);
}
}
}
#[tokio::test]
async fn an_overlay_consumes_the_resolve_key_without_emitting_resolve_merge() {
let overlays = [
ModalState::QrPopup,
ModalState::ConfirmWorktreeDelete {
path: PathBuf::from("/tmp/wt-a"),
branch: "change-b".to_string(),
},
ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
},
];
for modal in overlays {
let mut app = routing_app();
app.execution_mode = AppExecutionMode::Select;
app.changes[1].set_display_status_cache("merge wait");
app.cursor_index = 1;
app.modal = Some(modal.clone());
let commands = route_key(&mut app, KeyCode::Char('M')).await;
assert!(
commands.is_empty(),
"{modal:?} must not let the resolve key through"
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Select,
"{modal:?} must not let the resolve key move the execution axis"
);
}
}
#[tokio::test]
async fn qr_closes_on_any_key_and_exposes_the_latest_execution_mode() {
for code in [KeyCode::Esc, KeyCode::Char('x'), KeyCode::Enter] {
let mut app = routing_app();
app.show_qr_popup();
app.execution_mode = AppExecutionMode::Stopping;
let commands = route_key(&mut app, code).await;
assert!(commands.is_empty());
assert!(app.modal.is_none(), "{code:?} must close the QR popup");
assert_eq!(
app.execution_mode,
AppExecutionMode::Stopping,
"closing QR must expose the latest execution mode, not a captured one"
);
}
}
#[tokio::test]
async fn force_kill_confirm_dispatches_stop_and_dequeue_without_rewriting_execution() {
let mut app = routing_app();
app.execution_mode = AppExecutionMode::Stopping;
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
let commands = route_key(&mut app, KeyCode::Char('y')).await;
assert!(
matches!(commands.as_slice(), [TuiCommand::DequeueChange(id)] if id == "change-a"),
"expected a single stop-and-dequeue command, got {commands:?}"
);
assert!(app.modal.is_none());
assert_eq!(
app.execution_mode,
AppExecutionMode::Stopping,
"confirming must not rewrite execution back to Running"
);
}
#[tokio::test]
async fn force_kill_cancel_preserves_the_current_execution_mode() {
let mut app = routing_app();
app.execution_mode = AppExecutionMode::Stopping;
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
let commands = route_key(&mut app, KeyCode::Esc).await;
assert!(commands.is_empty());
assert!(app.modal.is_none());
assert_eq!(
app.execution_mode,
AppExecutionMode::Stopping,
"cancel must not restore Running unconditionally"
);
assert_eq!(app.stop_mode, StopMode::None);
}
#[tokio::test]
async fn force_kill_confirm_refuses_a_stale_target_without_dispatching() {
let mut app = routing_app();
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
app.changes[0].set_display_status_cache("archived");
let commands = route_key(&mut app, KeyCode::Char('y')).await;
assert!(
commands.is_empty(),
"a stale force-kill must not reach the shared service"
);
assert!(app.modal.is_none());
assert_eq!(app.changes[0].display_status_cache, "archived");
assert!(app
.warning_message
.as_ref()
.is_some_and(|msg| msg.contains("Force-kill canceled")));
}
#[tokio::test]
async fn worktree_delete_confirm_dispatches_when_identity_still_matches() {
let mut app = routing_app();
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![crate::tui::types::WorktreeInfo {
path: PathBuf::from("/tmp/wt-a"),
head: "abc1234".to_string(),
branch: "change-b".to_string(),
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: false,
is_merging: false,
inspection: crate::worktree_ops::InspectionState::Checked,
}];
app.modal = Some(ModalState::ConfirmWorktreeDelete {
path: PathBuf::from("/tmp/wt-a"),
branch: "change-b".to_string(),
});
let commands = route_key(&mut app, KeyCode::Char('y')).await;
assert!(
matches!(
commands.as_slice(),
[TuiCommand::DeleteWorktree(intent)]
if intent.path == *"/tmp/wt-a"
&& intent.branch == "change-b"
&& !intent.skip_teardown
&& !intent.allow_known_dirty
),
"expected a single worktree delete command, got {commands:?}"
);
assert!(app.modal.is_none());
assert!(app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")));
}
#[tokio::test]
async fn worktree_delete_confirm_refuses_stale_identity_without_mutating_state() {
for (name, mutate) in [
(
"absent",
(|app: &mut AppState| app.worktrees.clear()) as fn(&mut AppState),
),
("rebranded", |app: &mut AppState| {
app.worktrees[0].branch = "change-z".to_string()
}),
("main", |app: &mut AppState| app.worktrees[0].is_main = true),
] {
for code in [KeyCode::Char('y'), KeyCode::Char('s')] {
let mut app = routing_app();
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![crate::tui::types::WorktreeInfo {
path: PathBuf::from("/tmp/wt-a"),
head: "abc1234".to_string(),
branch: "change-b".to_string(),
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: false,
is_merging: false,
inspection: crate::worktree_ops::InspectionState::Checked,
}];
app.modal = Some(ModalState::ConfirmWorktreeDelete {
path: PathBuf::from("/tmp/wt-a"),
branch: "change-b".to_string(),
});
mutate(&mut app);
let commands = route_key(&mut app, code).await;
assert!(
commands.is_empty(),
"{name}: a stale worktree delete must not be dispatched for {code:?}"
);
assert!(
app.modal.is_none(),
"{name}: the stale modal must be cleared"
);
assert!(
!app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")),
"{name}: refusing must not mark the worktree as deleting"
);
assert!(app
.warning_message
.as_ref()
.is_some_and(|msg| msg.contains("Worktree delete canceled")));
}
}
}
type DriftCase = (&'static str, fn(&mut AppState));
fn tui_dirty_worktree_delete_app(skip_teardown: bool) -> AppState {
let mut app = routing_app();
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![crate::tui::types::WorktreeInfo {
path: PathBuf::from("/tmp/wt-a"),
head: "abc1234".to_string(),
branch: "change-b".to_string(),
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: false,
is_merging: false,
inspection: crate::worktree_ops::InspectionState::Checked,
}];
app.modal = Some(ModalState::ConfirmDirtyDiscard {
path: PathBuf::from("/tmp/wt-a"),
identity: "gitdir: /tmp/wt-a/.git".to_string(),
branch: "change-b".to_string(),
head: "abc1234".to_string(),
skip_teardown,
});
app
}
#[tokio::test]
async fn tui_dirty_worktree_delete_uppercase_x_is_the_only_key_that_discards() {
for skip_teardown in [false, true] {
let mut app = tui_dirty_worktree_delete_app(skip_teardown);
let commands = route_key(&mut app, KeyCode::Char('X')).await;
let [TuiCommand::DeleteWorktree(intent)] = commands.as_slice() else {
panic!("expected a single delete command, got {commands:?}");
};
assert!(intent.allow_known_dirty);
assert_eq!(intent.skip_teardown, skip_teardown);
assert_eq!(intent.path, PathBuf::from("/tmp/wt-a"));
assert_eq!(intent.branch, "change-b");
assert_eq!(intent.identity.as_deref(), Some("gitdir: /tmp/wt-a/.git"));
assert_eq!(intent.head.as_deref(), Some("abc1234"));
assert!(app.modal.is_none());
assert!(app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")));
}
}
#[tokio::test]
async fn tui_dirty_worktree_delete_confirmation_ignores_every_other_key() {
for code in [
KeyCode::Char('y'),
KeyCode::Char('Y'),
KeyCode::Char('s'),
KeyCode::Char('S'),
KeyCode::Char('x'),
KeyCode::Char('d'),
KeyCode::Char('D'),
KeyCode::Enter,
KeyCode::Char(' '),
KeyCode::Down,
KeyCode::Tab,
KeyCode::F(5),
] {
let mut app = tui_dirty_worktree_delete_app(false);
let before = app.modal.clone();
let commands = route_key(&mut app, code).await;
assert!(
commands.is_empty(),
"{code:?} must not dispatch anything from the destructive confirmation"
);
assert_eq!(
app.modal, before,
"{code:?} must leave the destructive confirmation exactly as it was"
);
assert!(
!app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")),
"{code:?} must not start a deletion"
);
}
}
#[tokio::test]
async fn tui_dirty_worktree_delete_n_and_esc_cancel_and_retain_the_content() {
for code in [KeyCode::Char('n'), KeyCode::Char('N'), KeyCode::Esc] {
let mut app = tui_dirty_worktree_delete_app(false);
let commands = route_key(&mut app, code).await;
assert!(commands.is_empty(), "{code:?} must dispatch nothing");
assert!(app.modal.is_none(), "{code:?} must close the confirmation");
assert!(
!app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")),
"{code:?} must retain the worktree and its uncommitted work"
);
}
}
#[tokio::test]
async fn tui_dirty_worktree_delete_refuses_a_target_that_became_active_before_dispatch() {
let cases: [DriftCase; 6] = [
("absent", |app: &mut AppState| app.worktrees.clear()),
("main", |app: &mut AppState| app.worktrees[0].is_main = true),
("rebranded", |app: &mut AppState| {
app.worktrees[0].branch = "change-z".to_string()
}),
("head-moved", |app: &mut AppState| {
app.worktrees[0].head = "def5678".to_string()
}),
("active", |app: &mut AppState| {
app.changes[1].set_display_status_cache("applying")
}),
("deleting", |app: &mut AppState| {
app.mark_worktree_deleting(PathBuf::from("/tmp/wt-a"))
}),
];
for (name, mutate) in cases {
let mut app = tui_dirty_worktree_delete_app(false);
mutate(&mut app);
let commands = route_key(&mut app, KeyCode::Char('X')).await;
assert!(
commands.is_empty(),
"{name}: a drifted target must not be discarded"
);
assert!(
app.modal.is_none(),
"{name}: the stale confirmation must be cleared"
);
assert!(app
.warning_message
.as_ref()
.is_some_and(|msg| msg.contains("Dirty worktree discard canceled")));
}
}
fn tui_ahead_worktree_delete_app(skip_teardown: bool, dirty: bool) -> AppState {
let mut app = tui_dirty_worktree_delete_app(skip_teardown);
app.modal = Some(ModalState::ConfirmAheadDiscard {
path: PathBuf::from("/tmp/wt-a"),
identity: "gitdir: /tmp/wt-a/.git".to_string(),
branch: "change-b".to_string(),
head: "abc1234".to_string(),
dirty,
skip_teardown,
});
app
}
#[tokio::test]
async fn tui_ahead_worktree_delete_uppercase_x_is_the_only_key_that_discards() {
for dirty in [false, true] {
for skip_teardown in [false, true] {
let mut app = tui_ahead_worktree_delete_app(skip_teardown, dirty);
let commands = route_key(&mut app, KeyCode::Char('X')).await;
let [TuiCommand::DeleteWorktree(intent)] = commands.as_slice() else {
panic!("expected a single delete command, got {commands:?}");
};
assert!(intent.allow_commits_ahead);
assert_eq!(
intent.allow_known_dirty, dirty,
"the one keypress grants dirty discard only where the modal disclosed it"
);
assert_eq!(intent.skip_teardown, skip_teardown);
assert_eq!(intent.path, PathBuf::from("/tmp/wt-a"));
assert_eq!(intent.branch, "change-b");
assert_eq!(intent.identity.as_deref(), Some("gitdir: /tmp/wt-a/.git"));
assert_eq!(intent.head.as_deref(), Some("abc1234"));
assert!(app.modal.is_none());
assert!(app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")));
}
}
}
#[tokio::test]
async fn tui_ahead_worktree_delete_confirmation_ignores_every_other_key() {
for code in [
KeyCode::Char('y'),
KeyCode::Char('Y'),
KeyCode::Char('s'),
KeyCode::Char('S'),
KeyCode::Char('x'),
KeyCode::Char('d'),
KeyCode::Char('D'),
KeyCode::Enter,
KeyCode::Char(' '),
KeyCode::Down,
KeyCode::Tab,
KeyCode::F(5),
] {
let mut app = tui_ahead_worktree_delete_app(false, true);
let before = app.modal.clone();
let commands = route_key(&mut app, code).await;
assert!(
commands.is_empty(),
"{code:?} must not dispatch anything from the ahead confirmation"
);
assert_eq!(
app.modal, before,
"{code:?} must leave the ahead confirmation exactly as it was"
);
assert!(
!app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")),
"{code:?} must not start a deletion"
);
}
}
#[tokio::test]
async fn tui_ahead_worktree_delete_n_and_esc_cancel_and_retain_both_resources() {
for code in [KeyCode::Char('n'), KeyCode::Char('N'), KeyCode::Esc] {
let mut app = tui_ahead_worktree_delete_app(false, false);
let commands = route_key(&mut app, code).await;
assert!(commands.is_empty(), "{code:?} must dispatch nothing");
assert!(app.modal.is_none(), "{code:?} must close the confirmation");
assert!(
!app.is_worktree_deleting(&PathBuf::from("/tmp/wt-a")),
"{code:?} must retain the worktree, its content, and its branch"
);
}
}
#[tokio::test]
async fn tui_ahead_worktree_delete_refuses_a_target_that_drifted_before_dispatch() {
let cases: [DriftCase; 6] = [
("absent", |app: &mut AppState| app.worktrees.clear()),
("main", |app: &mut AppState| app.worktrees[0].is_main = true),
("rebranded", |app: &mut AppState| {
app.worktrees[0].branch = "change-z".to_string()
}),
("head-moved", |app: &mut AppState| {
app.worktrees[0].head = "def5678".to_string()
}),
("active", |app: &mut AppState| {
app.changes[1].set_display_status_cache("applying")
}),
("deleting", |app: &mut AppState| {
app.mark_worktree_deleting(PathBuf::from("/tmp/wt-a"))
}),
];
for (name, mutate) in cases {
let mut app = tui_ahead_worktree_delete_app(false, false);
mutate(&mut app);
let commands = route_key(&mut app, KeyCode::Char('X')).await;
assert!(
commands.is_empty(),
"{name}: a drifted target must not be discarded"
);
assert!(
app.modal.is_none(),
"{name}: the stale confirmation must be cleared"
);
assert!(app
.warning_message
.as_ref()
.is_some_and(|msg| msg.contains("Ahead worktree discard canceled")));
}
}
#[tokio::test]
async fn k_key_opens_force_kill_without_leaving_running_execution() {
let mut app = routing_app();
let commands = route_key(&mut app, KeyCode::Char('K')).await;
assert!(commands.is_empty());
assert_eq!(
app.modal,
Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string()
})
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"opening a confirmation must not move the execution axis"
);
}
#[tokio::test]
async fn w_key_opens_qr_over_the_current_execution_mode() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = routing_app();
app.execution_mode = mode;
route_key(&mut app, KeyCode::Char('w')).await;
assert_eq!(app.modal, Some(ModalState::QrPopup));
assert_eq!(app.execution_mode, mode);
}
}
#[tokio::test]
async fn w_key_is_ignored_when_web_monitoring_is_disabled() {
let mut app = routing_app();
app.web_url = None;
let before = UnderlyingState::capture(&app);
route_key(&mut app, KeyCode::Char('w')).await;
assert!(app.modal.is_none());
assert_eq!(UnderlyingState::capture(&app), before);
}
#[tokio::test]
async fn bulk_mark_key_applies_in_every_execution_mode() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = routing_app();
app.execution_mode = mode;
route_key(&mut app, KeyCode::Char('x')).await;
assert!(
app.changes[1].selected,
"{mode:?} must admit bulk mark for a visible non-terminal row"
);
}
}
fn error_details_app() -> AppState {
let mut app = routing_app();
app.changes[1].set_error_message_cache("Apply failed: stalled".to_string());
app.cursor_index = 1;
app
}
#[tokio::test]
async fn enter_opens_the_error_details_popup_on_an_error_row() {
let mut app = error_details_app();
let commands = route_key(&mut app, KeyCode::Enter).await;
assert!(commands.is_empty(), "opening a popup emits no command");
let popup = app
.error_details_popup
.as_ref()
.expect("Enter opens the Error Details popup");
assert_eq!(popup.change_id, "change-b");
assert_eq!(popup.error, "Apply failed: stalled");
assert_eq!(popup.scroll, 0);
assert!(popup.copy_feedback.is_none());
}
#[tokio::test]
async fn enter_on_a_non_error_row_keeps_its_existing_behavior() {
let mut app = error_details_app();
app.cursor_index = 0; let before = UnderlyingState::capture(&app);
let commands = route_key(&mut app, KeyCode::Enter).await;
assert!(commands.is_empty());
assert!(
app.error_details_popup.is_none(),
"only an error row opens the popup"
);
assert_eq!(UnderlyingState::capture(&app), before);
assert!(
app.logs
.iter()
.any(|log| log.message.contains("Enter ignored: not in Worktrees view")),
"the pre-existing Enter behavior is unchanged"
);
}
#[tokio::test]
async fn popup_scroll_keys_do_not_move_the_underlying_views() {
for code in [KeyCode::Down, KeyCode::Char('j'), KeyCode::PageDown] {
let mut app = error_details_app();
for index in 0..12 {
app.add_log(LogEntry::info(format!("log {index}")));
}
app.scroll_logs_up(3);
assert!(app.open_error_details_popup());
let before = UnderlyingState::capture(&app);
let log_anchor_before = app.log_anchor;
let commands = route_key_event(&mut app, key(code)).await;
assert!(commands.is_empty(), "{code:?} leaked a command");
assert!(
app.error_details_popup
.as_ref()
.is_some_and(|popup| popup.scroll > 0),
"{code:?} must scroll the popup"
);
assert_eq!(
UnderlyingState::capture(&app),
before,
"{code:?} moved the Changes list underneath the popup"
);
assert_eq!(
app.log_anchor, log_anchor_before,
"{code:?} moved the Logs panel underneath the popup"
);
}
}
#[tokio::test]
async fn escape_closes_the_popup_without_a_workflow_transition() {
let mut app = error_details_app();
assert!(app.open_error_details_popup());
let before = UnderlyingState::capture(&app);
let commands = route_key(&mut app, KeyCode::Esc).await;
assert!(commands.is_empty(), "closing a popup emits no command");
assert!(app.error_details_popup.is_none());
assert_eq!(UnderlyingState::capture(&app), before);
assert_eq!(app.stop_mode, StopMode::None, "Esc must not reach the view");
}
#[tokio::test]
async fn warning_popup_retains_first_claim_over_the_error_details_popup() {
let mut app = error_details_app();
assert!(app.open_error_details_popup());
app.show_warning_popup("warning", "line 1\nline 2\nline 3");
let before = UnderlyingState::capture(&app);
route_key(&mut app, KeyCode::Down).await;
assert_eq!(app.warning_popup_scroll, 1, "the warning popup scrolled");
assert_eq!(
app.error_details_popup.as_ref().map(|popup| popup.scroll),
Some(0),
"the Error Details popup must not process the same key"
);
assert_eq!(UnderlyingState::capture(&app), before);
}
#[tokio::test]
async fn ctrl_c_keeps_its_global_quit_meaning_while_the_popup_is_open() {
let mut app = error_details_app();
app.set_clipboard(Arc::new(
crate::tui::clipboard::test_doubles::RecordingClipboard::default(),
));
assert!(app.open_error_details_popup());
route_key_event(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert!(app.should_quit, "Ctrl+C must still quit");
assert!(
app.error_details_popup
.as_ref()
.is_some_and(|popup| popup.copy_feedback.is_none()),
"a modified key must not be treated as the popup copy action"
);
}
#[test]
fn unmodified_c_copies_stable_plain_text_and_keeps_the_popup_open() {
let mut app = error_details_app();
let clipboard =
Arc::new(crate::tui::clipboard::test_doubles::RecordingClipboard::default());
app.set_clipboard(clipboard.clone());
assert!(app.open_error_details_popup());
assert!(handle_error_details_popup_key(
&mut app,
key(KeyCode::Char('c'))
));
assert_eq!(
clipboard.copies(),
vec!["Change: change-b\nError: Apply failed: stalled".to_string()]
);
let popup = app.error_details_popup.as_ref().expect("popup stays open");
assert_eq!(popup.error, "Apply failed: stalled");
assert_eq!(
popup.copy_feedback,
Some(crate::tui::state::CopyFeedback::Copied)
);
}
#[tokio::test]
async fn only_unmodified_c_copies_and_ctrl_c_still_quits() {
for (code, modifiers) in [
(KeyCode::Char('c'), KeyModifiers::SHIFT),
(KeyCode::Char('C'), KeyModifiers::SHIFT),
(KeyCode::Char('c'), KeyModifiers::SUPER),
(
KeyCode::Char('c'),
KeyModifiers::SHIFT | KeyModifiers::SUPER,
),
] {
let mut app = error_details_app();
let clipboard =
Arc::new(crate::tui::clipboard::test_doubles::RecordingClipboard::default());
app.set_clipboard(clipboard.clone());
assert!(app.open_error_details_popup());
assert!(
handle_error_details_popup_key(&mut app, KeyEvent::new(code, modifiers)),
"{code:?}+{modifiers:?} stays owned by the popup"
);
assert!(
clipboard.copies().is_empty(),
"{code:?}+{modifiers:?} must not copy"
);
let popup = app.error_details_popup.as_ref().expect("popup stays open");
assert!(
popup.copy_feedback.is_none(),
"{code:?}+{modifiers:?} reports no copy feedback"
);
}
let mut app = error_details_app();
let clipboard =
Arc::new(crate::tui::clipboard::test_doubles::RecordingClipboard::default());
app.set_clipboard(clipboard.clone());
assert!(app.open_error_details_popup());
assert!(handle_error_details_popup_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
));
assert_eq!(
clipboard.copies(),
vec!["Change: change-b\nError: Apply failed: stalled".to_string()],
"unmodified c is the one binding that copies"
);
assert!(!app.should_quit, "unmodified c never quits");
let mut app = error_details_app();
let clipboard =
Arc::new(crate::tui::clipboard::test_doubles::RecordingClipboard::default());
app.set_clipboard(clipboard.clone());
assert!(app.open_error_details_popup());
route_key_event(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert!(app.should_quit, "Ctrl+C keeps its global quit meaning");
assert!(clipboard.copies().is_empty(), "Ctrl+C must not copy");
}
#[test]
fn a_refused_copy_keeps_the_popup_open_with_actionable_feedback() {
let mut app = error_details_app();
app.set_clipboard(Arc::new(
crate::tui::clipboard::test_doubles::FailingClipboard::new("no clipboard provider"),
));
assert!(app.open_error_details_popup());
assert!(handle_error_details_popup_key(
&mut app,
key(KeyCode::Char('c'))
));
let popup = app.error_details_popup.as_ref().expect("popup stays open");
assert_eq!(popup.error, "Apply failed: stalled");
let feedback = popup.copy_feedback.clone().expect("failure is reported");
assert_eq!(
feedback,
crate::tui::state::CopyFeedback::Failed("no clipboard provider".to_string())
);
let message = feedback.message();
assert!(message.contains("no clipboard provider"), "{message}");
assert!(message.contains("manually"), "{message}");
}
#[test]
fn popup_keys_are_ignored_when_no_popup_is_open() {
let mut app = error_details_app();
for code in [
KeyCode::Char('c'),
KeyCode::Char('j'),
KeyCode::Esc,
KeyCode::PageDown,
] {
assert!(
!handle_error_details_popup_key(&mut app, key(code)),
"{code:?} must fall through when the popup is closed"
);
}
}
fn dismissal_routing_app() -> AppState {
let mut app = AppState::new(vec![
create_test_change("merged-a"),
create_test_change("change-b"),
create_test_change("merged-c"),
]);
app.view_mode = ViewMode::Changes;
app.execution_mode = AppExecutionMode::Running;
app.changes[0].set_display_status_cache("merged");
app.changes[1].set_display_status_cache("applying");
app.changes[2].set_display_status_cache("merged");
app.web_url = Some("http://127.0.0.1:8080".to_string());
app
}
fn row_ids(app: &AppState) -> Vec<String> {
app.changes.iter().map(|c| c.id.clone()).collect()
}
#[tokio::test]
async fn changes_view_d_dismisses_the_focused_row_in_one_key_event() {
let mut app = dismissal_routing_app();
let commands = route_key(&mut app, KeyCode::Char('d')).await;
assert!(commands.is_empty(), "dismissal dispatches no command");
assert_eq!(app.modal, None, "no confirmation may open");
assert_eq!(row_ids(&app), vec!["change-b", "merged-c"]);
assert!(app.dismissed_merged_ids().contains("merged-a"));
assert!(!app.dismissed_merged_ids().contains("merged-c"));
}
#[tokio::test]
async fn changes_view_d_is_silent_on_a_non_merged_row() {
let mut app = dismissal_routing_app();
app.cursor_index = 1;
let commands = route_key(&mut app, KeyCode::Char('d')).await;
assert!(commands.is_empty());
assert_eq!(app.modal, None);
assert_eq!(row_ids(&app), vec!["merged-a", "change-b", "merged-c"]);
assert!(app.dismissed_merged_ids().is_empty());
}
#[tokio::test]
async fn changes_view_shift_d_dismisses_every_projected_merged_row_in_one_key_event() {
let mut app = dismissal_routing_app();
app.cursor_index = 1;
let commands = route_key(&mut app, KeyCode::Char('D')).await;
assert!(commands.is_empty());
assert_eq!(app.modal, None, "no confirmation may open");
assert_eq!(row_ids(&app), vec!["change-b"]);
assert!(app.dismissed_merged_ids().contains("merged-a"));
assert!(app.dismissed_merged_ids().contains("merged-c"));
}
#[tokio::test]
async fn changes_view_shift_d_is_silent_without_a_merged_row() {
let mut app = dismissal_routing_app();
app.changes[0].set_display_status_cache("archived");
app.changes[2].set_display_status_cache("pushed");
let commands = route_key(&mut app, KeyCode::Char('D')).await;
assert!(commands.is_empty());
assert_eq!(app.modal, None);
assert_eq!(row_ids(&app), vec!["merged-a", "change-b", "merged-c"]);
assert!(app.dismissed_merged_ids().is_empty());
}
#[tokio::test]
async fn y_n_and_esc_no_longer_carry_any_dismissal_meaning() {
for code in [
KeyCode::Char('y'),
KeyCode::Char('Y'),
KeyCode::Char('n'),
KeyCode::Char('N'),
KeyCode::Esc,
] {
let mut app = dismissal_routing_app();
route_key(&mut app, code).await;
assert_eq!(app.modal, None, "{code:?} must open no overlay");
assert_eq!(row_ids(&app), vec!["merged-a", "change-b", "merged-c"]);
assert!(
app.dismissed_merged_ids().is_empty(),
"{code:?} must not dismiss anything"
);
}
}
#[tokio::test]
async fn an_overlay_that_owns_input_keeps_d_and_shift_d_away_from_the_rows() {
for code in [KeyCode::Char('d'), KeyCode::Char('D')] {
let mut app = dismissal_routing_app();
let overlay = ModalState::ConfirmForceKill {
change_id: "change-b".to_string(),
};
app.modal = Some(overlay.clone());
let commands = route_key(&mut app, code).await;
assert!(commands.is_empty(), "{code:?} must dispatch nothing");
assert_eq!(app.modal, Some(overlay), "{code:?}");
assert_eq!(row_ids(&app), vec!["merged-a", "change-b", "merged-c"]);
assert!(app.dismissed_merged_ids().is_empty(), "{code:?}");
}
}
#[tokio::test]
async fn dismissal_leaves_every_other_changes_view_control_intact() {
let mut app = dismissal_routing_app();
let marks_before: Vec<bool> = app.changes.iter().map(|c| c.selected).collect();
route_key(&mut app, KeyCode::Char('d')).await;
assert_eq!(
app.view_mode,
ViewMode::Changes,
"dismissal must not switch views"
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"dismissal must not touch the execution axis"
);
assert_eq!(
app.changes.iter().map(|c| c.selected).collect::<Vec<_>>(),
marks_before[1..].to_vec(),
"surviving rows keep their execution marks"
);
}
#[tokio::test]
async fn worktrees_view_keeps_d_and_shift_d_as_worktree_deletion() {
for code in [KeyCode::Char('d'), KeyCode::Char('D')] {
let mut app = tui_dirty_worktree_delete_app(false);
app.modal = None;
app.changes[1].set_display_status_cache("merged");
let commands = route_key(&mut app, code).await;
assert!(commands.is_empty());
assert_eq!(
app.modal,
Some(ModalState::ConfirmWorktreeDelete {
path: PathBuf::from("/tmp/wt-a"),
branch: "change-b".to_string(),
}),
"{code:?} must keep opening the worktree delete confirmation"
);
assert!(app.dismissed_merged_ids().is_empty());
}
}
}