use std::io::{self, Write};
use std::path::{Path, PathBuf};
use futures_util::StreamExt;
use ratatui::prelude::*;
use ratatui::widgets::{Clear, List, ListItem, Paragraph};
use termina::event::{Event, KeyCode, KeyEventKind, Modifiers};
use termina::{EventStream, PlatformTerminal, Terminal};
use shuvarie_core::{Category, TrustGrants, WorkspaceScan};
use super::add_provider::centered_rect;
use super::{escape, list, theme};
pub enum TrustPromptOutcome {
Grant(TrustGrants),
Skip,
Quit,
}
pub async fn run_prompt(
scan: &WorkspaceScan,
cwd: &Path,
new_items: bool,
) -> io::Result<TrustPromptOutcome> {
let mut model = TrustPrompt::new(scan, cwd, new_items);
let mut term = PlatformTerminal::new()?;
term.enter_raw_mode()?;
let reader = term.event_reader();
let mut stream = EventStream::new(reader, |_| true);
let mut rat = ratatui::Terminal::new(ratatui::prelude::TerminaBackend::new(term))?;
write!(
rat.backend_mut().terminal_mut(),
"{}",
escape::ENTER_ALTERNATE_SCREEN
)?;
rat.backend_mut().terminal_mut().flush()?;
let outcome = prompt_loop(&mut model, &mut rat, &mut stream).await;
let term = rat.backend_mut().terminal_mut();
write!(term, "{}", escape::EXIT_ALTERNATE_SCREEN)?;
term.flush()?;
term.enter_cooked_mode()?;
outcome
}
async fn prompt_loop(
model: &mut TrustPrompt,
rat: &mut ratatui::Terminal<ratatui::prelude::TerminaBackend<termina::PlatformTerminal>>,
stream: &mut EventStream,
) -> io::Result<TrustPromptOutcome> {
loop {
rat.draw(|frame| model.view(frame, frame.area()))?;
let Some(result) = stream.next().await else {
return Ok(TrustPromptOutcome::Quit);
};
let Event::Key(key) = result? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('c') | KeyCode::Char('d')
if key.modifiers.contains(Modifiers::CONTROL) =>
{
return Ok(TrustPromptOutcome::Quit);
}
KeyCode::Up | KeyCode::Char('k') | KeyCode::Left => model.move_up(),
KeyCode::Down | KeyCode::Char('j') | KeyCode::Right => model.move_down(),
KeyCode::Char(' ') => model.toggle(),
KeyCode::Char('a') => model.check_all(),
KeyCode::Char('n') => model.check_none(),
KeyCode::Enter => return Ok(TrustPromptOutcome::Grant(model.grants())),
KeyCode::Escape => return Ok(TrustPromptOutcome::Skip),
KeyCode::Char('q') => return Ok(TrustPromptOutcome::Quit),
_ => {}
}
}
}
pub struct TrustPrompt {
cwd: PathBuf,
items: Vec<(Category, String)>,
checked: Vec<bool>,
selected: usize,
new_items: bool,
}
impl TrustPrompt {
pub fn new(scan: &WorkspaceScan, cwd: &Path, new_items: bool) -> Self {
let items: Vec<(Category, String)> = scan
.items
.iter()
.map(|item| (item.category, item.detail.clone()))
.collect();
let checked = vec![true; items.len()];
Self {
cwd: cwd.to_path_buf(),
items,
checked,
selected: 0,
new_items,
}
}
fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
fn move_down(&mut self) {
self.selected = (self.selected + 1).min(self.items.len().saturating_sub(1));
}
fn toggle(&mut self) {
if let Some(checked) = self.checked.get_mut(self.selected) {
*checked = !*checked;
}
}
fn check_all(&mut self) {
self.checked.iter_mut().for_each(|c| *c = true);
}
fn check_none(&mut self) {
self.checked.iter_mut().for_each(|c| *c = false);
}
fn grants(&self) -> TrustGrants {
TrustGrants::from_categories(
self.items
.iter()
.zip(&self.checked)
.filter(|(_, checked)| **checked)
.map(|((category, _), _)| *category),
)
}
fn view(&self, frame: &mut Frame<'_>, area: Rect) {
if self.items.is_empty() {
return;
}
let popup = centered_rect(64, 44, area);
frame.render_widget(Clear, popup);
let title = if self.new_items {
"Workspace trust — new files"
} else {
"Workspace trust"
};
let block = theme::overlay_block(title);
let inner = block.inner(popup);
frame.render_widget(block, popup);
let label_width = self
.items
.iter()
.map(|(category, _)| label(*category).len())
.max()
.unwrap_or(0);
let mut lines: Vec<ListItem> = vec![
ListItem::new(Line::from(
Span::raw(self.cwd.to_string_lossy().into_owned()).fg(theme::text_muted()),
)),
ListItem::new(Line::from("")),
];
for (i, (category, detail)) in self.items.iter().enumerate() {
let checked = self.checked[i];
let box_glyph: &str = if checked { "[x]" } else { "[ ]" };
let line = Line::from(vec![
Span::raw(format!("{box_glyph} ")).fg(if checked {
theme::accent()
} else {
theme::text_muted()
}),
Span::raw(format!("{:<label_width$}", label(*category))).fg(theme::text()),
Span::raw(" ").fg(theme::text()),
Span::raw(detail.clone()).fg(theme::text_muted()),
]);
lines.push(list::render_list_item_line(line, i == self.selected));
}
lines.push(ListItem::new(Line::from("")));
let list = List::new(lines);
frame.render_widget(list, inner);
let help = theme::help_line(&[
("↑↓", "select"),
("Space", "toggle"),
("a", "trust all"),
("n", "none"),
("Enter", "confirm"),
("Esc", "skip (session only)"),
]);
frame.render_widget(
Paragraph::new(help).fg(theme::text_muted()),
Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1),
);
}
}
fn label(category: Category) -> &'static str {
match category {
Category::Contexts => "Context files",
Category::Skills => "Skills",
Category::Configs => "Config",
}
}