fix: align keys UI with TUI viewport

This commit is contained in:
Keisuke Hirata 2026-06-01 11:16:16 +09:00
parent b6a57f75f6
commit 5886f6ad08
No known key found for this signature in database

View File

@ -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<T> = Result<T, Box<dyn std::error::Error>>;
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
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<CrosstermBackend<Stdout>>, store: SecretStore) -> UiResult<()> {
fn make_inline_terminal() -> io::Result<InlineTerminal> {
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<Vec<String>> {
.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<ListItem<'_>> = 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<usize> {
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"));
}
}