pub mod dismiss_prompt;
pub mod panels;
pub mod state;
use std::io::{self, Stdout, Write};
use std::time::Duration;
use crossterm::event::{self, Event};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::sync::Mutex;
use quorum_core::archive::SuppressionSummary;
use quorum_core::memory::{MemoryError, MemoryStore};
use quorum_core::review::Review;
pub use state::{AppState, Command, Modal};
#[derive(thiserror::Error, Debug)]
pub enum TuiError {
#[error("terminal io: {0}")]
Io(#[from] io::Error),
#[error("memory store: {0}")]
Memory(#[from] MemoryError),
}
pub struct TuiOutcome {
pub kept_findings: Vec<quorum_core::review::Finding>,
pub newly_suppressed: Vec<SuppressionSummary>,
}
pub fn run(
review: &Review,
store: &dyn MemoryStore,
repo_head_sha: &str,
branch: &str,
no_expire: bool,
) -> Result<TuiOutcome, TuiError> {
let mut state = AppState::new(review.findings.clone(), no_expire);
let session_id = review.session_id.clone();
let db_path_str = db_path_label(store);
let mut session = TuiSession::enter()?;
let mut newly_suppressed: Vec<SuppressionSummary> = Vec::new();
loop {
session.terminal.draw(|f| {
panels::render(f, &state, &session_id, &db_path_str);
})?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let ev = event::read()?;
let key = match ev {
Event::Key(k) => k,
_ => continue,
};
match state.on_key(key) {
Command::None => continue,
Command::Quit => break,
Command::Dismiss {
finding_index,
reason,
note,
} => {
let f = match state.findings.get(finding_index).cloned() {
Some(f) => f,
None => continue,
};
let expires = if state.no_expire {
None
} else {
Some(time::Duration::days(365))
};
match store.dismiss(&f, repo_head_sha, branch, reason, note.clone(), expires) {
Ok(id) => {
let summary = SuppressionSummary {
finding_identity_hash: quorum_core::memory::finding_identity_hash(&f)
.to_hex(),
title_snapshot: f.title.clone(),
source_type_snapshot: source_kind(f.source).to_string(),
reason: reason.as_db_str().to_string(),
dismissed_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
};
newly_suppressed.push(summary);
state.apply_committed_dismissal(finding_index, id);
}
Err(MemoryError::AlreadyDismissed) => {
state.apply_already_dismissed(finding_index);
}
Err(MemoryError::OtherWithoutNote) | Err(MemoryError::InvalidNote) => {
state.status_message = Some(
"invalid note rejected by storage; press any key to continue".into(),
);
state.modal = Modal::Error;
}
Err(e) => {
state.status_message = Some(format!("dismiss failed: {e}"));
state.modal = Modal::Error;
}
}
}
Command::Undo => {
if let Some(entry) = state.undo_stack.pop() {
let id = entry.dismissal_id;
match store.delete(id) {
Ok(_) => {
let hash =
quorum_core::memory::finding_identity_hash(&entry.finding).to_hex();
newly_suppressed.retain(|s| s.finding_identity_hash != hash);
state.apply_undo(entry);
}
Err(e) => {
state.undo_stack.push(entry);
state.status_message = Some(format!("undo failed: {e}"));
state.modal = Modal::Error;
}
}
}
}
}
}
session.terminal.draw(|f| {
panels::render(f, &state, &session_id, &db_path_str);
})?;
let kept_findings = state.findings;
Ok(TuiOutcome {
kept_findings,
newly_suppressed,
})
}
fn source_kind(s: quorum_core::review::FindingSource) -> &'static str {
match s {
quorum_core::review::FindingSource::Divergence => "divergence",
quorum_core::review::FindingSource::Agreement => "agreement",
quorum_core::review::FindingSource::Assumption => "assumption",
}
}
fn db_path_label(_store: &dyn MemoryStore) -> String {
".quorum/dismissals.sqlite".to_string()
}
pub struct TuiSession {
terminal: Terminal<CrosstermBackend<Stdout>>,
raw_was_enabled: bool,
}
impl TuiSession {
pub fn enter() -> Result<Self, TuiError> {
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
enable_raw_mode()?;
install_panic_hook();
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
raw_was_enabled: true,
})
}
}
impl Drop for TuiSession {
fn drop(&mut self) {
if self.raw_was_enabled {
let _ = disable_raw_mode();
}
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = io::stdout().flush();
restore_panic_hook();
}
}
type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync>;
static PRIOR_HOOK: Mutex<Option<PanicHook>> = Mutex::new(None);
fn install_panic_hook() {
let mut slot = PRIOR_HOOK.lock().unwrap();
if slot.is_some() {
return; }
let prev = std::panic::take_hook();
*slot = Some(prev);
drop(slot);
std::panic::set_hook(Box::new(|info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = io::stdout().flush();
let guard = PRIOR_HOOK.lock().unwrap();
if let Some(prev) = guard.as_ref() {
prev(info);
}
}));
}
fn restore_panic_hook() {
let mut slot = PRIOR_HOOK.lock().unwrap();
if let Some(prev) = slot.take() {
std::panic::set_hook(prev);
}
}
#[cfg(test)]
mod tests {
}