forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somsorcery-ssh: refactor3356bff

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