use crate::tui::events::LogEntry;
use crate::tui::types::ViewMode;
use super::{AppState, ChangeState};
pub(crate) const DISMISSABLE_STATUS: &str = "merged";
pub(crate) fn is_dismissable(change: &ChangeState) -> bool {
change.display_status_cache == DISMISSABLE_STATUS
}
pub(crate) fn focused_row_is_dismissable(state: &AppState) -> bool {
focused_dismissable_id(state).is_some()
}
pub(crate) fn has_dismissable_rows(state: &AppState) -> bool {
state.view_mode == ViewMode::Changes && state.changes.iter().any(is_dismissable)
}
fn focused_dismissable_id(state: &AppState) -> Option<String> {
if state.view_mode != ViewMode::Changes {
return None;
}
state
.changes
.get(state.cursor_index)
.filter(|change| is_dismissable(change))
.map(|change| change.id.clone())
}
fn projected_dismissable_ids(state: &AppState) -> Vec<String> {
state
.changes
.iter()
.filter(|change| is_dismissable(change))
.map(|change| change.id.clone())
.collect()
}
fn overlay_owns_input(state: &AppState) -> bool {
state.has_overlay() || state.error_details_popup.is_some()
}
pub(crate) fn dismiss_focused(state: &mut AppState) -> bool {
if overlay_owns_input(state) {
return false;
}
let Some(change_id) = focused_dismissable_id(state) else {
return false;
};
dismiss(state, vec![change_id]);
true
}
pub(crate) fn dismiss_all_projected(state: &mut AppState) -> bool {
if overlay_owns_input(state) || state.view_mode != ViewMode::Changes {
return false;
}
let change_ids = projected_dismissable_ids(state);
if change_ids.is_empty() {
return false;
}
dismiss(state, change_ids);
true
}
fn dismiss(state: &mut AppState, ids: Vec<String>) {
let filter_target = if state.selected_proposal_log_filter {
state
.selected_proposal_log_filter_target()
.map(str::to_string)
} else {
None
};
for id in &ids {
state.dismissed_merged_ids.insert(id.clone());
}
state.changes.retain(|change| !ids.contains(&change.id));
state.known_change_ids.retain(|id| !ids.contains(id));
state.new_change_count = state.changes.iter().filter(|c| c.is_new).count();
repair_cursor(state);
if filter_target.is_some_and(|target| ids.contains(&target)) {
state.selected_proposal_log_filter = false;
}
let summary = if ids.len() == 1 {
format!("Dismissed merged row '{}' from this session", ids[0])
} else {
format!(
"Dismissed {} merged rows from this session: {}",
ids.len(),
ids.join(", ")
)
};
state.add_log(LogEntry::info(summary));
}
fn repair_cursor(state: &mut AppState) {
if state.changes.is_empty() {
state.cursor_index = 0;
state.list_state.select(None);
return;
}
if state.cursor_index >= state.changes.len() {
state.cursor_index = state.changes.len() - 1;
}
state.list_state.select(Some(state.cursor_index));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::tui::types::ModalState;
fn change(id: &str) -> Change {
Change {
id: id.to_string(),
completed_tasks: 1,
total_tasks: 1,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
fn app(rows: &[(&str, &str)]) -> AppState {
let mut app = AppState::new(rows.iter().map(|(id, _)| change(id)).collect());
for (index, (_, status)) in rows.iter().enumerate() {
app.changes[index].set_display_status_cache(status);
}
app.logs.clear();
app
}
fn row_ids(app: &AppState) -> Vec<String> {
app.changes.iter().map(|c| c.id.clone()).collect()
}
#[test]
fn individual_dismissal_removes_only_the_focused_row_and_opens_no_modal() {
let mut app = app(&[
("alpha", "merged"),
("beta", "not queued"),
("gamma", "merged"),
]);
assert!(dismiss_focused(&mut app));
assert_eq!(app.modal, None, "dismissal must not open an overlay");
assert_eq!(row_ids(&app), vec!["beta", "gamma"]);
assert!(app.dismissed_merged_ids().contains("alpha"));
assert!(!app.dismissed_merged_ids().contains("gamma"));
assert!(!app.known_change_ids.contains("alpha"));
}
#[test]
fn individual_dismissal_is_inert_on_a_non_merged_row() {
let mut app = app(&[("alpha", "applying"), ("beta", "merged")]);
assert!(!dismiss_focused(&mut app));
assert_eq!(app.modal, None);
assert!(app.logs.is_empty());
assert!(app.dismissed_merged_ids().is_empty());
assert_eq!(row_ids(&app), vec!["alpha", "beta"]);
}
#[test]
fn bulk_dismissal_removes_every_projected_merged_row_and_nothing_else() {
let mut app = app(&[
("alpha", "merged"),
("beta", "not queued"),
("gamma", "merged"),
]);
assert!(dismiss_all_projected(&mut app));
assert_eq!(app.modal, None);
assert_eq!(row_ids(&app), vec!["beta"]);
assert_eq!(app.changes[0].display_status_cache, "not queued");
assert!(app.dismissed_merged_ids().contains("alpha"));
assert!(app.dismissed_merged_ids().contains("gamma"));
}
#[test]
fn bulk_dismissal_targets_the_whole_projection_regardless_of_the_cursor() {
let mut app = app(&[
("alpha", "merged"),
("beta", "not queued"),
("gamma", "merged"),
]);
app.cursor_index = 1;
assert!(dismiss_all_projected(&mut app));
assert_eq!(row_ids(&app), vec!["beta"]);
}
#[test]
fn bulk_dismissal_has_no_target_when_no_row_is_merged() {
let mut app = app(&[("alpha", "applying"), ("beta", "not queued")]);
assert!(!dismiss_all_projected(&mut app));
assert_eq!(app.modal, None);
assert!(app.logs.is_empty());
assert!(app.dismissed_merged_ids().is_empty());
}
#[test]
fn a_row_that_stopped_being_merged_is_not_hidden() {
let mut app = app(&[("alpha", "merged"), ("beta", "not queued")]);
app.changes[0].set_display_status_cache("resolving");
assert!(!dismiss_focused(&mut app));
assert!(!dismiss_all_projected(&mut app));
assert_eq!(row_ids(&app), vec!["alpha", "beta"]);
assert!(app.dismissed_merged_ids().is_empty());
assert!(
app.logs.is_empty(),
"a no-op dismissal must not log a dismissal that did not happen"
);
}
#[test]
fn cursor_survives_removing_the_first_middle_last_and_only_row() {
let mut first = app(&[
("alpha", "merged"),
("beta", "not queued"),
("gamma", "not queued"),
]);
first.cursor_index = 0;
assert!(dismiss_focused(&mut first));
assert_eq!(first.cursor_index, 0);
assert_eq!(first.list_state.selected(), Some(0));
assert_eq!(row_ids(&first), vec!["beta", "gamma"]);
let mut middle = app(&[
("alpha", "not queued"),
("beta", "merged"),
("gamma", "not queued"),
]);
middle.cursor_index = 1;
assert!(dismiss_focused(&mut middle));
assert_eq!(middle.cursor_index, 1);
assert_eq!(row_ids(&middle), vec!["alpha", "gamma"]);
let mut last = app(&[("alpha", "not queued"), ("beta", "merged")]);
last.cursor_index = 1;
assert!(dismiss_focused(&mut last));
assert_eq!(last.cursor_index, 0);
assert_eq!(last.list_state.selected(), Some(0));
let mut only = app(&[("alpha", "merged")]);
assert!(dismiss_focused(&mut only));
assert!(only.changes.is_empty());
assert_eq!(only.cursor_index, 0);
assert_eq!(only.list_state.selected(), None);
}
#[test]
fn dismissing_every_row_leaves_an_empty_but_valid_projection() {
let mut app = app(&[("alpha", "merged"), ("beta", "merged")]);
assert!(dismiss_all_projected(&mut app));
assert!(app.changes.is_empty());
assert_eq!(app.list_state.selected(), None);
assert_eq!(app.new_change_count, 0);
assert!(!has_dismissable_rows(&app));
assert!(!focused_row_is_dismissable(&app));
}
#[test]
fn dismissing_the_log_filter_target_disables_the_filter_without_dropping_logs() {
let mut app = app(&[("alpha", "merged"), ("beta", "not queued")]);
app.add_log(LogEntry::info("alpha ran").with_change_id("alpha"));
let buffered = app.logs.len();
app.toggle_selected_proposal_log_filter();
assert!(app.selected_proposal_log_filter);
assert_eq!(app.selected_proposal_log_filter_target(), Some("alpha"));
assert!(dismiss_focused(&mut app));
assert!(!app.selected_proposal_log_filter);
assert!(
app.logs.len() >= buffered,
"disabling the filter must not delete buffered entries"
);
assert!(app
.logs
.iter()
.any(|entry| entry.message.contains("alpha ran")));
}
#[test]
fn dismissing_a_row_the_filter_does_not_target_keeps_the_filter_on() {
let mut app = app(&[("alpha", "not queued"), ("beta", "merged")]);
app.toggle_selected_proposal_log_filter();
assert!(app.selected_proposal_log_filter);
assert!(dismiss_all_projected(&mut app));
assert!(app.selected_proposal_log_filter);
assert_eq!(app.selected_proposal_log_filter_target(), Some("alpha"));
}
#[test]
fn an_overlay_that_already_owns_input_refuses_a_dismissal() {
let mut app = app(&[("alpha", "merged")]);
app.modal = Some(ModalState::QrPopup);
assert!(!dismiss_focused(&mut app));
assert!(!dismiss_all_projected(&mut app));
assert_eq!(app.modal, Some(ModalState::QrPopup));
assert_eq!(row_ids(&app), vec!["alpha"]);
assert!(app.dismissed_merged_ids().is_empty());
}
#[test]
fn dismissal_is_changes_view_only() {
let mut app = app(&[("alpha", "merged")]);
app.view_mode = ViewMode::Worktrees;
assert!(!dismiss_focused(&mut app));
assert!(!dismiss_all_projected(&mut app));
assert!(!has_dismissable_rows(&app));
assert_eq!(app.modal, None);
assert_eq!(row_ids(&app), vec!["alpha"]);
}
#[test]
fn hints_track_the_focused_row_and_the_projection_independently() {
let mut app = app(&[("alpha", "not queued"), ("beta", "merged")]);
assert!(!focused_row_is_dismissable(&app));
assert!(has_dismissable_rows(&app));
app.cursor_index = 1;
assert!(focused_row_is_dismissable(&app));
}
}