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