diff --git a/crates/tui/src/keys.rs b/crates/tui/src/keys.rs index 99b09ebe..f63e9f73 100644 --- a/crates/tui/src/keys.rs +++ b/crates/tui/src/keys.rs @@ -1,18 +1,15 @@ -use std::io::{self, Stdout}; +use std::io::{self, Stdout, Write}; use std::process::ExitCode; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; -use ratatui::Terminal; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout}; -use ratatui::style::{Modifier, Style}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::widgets::Paragraph; +use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; use secrets::{SecretStore, SecretValue}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -238,12 +235,16 @@ pub async fn launch() -> ExitCode { } type UiResult = Result>; +type InlineTerminal = Terminal>; -struct TerminalRestoreGuard { +const MAX_ROWS: usize = 10; +const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5; + +struct RawModeGuard { active: bool, } -impl TerminalRestoreGuard { +impl RawModeGuard { fn new() -> Self { Self { active: true } } @@ -254,12 +255,11 @@ impl TerminalRestoreGuard { } fn cleanup(&mut self) { - let _ = execute!(io::stdout(), crossterm::cursor::Show, LeaveAlternateScreen); let _ = disable_raw_mode(); } } -impl Drop for TerminalRestoreGuard { +impl Drop for RawModeGuard { fn drop(&mut self) { if self.active { self.cleanup(); @@ -269,18 +269,41 @@ impl Drop for TerminalRestoreGuard { fn run(store: SecretStore) -> UiResult<()> { enable_raw_mode()?; - let guard = TerminalRestoreGuard::new(); - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let guard = RawModeGuard::new(); + let mut terminal = make_inline_terminal()?; let result = run_loop(&mut terminal, store); + let close_result = close_viewport(&mut terminal); drop(terminal); guard.restore(); - result + result?; + close_result?; + Ok(()) } -fn run_loop(terminal: &mut Terminal>, store: SecretStore) -> UiResult<()> { +fn make_inline_terminal() -> io::Result { + let backend = CrosstermBackend::new(io::stdout()); + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(VIEWPORT_LINES), + }, + ) +} + +/// Park the cursor at the very bottom of the inline viewport and emit one +/// newline before dropping the terminal. This matches the resume picker and +/// keeps the shell prompt (or a later inline viewport) from drawing over rows. +fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> { + let area = terminal.get_frame().area(); + let last_row = area.bottom().saturating_sub(1); + terminal.set_cursor_position((0, last_row))?; + let mut out = io::stdout(); + out.write_all(b"\r\n")?; + out.flush()?; + Ok(()) +} + +fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> { let mut app = KeysApp::new(load_ids(&store)?); loop { terminal.draw(|frame| draw(frame, &app))?; @@ -327,59 +350,188 @@ fn load_ids(store: &SecretStore) -> UiResult> { .collect()) } -fn draw(frame: &mut ratatui::Frame<'_>, app: &KeysApp) { +fn draw(frame: &mut Frame<'_>, app: &KeysApp) { let area = frame.area(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(5), - ]) - .split(area); - - let title = Paragraph::new("Local secret keys (values are never displayed)").block( - Block::default() - .borders(Borders::ALL) - .title("insomnia keys"), - ); - frame.render_widget(title, chunks[0]); - - let items: Vec> = if app.ids.is_empty() { - vec![ListItem::new(Line::from(Span::raw("No keys stored")))] - } else { - app.ids - .iter() - .map(|id| ListItem::new(Line::from(Span::raw(id.clone())))) - .collect() - }; - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("Key ids")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - let mut state = ListState::default(); - if !app.ids.is_empty() { - state.select(Some(app.selected)); + let mut constraints = Vec::with_capacity(MAX_ROWS + 5); + constraints.push(Constraint::Length(1)); // title + for _ in 0..MAX_ROWS { + constraints.push(Constraint::Length(1)); // key rows } - frame.render_stateful_widget(list, chunks[1], &mut state); + constraints.push(Constraint::Length(1)); // mode / actions + constraints.push(Constraint::Length(1)); // notice + constraints.push(Constraint::Length(1)); // protection note + constraints.push(Constraint::Length(1)); // spacer + let layout = Layout::vertical(constraints).split(area); - let input_line = match &app.mode { - Mode::Normal => "a add/set d delete ↑/↓ select q quit".to_string(), - Mode::EditingId => format!("Secret id: {}", app.input), - Mode::EditingValue { id } => format!( - "Value for `{id}`: {}", - "•".repeat(app.input.chars().count()) + frame.render_widget(Paragraph::new(title_line(app)), layout[0]); + + let (start, end) = visible_key_window(app.ids.len(), app.selected); + for row in 0..MAX_ROWS { + let line = if app.ids.is_empty() && row == 0 { + empty_keys_line() + } else if let Some(id) = app.ids.get(start + row).filter(|_| start + row < end) { + key_row_line(id, start + row == app.selected) + } else { + Line::from("") + }; + frame.render_widget(Paragraph::new(line), layout[row + 1]); + } + + let mode_row = MAX_ROWS + 1; + frame.render_widget(Paragraph::new(mode_line(app)), layout[mode_row]); + frame.render_widget(Paragraph::new(notice_line(app)), layout[MAX_ROWS + 2]); + frame.render_widget(Paragraph::new(protection_line()), layout[MAX_ROWS + 3]); + + if let Some(cursor_col) = mode_cursor_col(app) { + let col = cursor_col.min(area.width.saturating_sub(1) as usize); + frame.set_cursor_position((layout[mode_row].x + col as u16, layout[mode_row].y)); + } +} + +fn title_line(app: &KeysApp) -> Line<'_> { + Line::from(vec![ + Span::styled( + "insomnia keys local secrets", + Style::default().add_modifier(Modifier::BOLD), ), - Mode::ConfirmDelete { id } => format!("Delete `{id}`? y/N"), - }; - let help = Paragraph::new(vec![ - Line::from(input_line), - Line::from(app.notice.clone()), - Line::from("Protection is local obfuscation at rest, not a system keychain."), + Span::raw(" "), + Span::styled( + key_count_label(app.ids.len()), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled("values hidden", Style::default().fg(Color::DarkGray)), ]) - .block(Block::default().borders(Borders::ALL).title("Actions")) - .wrap(Wrap { trim: true }); - frame.render_widget(Clear, chunks[2]); - frame.render_widget(help, chunks[2]); +} + +fn key_count_label(count: usize) -> String { + match count { + 0 => "0 ids".to_string(), + 1 => "1 id".to_string(), + n => format!("{n} ids"), + } +} + +fn visible_key_window(total: usize, selected: usize) -> (usize, usize) { + if total <= MAX_ROWS { + return (0, total); + } + let last_start = total - MAX_ROWS; + let start = selected.saturating_sub(MAX_ROWS / 2).min(last_start); + (start, start + MAX_ROWS) +} + +fn empty_keys_line() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + Span::styled("No keys stored", Style::default().fg(Color::DarkGray)), + ]) +} + +fn key_row_line(id: &str, selected: bool) -> Line<'_> { + let marker = if selected { "▶ " } else { " " }; + let id_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + Line::from(vec![ + Span::raw(marker), + Span::styled(id, id_style), + Span::raw(" "), + Span::styled("[secret id]", Style::default().fg(Color::DarkGray)), + ]) +} + +fn mode_line(app: &KeysApp) -> Line<'_> { + match &app.mode { + Mode::Normal => Line::from(vec![ + Span::raw(" "), + Span::styled("[a]", Style::default().fg(Color::Green)), + Span::raw(" add/set "), + Span::styled("[d]", Style::default().fg(Color::Yellow)), + Span::raw(" delete "), + Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)), + Span::raw(" select "), + Span::styled("[q]", Style::default().fg(Color::Yellow)), + Span::raw(" quit"), + ]), + Mode::EditingId => Line::from(vec![ + Span::raw(" "), + Span::styled("secret id: ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.input.as_str(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ]), + Mode::EditingValue { id } => Line::from(vec![ + Span::raw(" "), + Span::styled("value for `", Style::default().fg(Color::DarkGray)), + Span::styled(id.as_str(), Style::default().fg(Color::Cyan)), + Span::styled("`: ", Style::default().fg(Color::DarkGray)), + Span::styled( + "•".repeat(app.input.chars().count()), + Style::default().fg(Color::Green), + ), + ]), + Mode::ConfirmDelete { id } => Line::from(vec![ + Span::raw(" "), + Span::styled("delete `", Style::default().fg(Color::DarkGray)), + Span::styled(id.as_str(), Style::default().fg(Color::Cyan)), + Span::styled("`? ", Style::default().fg(Color::DarkGray)), + Span::styled("[y]", Style::default().fg(Color::Red)), + Span::raw(" yes "), + Span::styled("[n/esc]", Style::default().fg(Color::Yellow)), + Span::raw(" cancel"), + ]), + } +} + +fn notice_line(app: &KeysApp) -> Line<'_> { + if app.notice.is_empty() { + return Line::from(""); + } + Line::from(vec![ + Span::raw(" "), + Span::styled(app.notice.as_str(), notice_style(app.notice.as_str())), + ]) +} + +fn notice_style(notice: &str) -> Style { + if notice.contains("failed") || notice.contains("must not") { + Style::default().fg(Color::Red) + } else if notice.starts_with("Saved") || notice.starts_with("Deleted") { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn protection_line() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + Span::styled( + "local obfuscation at rest; not a system keychain", + Style::default().fg(Color::DarkGray), + ), + ]) +} + +fn mode_cursor_col(app: &KeysApp) -> Option { + match &app.mode { + Mode::EditingId => Some(" secret id: ".chars().count() + app.input.chars().count()), + Mode::EditingValue { id } => Some( + " value for `".chars().count() + + id.chars().count() + + "`: ".chars().count() + + app.input.chars().count(), + ), + Mode::Normal | Mode::ConfirmDelete { .. } => None, + } } #[cfg(test)] @@ -431,4 +583,26 @@ mod tests { } ); } + + fn line_text(line: Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn renderer_masks_editing_value() { + let mut app = KeysApp::new(vec!["web/brave/test".into()]); + app.mode = Mode::EditingValue { + id: "web/brave/test".into(), + }; + app.input = "secret-value".into(); + + let text = line_text(mode_line(&app)); + + assert!(text.contains("web/brave/test")); + assert!(text.contains("••••••••••••")); + assert!(!text.contains("secret-value")); + } }