use std::io; use std::path::PathBuf; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread; use std::time::Duration; use anyhow::Result; use crossterm::cursor::Show; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}; use ratatui::{Terminal, TerminalOptions, Viewport}; use unicode_width::UnicodeWidthStr; use crate::forge::{self, Repo}; use crate::refresh::Refresh; struct App { root: PathBuf, repos: Vec, selected: usize, mode: Mode, status: Option, title: String, clone_url_base: Option, refresh: Option, refresh_tx: Sender<(String, std::result::Result<(), String>)>, refresh_rx: Receiver<(String, std::result::Result<(), String>)>, } enum Mode { Browse, EditDescription { buffer: String, cursor: usize }, } pub fn run( root: PathBuf, title: String, clone_url_base: Option, refresh: Option, ) -> Result<()> { let (refresh_tx, refresh_rx) = mpsc::channel(); let mut app = App { repos: forge::discover(&root)?, root, selected: 0, mode: Mode::Browse, status: None, title, clone_url_base, refresh, refresh_tx, refresh_rx, }; let previous_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal(); previous_hook(info); })); enable_raw_mode()?; let _guard = TerminalGuard; let mut terminal = Terminal::with_options( CrosstermBackend::new(io::stdout()), TerminalOptions { viewport: Viewport::Inline(8), }, )?; terminal.clear()?; loop { app.apply_refresh_results(); terminal.draw(|frame| render(frame, &app))?; if !event::poll(Duration::from_millis(100))? { continue; } let Event::Key(key) = event::read()? else { continue; }; if key.kind != KeyEventKind::Press { continue; } if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { break; } if handle_key(&mut app, key) { break; } } Ok(()) } fn handle_key(app: &mut App, key: KeyEvent) -> bool { match &mut app.mode { Mode::Browse => match key.code { KeyCode::Char('q') | KeyCode::Esc => return true, KeyCode::Down | KeyCode::Char('j') => { if !app.repos.is_empty() { app.selected = (app.selected + 1).min(app.repos.len() - 1); } } KeyCode::Up | KeyCode::Char('k') => { app.selected = app.selected.saturating_sub(1); } KeyCode::Home | KeyCode::Char('g') => app.selected = 0, KeyCode::End | KeyCode::Char('G') => { app.selected = app.repos.len().saturating_sub(1); } KeyCode::Enter | KeyCode::Char('e') => { if let Some(repo) = app.repos.get(app.selected) { let buffer = repo.description.clone().unwrap_or_default(); let cursor = buffer.len(); app.mode = Mode::EditDescription { buffer, cursor }; app.status = None; } } KeyCode::Char('r') => { if let Err(error) = app.reload() { app.status = Some(format!("reload failed: {error:#}")); } } _ => {} }, Mode::EditDescription { buffer, cursor } => { let mut save = false; match key.code { KeyCode::Esc => app.mode = Mode::Browse, KeyCode::Enter => save = true, KeyCode::Left => *cursor = previous_boundary(buffer, *cursor), KeyCode::Right => *cursor = next_boundary(buffer, *cursor), KeyCode::Home => *cursor = 0, KeyCode::End => *cursor = buffer.len(), KeyCode::Backspace => { let previous = previous_boundary(buffer, *cursor); buffer.drain(previous..*cursor); *cursor = previous; } KeyCode::Delete => { let next = next_boundary(buffer, *cursor); buffer.drain(*cursor..next); } KeyCode::Char(character) if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { buffer.insert(*cursor, character); *cursor += character.len_utf8(); } _ => {} } if save && let Err(error) = app.save_description() { app.mode = Mode::Browse; app.status = Some(format!("update failed: {error:#}")); } } } false } impl App { fn reload(&mut self) -> Result<()> { let selected = self.repos.get(self.selected).map(Repo::id); self.repos = forge::discover(&self.root)?; self.selected = selected .and_then(|id| self.repos.iter().position(|repo| repo.id() == id)) .unwrap_or(0) .min(self.repos.len().saturating_sub(1)); self.status = Some(format!("loaded {} repositories", self.repos.len())); Ok(()) } fn save_description(&mut self) -> Result<()> { let Mode::EditDescription { buffer, .. } = &self.mode else { return Ok(()); }; let Some(repo) = self.repos.get(self.selected).cloned() else { self.mode = Mode::Browse; return Ok(()); }; forge::set_description(&repo, buffer)?; self.mode = Mode::Browse; self.reload()?; let id = repo.id(); let Some(refresh) = self.refresh.clone() else { self.status = Some(format!("updated {id}")); return Ok(()); }; let tx = self.refresh_tx.clone(); self.status = Some(format!("updated {id}; refreshing…")); drop(thread::spawn(move || { let result = refresh .send(&repo.user, &repo.name) .map_err(|error| format!("{error:#}")); let _ = tx.send((id, result)); })); Ok(()) } fn apply_refresh_results(&mut self) { while let Ok((id, result)) = self.refresh_rx.try_recv() { self.status = Some(match result { Ok(()) => format!("refreshed {id}"), Err(error) => format!("refresh failed for {id}: {error}"), }); } } fn selected_url(&self) -> Option { let base = self.clone_url_base.as_deref()?.trim_end_matches('/'); let repo = self.repos.get(self.selected)?; Some(format!("{base}/{}/{}/", repo.user, repo.name)) } } fn render(frame: &mut ratatui::Frame<'_>, app: &App) { let areas = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(3), ]) .split(frame.area()); let rows = app.repos.iter().map(|repo| { Row::new([ Cell::from(repo.id()).style( Style::default() .fg(Color::LightMagenta) .add_modifier(Modifier::BOLD), ), Cell::from(repo.head.as_deref().unwrap_or("")) .style(Style::default().fg(Color::LightYellow)), Cell::from(repo.description.as_deref().unwrap_or("")) .style(Style::default().fg(Color::White)), ]) }); let table = Table::new( rows, [ Constraint::Length(16), Constraint::Length(16), Constraint::Min(16), ], ) .header( Row::new(["REPOSITORY", "HEAD", "DESCRIPTION"]).style( Style::default() .fg(Color::Black) .bg(Color::LightCyan) .add_modifier(Modifier::BOLD), ), ) .column_spacing(2) .highlight_symbol("▸ "); let mut state = TableState::default().with_selected((!app.repos.is_empty()).then_some(app.selected)); frame.render_stateful_widget(table, areas[1], &mut state); match &app.mode { Mode::Browse => { let (detail, detail_style) = if let Some(status) = &app.status { let color = if status.contains("failed") { Color::LightRed } else { Color::LightGreen }; (status.clone(), Style::default().fg(color)) } else { ( app.selected_url().unwrap_or_default(), Style::default().fg(Color::Cyan), ) }; let key = Style::default() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD); let label = Style::default().fg(Color::White); let footer = Paragraph::new(vec![ Line::styled(detail, detail_style), Line::from(vec![ Span::styled("↑/k ↓/j", key), Span::styled(" move ", label), Span::styled("Enter/e", key), Span::styled(" edit ", label), Span::styled("r", key), Span::styled(" reload ", label), Span::styled("q", key), Span::styled(" quit", label), ]), ]) .block( Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray)), ); frame.render_widget(footer, areas[2]); } Mode::EditDescription { buffer, cursor } => { render_editor(frame, areas[2], buffer, *cursor); } } } fn render_editor(frame: &mut ratatui::Frame<'_>, area: Rect, buffer: &str, cursor: usize) { let inner_width = area.width.saturating_sub(2).max(1); let column = buffer[..cursor].width() as u16; let scroll = column.saturating_sub(inner_width.saturating_sub(1)); let editor = Paragraph::new(buffer) .style(Style::default().fg(Color::White)) .scroll((0, scroll)) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Magenta)) .title_style( Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), ) .title("description — Enter save, Esc cancel"), ); frame.render_widget(editor, area); frame.set_cursor_position((area.x + 1 + column - scroll, area.y + 1)); } fn previous_boundary(text: &str, cursor: usize) -> usize { text[..cursor] .char_indices() .next_back() .map_or(0, |(index, _)| index) } fn next_boundary(text: &str, cursor: usize) -> usize { text[cursor..] .char_indices() .nth(1) .map_or(text.len(), |(index, _)| cursor + index) } struct TerminalGuard; impl Drop for TerminalGuard { fn drop(&mut self) { restore_terminal(); } } fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), Show); } #[cfg(test)] mod tests { use super::*; #[test] fn cursor_moves_across_character_boundaries() { let text = "aø日"; assert_eq!(next_boundary(text, 0), 1); assert_eq!(next_boundary(text, 1), 3); assert_eq!(next_boundary(text, 3), text.len()); assert_eq!(previous_boundary(text, text.len()), 3); assert_eq!(previous_boundary(text, 3), 1); } }