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