char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsorcery-ssh-tui: improve colorization395b62a

main
12.0 KiB374 linesraw
1use std::io;
2use std::path::PathBuf;
3use std::sync::mpsc::{self, Receiver, Sender};
4use std::thread;
5use std::time::Duration;
6
7use anyhow::Result;
8use crossterm::cursor::Show;
9use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
10use crossterm::execute;
11use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
12use ratatui::backend::CrosstermBackend;
13use ratatui::layout::{Constraint, Layout, Rect};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
17use ratatui::{Terminal, TerminalOptions, Viewport};
18use unicode_width::UnicodeWidthStr;
19
20use crate::forge::{self, Repo};
21use crate::refresh::Refresh;
22
23struct App {
24    root: PathBuf,
25    repos: Vec<Repo>,
26    selected: usize,
27    mode: Mode,
28    status: Option<String>,
29    title: String,
30    clone_url_base: Option<String>,
31    refresh: Option<Refresh>,
32    refresh_tx: Sender<(String, std::result::Result<(), String>)>,
33    refresh_rx: Receiver<(String, std::result::Result<(), String>)>,
34}
35
36enum Mode {
37    Browse,
38    EditDescription { buffer: String, cursor: usize },
39}
40
41pub fn run(
42    root: PathBuf,
43    title: String,
44    clone_url_base: Option<String>,
45    refresh: Option<Refresh>,
46) -> Result<()> {
47    let (refresh_tx, refresh_rx) = mpsc::channel();
48    let mut app = App {
49        repos: forge::discover(&root)?,
50        root,
51        selected: 0,
52        mode: Mode::Browse,
53        status: None,
54        title,
55        clone_url_base,
56        refresh,
57        refresh_tx,
58        refresh_rx,
59    };
60
61    let previous_hook = std::panic::take_hook();
62    std::panic::set_hook(Box::new(move |info| {
63        restore_terminal();
64        previous_hook(info);
65    }));
66
67    enable_raw_mode()?;
68    let _guard = TerminalGuard;
69    let mut terminal = Terminal::with_options(
70        CrosstermBackend::new(io::stdout()),
71        TerminalOptions {
72            viewport: Viewport::Inline(8),
73        },
74    )?;
75    terminal.clear()?;
76
77    loop {
78        app.apply_refresh_results();
79        terminal.draw(|frame| render(frame, &app))?;
80        if !event::poll(Duration::from_millis(100))? {
81            continue;
82        }
83        let Event::Key(key) = event::read()? else {
84            continue;
85        };
86        if key.kind != KeyEventKind::Press {
87            continue;
88        }
89        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
90            break;
91        }
92        if handle_key(&mut app, key) {
93            break;
94        }
95    }
96
97    Ok(())
98}
99
100fn handle_key(app: &mut App, key: KeyEvent) -> bool {
101    match &mut app.mode {
102        Mode::Browse => match key.code {
103            KeyCode::Char('q') | KeyCode::Esc => return true,
104            KeyCode::Down | KeyCode::Char('j') => {
105                if !app.repos.is_empty() {
106                    app.selected = (app.selected + 1).min(app.repos.len() - 1);
107                }
108            }
109            KeyCode::Up | KeyCode::Char('k') => {
110                app.selected = app.selected.saturating_sub(1);
111            }
112            KeyCode::Home | KeyCode::Char('g') => app.selected = 0,
113            KeyCode::End | KeyCode::Char('G') => {
114                app.selected = app.repos.len().saturating_sub(1);
115            }
116            KeyCode::Enter | KeyCode::Char('e') => {
117                if let Some(repo) = app.repos.get(app.selected) {
118                    let buffer = repo.description.clone().unwrap_or_default();
119                    let cursor = buffer.len();
120                    app.mode = Mode::EditDescription { buffer, cursor };
121                    app.status = None;
122                }
123            }
124            KeyCode::Char('r') => {
125                if let Err(error) = app.reload() {
126                    app.status = Some(format!("reload failed: {error:#}"));
127                }
128            }
129            _ => {}
130        },
131        Mode::EditDescription { buffer, cursor } => {
132            let mut save = false;
133            match key.code {
134                KeyCode::Esc => app.mode = Mode::Browse,
135                KeyCode::Enter => save = true,
136                KeyCode::Left => *cursor = previous_boundary(buffer, *cursor),
137                KeyCode::Right => *cursor = next_boundary(buffer, *cursor),
138                KeyCode::Home => *cursor = 0,
139                KeyCode::End => *cursor = buffer.len(),
140                KeyCode::Backspace => {
141                    let previous = previous_boundary(buffer, *cursor);
142                    buffer.drain(previous..*cursor);
143                    *cursor = previous;
144                }
145                KeyCode::Delete => {
146                    let next = next_boundary(buffer, *cursor);
147                    buffer.drain(*cursor..next);
148                }
149                KeyCode::Char(character)
150                    if !key
151                        .modifiers
152                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
153                {
154                    buffer.insert(*cursor, character);
155                    *cursor += character.len_utf8();
156                }
157                _ => {}
158            }
159            if save && let Err(error) = app.save_description() {
160                app.mode = Mode::Browse;
161                app.status = Some(format!("update failed: {error:#}"));
162            }
163        }
164    }
165    false
166}
167
168impl App {
169    fn reload(&mut self) -> Result<()> {
170        let selected = self.repos.get(self.selected).map(Repo::id);
171        self.repos = forge::discover(&self.root)?;
172        self.selected = selected
173            .and_then(|id| self.repos.iter().position(|repo| repo.id() == id))
174            .unwrap_or(0)
175            .min(self.repos.len().saturating_sub(1));
176        self.status = Some(format!("loaded {} repositories", self.repos.len()));
177        Ok(())
178    }
179
180    fn save_description(&mut self) -> Result<()> {
181        let Mode::EditDescription { buffer, .. } = &self.mode else {
182            return Ok(());
183        };
184        let Some(repo) = self.repos.get(self.selected).cloned() else {
185            self.mode = Mode::Browse;
186            return Ok(());
187        };
188        forge::set_description(&repo, buffer)?;
189        self.mode = Mode::Browse;
190        self.reload()?;
191
192        let id = repo.id();
193        let Some(refresh) = self.refresh.clone() else {
194            self.status = Some(format!("updated {id}"));
195            return Ok(());
196        };
197        let tx = self.refresh_tx.clone();
198        self.status = Some(format!("updated {id}; refreshing…"));
199        drop(thread::spawn(move || {
200            let result = refresh
201                .send(&repo.user, &repo.name)
202                .map_err(|error| format!("{error:#}"));
203            let _ = tx.send((id, result));
204        }));
205        Ok(())
206    }
207
208    fn apply_refresh_results(&mut self) {
209        while let Ok((id, result)) = self.refresh_rx.try_recv() {
210            self.status = Some(match result {
211                Ok(()) => format!("refreshed {id}"),
212                Err(error) => format!("refresh failed for {id}: {error}"),
213            });
214        }
215    }
216
217    fn selected_url(&self) -> Option<String> {
218        let base = self.clone_url_base.as_deref()?.trim_end_matches('/');
219        let repo = self.repos.get(self.selected)?;
220        Some(format!("{base}/{}/{}/", repo.user, repo.name))
221    }
222}
223
224fn render(frame: &mut ratatui::Frame<'_>, app: &App) {
225    let areas = Layout::vertical([
226        Constraint::Length(1),
227        Constraint::Min(1),
228        Constraint::Length(3),
229    ])
230    .split(frame.area());
231
232    let rows = app.repos.iter().map(|repo| {
233        Row::new([
234            Cell::from(repo.id()).style(
235                Style::default()
236                    .fg(Color::LightMagenta)
237                    .add_modifier(Modifier::BOLD),
238            ),
239            Cell::from(repo.head.as_deref().unwrap_or(""))
240                .style(Style::default().fg(Color::LightYellow)),
241            Cell::from(repo.description.as_deref().unwrap_or(""))
242                .style(Style::default().fg(Color::White)),
243        ])
244    });
245    let table = Table::new(
246        rows,
247        [
248            Constraint::Length(16),
249            Constraint::Length(16),
250            Constraint::Min(16),
251        ],
252    )
253    .header(
254        Row::new(["REPOSITORY", "HEAD", "DESCRIPTION"]).style(
255            Style::default()
256                .fg(Color::Black)
257                .bg(Color::LightCyan)
258                .add_modifier(Modifier::BOLD),
259        ),
260    )
261    .column_spacing(2)
262    .highlight_symbol("▸ ");
263    let mut state =
264        TableState::default().with_selected((!app.repos.is_empty()).then_some(app.selected));
265    frame.render_stateful_widget(table, areas[1], &mut state);
266
267    match &app.mode {
268        Mode::Browse => {
269            let (detail, detail_style) = if let Some(status) = &app.status {
270                let color = if status.contains("failed") {
271                    Color::LightRed
272                } else {
273                    Color::LightGreen
274                };
275                (status.clone(), Style::default().fg(color))
276            } else {
277                (
278                    app.selected_url().unwrap_or_default(),
279                    Style::default().fg(Color::Cyan),
280                )
281            };
282            let key = Style::default()
283                .fg(Color::LightYellow)
284                .add_modifier(Modifier::BOLD);
285            let label = Style::default().fg(Color::White);
286            let footer = Paragraph::new(vec![
287                Line::styled(detail, detail_style),
288                Line::from(vec![
289                    Span::styled("↑/k ↓/j", key),
290                    Span::styled(" move  ", label),
291                    Span::styled("Enter/e", key),
292                    Span::styled(" edit  ", label),
293                    Span::styled("r", key),
294                    Span::styled(" reload  ", label),
295                    Span::styled("q", key),
296                    Span::styled(" quit", label),
297                ]),
298            ])
299            .block(
300                Block::default()
301                    .borders(Borders::TOP)
302                    .border_style(Style::default().fg(Color::DarkGray)),
303            );
304            frame.render_widget(footer, areas[2]);
305        }
306        Mode::EditDescription { buffer, cursor } => {
307            render_editor(frame, areas[2], buffer, *cursor);
308        }
309    }
310}
311
312fn render_editor(frame: &mut ratatui::Frame<'_>, area: Rect, buffer: &str, cursor: usize) {
313    let inner_width = area.width.saturating_sub(2).max(1);
314    let column = buffer[..cursor].width() as u16;
315    let scroll = column.saturating_sub(inner_width.saturating_sub(1));
316    let editor = Paragraph::new(buffer)
317        .style(Style::default().fg(Color::White))
318        .scroll((0, scroll))
319        .block(
320            Block::default()
321                .borders(Borders::ALL)
322                .border_style(Style::default().fg(Color::Magenta))
323                .title_style(
324                    Style::default()
325                        .fg(Color::White)
326                        .add_modifier(Modifier::BOLD),
327                )
328                .title("description — Enter save, Esc cancel"),
329        );
330    frame.render_widget(editor, area);
331    frame.set_cursor_position((area.x + 1 + column - scroll, area.y + 1));
332}
333
334fn previous_boundary(text: &str, cursor: usize) -> usize {
335    text[..cursor]
336        .char_indices()
337        .next_back()
338        .map_or(0, |(index, _)| index)
339}
340
341fn next_boundary(text: &str, cursor: usize) -> usize {
342    text[cursor..]
343        .char_indices()
344        .nth(1)
345        .map_or(text.len(), |(index, _)| cursor + index)
346}
347
348struct TerminalGuard;
349
350impl Drop for TerminalGuard {
351    fn drop(&mut self) {
352        restore_terminal();
353    }
354}
355
356fn restore_terminal() {
357    let _ = disable_raw_mode();
358    let _ = execute!(io::stdout(), Show);
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn cursor_moves_across_character_boundaries() {
367        let text = "aø日";
368        assert_eq!(next_boundary(text, 0), 1);
369        assert_eq!(next_boundary(text, 1), 3);
370        assert_eq!(next_boundary(text, 3), text.len());
371        assert_eq!(previous_boundary(text, text.len()), 3);
372        assert_eq!(previous_boundary(text, 3), 1);
373    }
374}