use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use crate::openspec::Change;
use crate::tui::events::LogEntry;
use crate::tui::types::WorktreeInfo;
use super::AppState;
fn is_refresh_merge_wait_terminal_status(status: &str) -> bool {
matches!(status, "archived" | "merged" | "rejected")
}
fn is_reducer_owned_refresh_merge_wait_protected_status(status: &str) -> bool {
matches!(
status,
"resolving"
| "resolve pending"
| "rejecting"
| "reject pending"
| "merged"
| "rejected"
| "error"
)
}
impl AppState {
pub(crate) fn handle_dependency_blocked(&mut self, change_id: String) {
let was_already_blocked = self
.changes
.iter_mut()
.find(|c| c.id == change_id)
.map(|change| {
let was_blocked = change.display_status_cache == "blocked";
change.set_display_status_cache("blocked");
was_blocked
})
.unwrap_or(false);
if was_already_blocked {
tracing::debug!(
change_id = %change_id,
"Suppressing repeated dependency-blocked TUI log"
);
return;
}
self.add_log(LogEntry::info(format!(
"Change '{}' blocked by dependencies",
change_id
)));
}
pub(crate) fn handle_dependency_resolved(&mut self, change_id: String) {
let was_blocked = self
.changes
.iter_mut()
.find(|c| c.id == change_id)
.map(|change| {
let was_blocked = change.display_status_cache == "blocked";
if was_blocked {
change.set_display_status_cache("queued");
}
was_blocked
})
.unwrap_or(false);
if !was_blocked {
tracing::debug!(
change_id = %change_id,
"Suppressing repeated dependency-resolved TUI log"
);
return;
}
self.reset_analysis_log_dedupe();
self.add_log(LogEntry::info(format!(
"Change '{}' dependencies resolved",
change_id
)));
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_changes_refreshed(
&mut self,
changes: Vec<Change>,
rejected_changes: Vec<Change>,
committed_change_ids: HashSet<String>,
uncommitted_file_change_ids: HashSet<String>,
worktree_change_ids: HashSet<String>,
worktree_paths: HashMap<String, PathBuf>,
_worktree_not_ahead_ids: HashSet<String>,
merge_wait_ids: HashSet<String>,
) {
let terminal_merge_wait_statuses = self.terminal_merge_wait_statuses(&merge_wait_ids);
let reducer_protected_merge_wait_ids =
self.reducer_protected_merge_wait_ids(&merge_wait_ids);
self.worktree_paths = worktree_paths;
self.update_changes_with_rejected(changes, rejected_changes);
self.apply_parallel_eligibility(&committed_change_ids, &uncommitted_file_change_ids);
self.apply_worktree_status(&worktree_change_ids);
self.apply_refresh_merge_wait_status(
&merge_wait_ids,
&terminal_merge_wait_statuses,
&reducer_protected_merge_wait_ids,
);
}
fn terminal_merge_wait_statuses(
&self,
merge_wait_ids: &HashSet<String>,
) -> HashMap<String, String> {
self.changes
.iter()
.filter(|change| {
merge_wait_ids.contains(&change.id)
&& is_refresh_merge_wait_terminal_status(&change.display_status_cache)
})
.map(|change| (change.id.clone(), change.display_status_cache.clone()))
.collect()
}
fn reducer_protected_merge_wait_ids(
&self,
merge_wait_ids: &HashSet<String>,
) -> HashSet<String> {
merge_wait_ids
.iter()
.filter(|change_id| {
self.reducer_display_status_snapshot
.get(change_id.as_str())
.is_some_and(|status| {
is_reducer_owned_refresh_merge_wait_protected_status(status)
})
})
.cloned()
.collect()
}
fn apply_refresh_merge_wait_status(
&mut self,
merge_wait_ids: &HashSet<String>,
terminal_merge_wait_statuses: &HashMap<String, String>,
reducer_protected_merge_wait_ids: &HashSet<String>,
) {
if merge_wait_ids.is_empty() {
return;
}
for change in &mut self.changes {
if !merge_wait_ids.contains(&change.id) {
continue;
}
if let Some(terminal_status) = terminal_merge_wait_statuses.get(&change.id) {
change.set_display_status_cache(terminal_status);
continue;
}
if is_refresh_merge_wait_terminal_status(&change.display_status_cache) {
continue;
}
if reducer_protected_merge_wait_ids.contains(&change.id) {
tracing::debug!(
change_id = %change.id,
reducer_status = ?self.reducer_display_status_snapshot.get(change.id.as_str()),
"Preserving reducer-owned active, pending, terminal, or error display over refresh merge-wait evidence"
);
continue;
}
change.set_display_status_cache("merge wait");
}
}
pub(crate) fn handle_worktrees_refreshed(&mut self, worktrees: Vec<WorktreeInfo>) {
self.worktrees = worktrees;
if self.worktree_cursor_index >= self.worktrees.len() && !self.worktrees.is_empty() {
self.worktree_cursor_index = self.worktrees.len() - 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
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 count_blocked_logs(app: &AppState, change_id: &str) -> usize {
let message = format!("Change '{}' blocked by dependencies", change_id);
app.logs
.iter()
.filter(|entry| entry.message == message)
.count()
}
type RefreshSets = (
HashSet<String>,
HashSet<String>,
HashSet<String>,
HashMap<String, PathBuf>,
HashSet<String>,
);
fn empty_refresh_sets() -> RefreshSets {
(
HashSet::new(),
HashSet::new(),
HashSet::new(),
HashMap::new(),
HashSet::new(),
)
}
#[test]
fn changes_refreshed_adds_new_active_log_and_rejected_non_new_without_moving_cursor() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.cursor_index = 0;
app.list_state.select(Some(0));
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![
create_test_change("change-a"),
create_test_change("change-new"),
],
vec![create_test_change("change-rejected")],
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::new(),
);
let active = app
.changes
.iter()
.find(|c| c.id == "change-new")
.expect("active new row");
assert_eq!(active.display_status_cache, "not queued");
assert!(active.is_new);
assert!(!active.selected);
let rejected = app
.changes
.iter()
.find(|c| c.id == "change-rejected")
.expect("rejected row");
assert_eq!(rejected.display_status_cache, "rejected");
assert!(!rejected.is_new);
assert!(!rejected.selected);
assert_eq!(app.cursor_index, 0, "refresh must not steal cursor focus");
assert_eq!(app.new_change_count, 1);
assert_eq!(
app.logs
.iter()
.filter(|entry| entry.message == "Detected new change: change-new")
.count(),
1
);
}
#[test]
fn merge_wait_refresh_corrects_stale_resolve_pending_row() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.changes[0].set_display_status_cache("resolve pending");
app.apply_display_statuses_from_reducer(&HashMap::from([(
"change-a".to_string(),
"merge wait",
)]));
app.changes[0].set_display_status_cache("resolve pending");
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![create_test_change("change-a")],
Vec::new(),
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::from(["change-a".to_string()]),
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
}
#[test]
fn merge_wait_refresh_preserves_reducer_owned_resolve_pending_row() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.apply_display_statuses_from_reducer(&HashMap::from([(
"change-a".to_string(),
"resolve pending",
)]));
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![create_test_change("change-a")],
Vec::new(),
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::from(["change-a".to_string()]),
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn merge_wait_refresh_preserves_reducer_owned_resolving_row() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.apply_display_statuses_from_reducer(&HashMap::from([(
"change-a".to_string(),
"resolving",
)]));
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![create_test_change("change-a")],
Vec::new(),
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::from(["change-a".to_string()]),
);
assert_eq!(app.changes[0].display_status_cache, "resolving");
}
#[test]
fn merge_wait_refresh_preserves_reducer_owned_reject_pending_and_error_rows() {
let mut app = AppState::new(vec![
create_test_change("reject-pending"),
create_test_change("error-change"),
]);
app.apply_display_statuses_from_reducer(&HashMap::from([
("reject-pending".to_string(), "reject pending"),
("error-change".to_string(), "error"),
]));
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![
create_test_change("reject-pending"),
create_test_change("error-change"),
],
Vec::new(),
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::from(["reject-pending".to_string(), "error-change".to_string()]),
);
assert_eq!(app.changes[0].display_status_cache, "reject pending");
assert_eq!(app.changes[1].display_status_cache, "error");
}
#[test]
fn merge_wait_refresh_preserves_terminal_rows() {
let mut app = AppState::new(vec![
create_test_change("merged-change"),
create_test_change("rejected-change"),
]);
app.changes[0].set_display_status_cache("merged");
app.changes[1].set_display_status_cache("rejected");
let (committed, uncommitted, worktrees, paths, not_ahead) = empty_refresh_sets();
app.handle_changes_refreshed(
vec![
create_test_change("merged-change"),
create_test_change("rejected-change"),
],
Vec::new(),
committed,
uncommitted,
worktrees,
paths,
not_ahead,
HashSet::from(["merged-change".to_string(), "rejected-change".to_string()]),
);
assert_eq!(app.changes[0].display_status_cache, "merged");
assert_eq!(app.changes[1].display_status_cache, "rejected");
}
#[test]
fn repeated_dependency_blocked_updates_status_without_duplicate_log() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.handle_dependency_blocked("change-a".to_string());
app.handle_dependency_blocked("change-a".to_string());
assert_eq!(app.changes[0].display_status_cache, "blocked");
assert_eq!(count_blocked_logs(&app, "change-a"), 1);
}
#[test]
fn dependency_resolved_then_reblocked_logs_again() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
app.handle_dependency_blocked("change-a".to_string());
app.handle_dependency_resolved("change-a".to_string());
app.handle_dependency_blocked("change-a".to_string());
assert_eq!(app.changes[0].display_status_cache, "blocked");
assert_eq!(count_blocked_logs(&app, "change-a"), 2);
}
}