@@ -3,29 +3,19 @@ use anyhow::{bail, Context, Result};
33use directories:: UserDirs ;
44use fuzzy_matcher:: skim:: SkimMatcherV2 ;
55use fuzzy_matcher:: FuzzyMatcher ;
6- use hex:: encode;
76use libsql:: Value ;
8- use log:: warn;
97use once_cell:: sync:: Lazy ;
108use ratatui:: layout:: Rect ;
11- use sha2:: { Digest , Sha256 } ;
129use std:: cmp:: Ordering ;
1310use std:: fs:: File ;
1411use std:: io:: Read ;
1512use std:: path:: { Path , PathBuf } ;
16- use std:: process:: { self , Command , Stdio } ;
17- use std:: sync:: mpsc:: Sender ;
18- use std:: thread;
19- use std:: time:: { SystemTime , UNIX_EPOCH } ;
2013
2114use crate :: model:: {
2215 AliasModel , EnsembleBuilder , FreqModel , PrefixModel , SqlitePool , SuggestModel , Suggestion ,
2316} ;
2417
2518static MATCHER : Lazy < SkimMatcherV2 > = Lazy :: new ( SkimMatcherV2 :: default) ;
26- const SOURCE_TUI : & str = "tui" ;
27- const MAX_OUTPUT_LEN : usize = 16 * 1024 ;
28- const TRUNC_SUFFIX : & str = "\n …[truncated]" ;
2919/// Run the fuzzy search over one or more history files
3020pub fn run_search ( files : Vec < PathBuf > , query : & str , top : usize , unique : bool ) -> Result < ( ) > {
3121 if files. is_empty ( ) {
@@ -74,7 +64,6 @@ pub enum Tab {
7464#[ derive( Clone ) ]
7565pub struct HistoryEntry {
7666 pub cmd : String ,
77- pub exit_code : Option < i32 > ,
7867 pub output_lines : Vec < String > ,
7968}
8069
@@ -108,9 +97,6 @@ pub struct App {
10897
10998 // corpus
11099 pub corpus : Vec < String > ,
111-
112- db : Option < SqlitePool > ,
113- session_id : String ,
114100}
115101
116102impl App {
@@ -141,8 +127,6 @@ impl App {
141127 output_scroll : 0 ,
142128 history_scroll : 0 ,
143129 corpus,
144- db,
145- session_id : Self :: generate_session_id ( ) ,
146130 }
147131 }
148132
@@ -168,27 +152,6 @@ impl App {
168152 self . selected = self . selected . min ( self . suggestions . len ( ) . saturating_sub ( 1 ) ) ;
169153 }
170154
171- fn generate_session_id ( ) -> String {
172- let ts = SystemTime :: now ( )
173- . duration_since ( UNIX_EPOCH )
174- . unwrap_or_default ( )
175- . as_millis ( ) ;
176- format ! ( "tui-{}-{ts}" , process:: id( ) )
177- }
178-
179- pub ( crate ) fn persist_last_run ( & self , exit_code : i32 ) {
180- let Some ( pool) = self . db . as_ref ( ) else {
181- return ;
182- } ;
183- let Some ( cmd) = self . last_run_cmd . as_ref ( ) else {
184- return ;
185- } ;
186- if let Err ( err) =
187- persist_history_entry ( pool, cmd, & self . output_lines , exit_code, & self . session_id )
188- {
189- warn ! ( "failed to persist history entry: {err:?}" ) ;
190- }
191- }
192155}
193156
194157pub fn load_history_lines ( files : Vec < PathBuf > , unique : bool ) -> Result < Vec < String > > {
@@ -222,44 +185,6 @@ pub fn load_history_lines(files: Vec<PathBuf>, unique: bool) -> Result<Vec<Strin
222185 Ok ( lines)
223186}
224187
225- pub enum ExecMsg {
226- Line ( String ) ,
227- Done ( i32 ) ,
228- }
229-
230- pub fn spawn_command ( cmdline : String , tx : Sender < ExecMsg > ) {
231- thread:: spawn ( move || {
232- let mut child = match Command :: new ( "/bin/sh" )
233- . arg ( "-lc" )
234- . arg ( & cmdline)
235- . stdout ( Stdio :: piped ( ) )
236- . stderr ( Stdio :: piped ( ) )
237- . spawn ( )
238- {
239- Ok ( c) => c,
240- Err ( e) => {
241- let _ = tx. send ( ExecMsg :: Line ( format ! ( "spawn error: {e}" ) ) ) ;
242- let _ = tx. send ( ExecMsg :: Done ( 127 ) ) ;
243- return ;
244- }
245- } ;
246-
247- let stdout = child. stdout . take ( ) ;
248- let stderr = child. stderr . take ( ) ;
249-
250- let tx1 = tx. clone ( ) ;
251- let t_out = thread:: spawn ( move || stream_reader ( stdout, tx1) ) ;
252- let tx2 = tx. clone ( ) ;
253- let t_err = thread:: spawn ( move || stream_reader ( stderr, tx2) ) ;
254-
255- let status = child. wait ( ) . unwrap_or_default ( ) ;
256- let _ = t_out. join ( ) ;
257- let _ = t_err. join ( ) ;
258- let code = status. code ( ) . unwrap_or ( -1 ) ;
259- let _ = tx. send ( ExecMsg :: Done ( code) ) ;
260- } ) ;
261- }
262-
263188pub fn read_history_file ( path : & Path ) -> Result < Vec < String > > {
264189 let mut file = File :: open ( path) . with_context ( || format ! ( "opening {path:?}" ) ) ?;
265190 let mut buf = Vec :: new ( ) ;
@@ -270,61 +195,6 @@ pub fn read_history_file(path: &Path) -> Result<Vec<String>> {
270195 . collect ( ) )
271196}
272197
273- fn stream_reader < R : Read + Send + ' static > ( mut reader : Option < R > , tx : Sender < ExecMsg > ) {
274- use std:: io:: { BufRead , BufReader } ;
275- if let Some ( r) = reader. take ( ) {
276- let br = BufReader :: new ( r) ;
277- for line in br. lines ( ) {
278- match line {
279- Ok ( l) => {
280- let _ = tx. send ( ExecMsg :: Line ( l) ) ;
281- }
282- Err ( e) => {
283- let _ = tx. send ( ExecMsg :: Line ( format ! ( "read error: {e}" ) ) ) ;
284- break ;
285- }
286- }
287- }
288- }
289- }
290-
291- fn persist_history_entry (
292- pool : & SqlitePool ,
293- command : & str ,
294- output_lines : & [ String ] ,
295- exit_code : i32 ,
296- session_id : & str ,
297- ) -> Result < ( ) > {
298- let trimmed = command. trim ( ) ;
299- if trimmed. is_empty ( ) {
300- return Ok ( ( ) ) ;
301- }
302-
303- let hash = hash_command ( trimmed) ;
304- let output = truncate_output ( & format_output ( output_lines, exit_code) ) ;
305-
306- pool. execute (
307- r#"
308- INSERT INTO history (command, hash, count, source, session_id, output)
309- VALUES (?1, ?2, 1, ?3, ?4, ?5)
310- ON CONFLICT(hash) DO UPDATE SET
311- count = count + 1,
312- source = excluded.source,
313- session_id = excluded.session_id,
314- output = excluded.output;
315- "# ,
316- vec ! [
317- Value :: Text ( trimmed. to_string( ) ) ,
318- Value :: Text ( hash) ,
319- Value :: Text ( SOURCE_TUI . to_string( ) ) ,
320- Value :: Text ( session_id. to_string( ) ) ,
321- Value :: Text ( output) ,
322- ] ,
323- ) ?;
324-
325- Ok ( ( ) )
326- }
327-
328198fn load_recent_history ( pool : & SqlitePool , limit : usize ) -> Result < Vec < HistoryEntry > > {
329199 pool. query_collect (
330200 "SELECT command, output FROM history ORDER BY id DESC LIMIT ?1" ,
@@ -333,159 +203,19 @@ fn load_recent_history(pool: &SqlitePool, limit: usize) -> Result<Vec<HistoryEnt
333203 let command: String = row. get ( 0 ) ?;
334204 let output_str: String = row. get ( 1 ) . unwrap_or_default ( ) ;
335205
336- // Parse output to extract lines and exit code
337- let mut output_lines = Vec :: new ( ) ;
338- let mut exit_code = None ;
339-
340- for line in output_str. lines ( ) {
341- if line. starts_with ( "(exit code: " ) && line. ends_with ( ")" ) {
342- // Extract exit code from the last line
343- if let Some ( code_str) = line. strip_prefix ( "(exit code: " ) . and_then ( |s| s. strip_suffix ( ")" ) ) {
344- exit_code = code_str. parse :: < i32 > ( ) . ok ( ) ;
345- }
346- } else {
347- output_lines. push ( line. to_string ( ) ) ;
348- }
349- }
206+ let output_lines: Vec < String > = output_str
207+ . lines ( )
208+ . map ( |s| s. to_string ( ) )
209+ . collect ( ) ;
350210
351211 Ok ( HistoryEntry {
352212 cmd : command,
353- exit_code,
354213 output_lines,
355214 } )
356215 } ,
357216 )
358217}
359218
360- fn hash_command ( command : & str ) -> String {
361- let mut hasher = Sha256 :: new ( ) ;
362- hasher. update ( command. as_bytes ( ) ) ;
363- encode ( hasher. finalize ( ) )
364- }
365-
366- fn format_output ( output_lines : & [ String ] , exit_code : i32 ) -> String {
367- let mut buf = output_lines. join ( "\n " ) ;
368- if !buf. is_empty ( ) {
369- buf. push ( '\n' ) ;
370- }
371- buf. push_str ( & format ! ( "(exit code: {exit_code})" ) ) ;
372- buf
373- }
374-
375- fn truncate_output ( output : & str ) -> String {
376- if output. len ( ) <= MAX_OUTPUT_LEN {
377- return output. to_string ( ) ;
378- }
379-
380- let limit = MAX_OUTPUT_LEN . saturating_sub ( TRUNC_SUFFIX . len ( ) ) ;
381- if limit == 0 {
382- return TRUNC_SUFFIX . to_string ( ) ;
383- }
384-
385- let mut end = limit;
386- while end > 0 && !output. is_char_boundary ( end) {
387- end -= 1 ;
388- }
389-
390- if end == 0 {
391- return TRUNC_SUFFIX . to_string ( ) ;
392- }
393-
394- let mut truncated = output[ ..end] . to_string ( ) ;
395- truncated. push_str ( TRUNC_SUFFIX ) ;
396- truncated
397- }
398-
399- #[ cfg( test) ]
400- mod tests {
401- use super :: * ;
402- use libsql:: Value ;
403-
404- fn setup_history_table ( pool : & SqlitePool ) {
405- pool. execute (
406- "CREATE TABLE history (
407- id INTEGER PRIMARY KEY,
408- command TEXT NOT NULL,
409- hash TEXT NOT NULL UNIQUE,
410- count INTEGER NOT NULL DEFAULT 1,
411- source TEXT,
412- session_id TEXT,
413- output TEXT
414- );" ,
415- Vec :: < Value > :: new ( ) ,
416- )
417- . unwrap ( ) ;
418- }
419-
420- #[ test]
421- fn persist_history_inserts_and_updates ( ) {
422- let pool = SqlitePool :: open_memory ( ) . unwrap ( ) ;
423- setup_history_table ( & pool) ;
424-
425- let lines = vec ! [ "hello" . to_string( ) ] ;
426- persist_history_entry ( & pool, "echo hello" , & lines, 0 , "session-1" ) . unwrap ( ) ;
427-
428- let rows = pool
429- . query_collect (
430- "SELECT command, count, output FROM history" ,
431- Vec :: < Value > :: new ( ) ,
432- |row| {
433- let command: String = row. get ( 0 ) ?;
434- let count: i64 = row. get ( 1 ) ?;
435- let output: String = row. get ( 2 ) ?;
436- Ok ( ( command, count, output) )
437- } ,
438- )
439- . unwrap ( ) ;
440- assert_eq ! ( rows. len( ) , 1 ) ;
441- let ( command, count, output) = & rows[ 0 ] ;
442- assert_eq ! ( command, "echo hello" ) ;
443- assert_eq ! ( * count, 1 ) ;
444- assert ! ( output. contains( "hello" ) ) ;
445- assert ! ( output. contains( "(exit code: 0)" ) ) ;
446-
447- let lines2 = vec ! [ "bye" . to_string( ) ] ;
448- persist_history_entry ( & pool, "echo hello" , & lines2, 1 , "session-1" ) . unwrap ( ) ;
449-
450- let rows = pool
451- . query_collect (
452- "SELECT count, output FROM history" ,
453- Vec :: < Value > :: new ( ) ,
454- |row| {
455- let count: i64 = row. get ( 0 ) ?;
456- let output: String = row. get ( 1 ) ?;
457- Ok ( ( count, output) )
458- } ,
459- )
460- . unwrap ( ) ;
461- let ( count, output) = & rows[ 0 ] ;
462- assert_eq ! ( * count, 2 ) ;
463- assert ! ( output. contains( "bye" ) ) ;
464- assert ! ( output. contains( "(exit code: 1)" ) ) ;
465- assert ! ( !output. contains( "hello" ) ) ;
466- }
467-
468- #[ test]
469- fn truncate_output_limits_size ( ) {
470- let pool = SqlitePool :: open_memory ( ) . unwrap ( ) ;
471- setup_history_table ( & pool) ;
472-
473- let long_line = "x" . repeat ( MAX_OUTPUT_LEN + 100 ) ;
474- let lines = vec ! [ long_line] ;
475- persist_history_entry ( & pool, "echo long" , & lines, 0 , "session-2" ) . unwrap ( ) ;
476-
477- let rows = pool
478- . query_collect ( "SELECT output FROM history" , Vec :: < Value > :: new ( ) , |row| {
479- let output: String = row. get ( 0 ) ?;
480- Ok ( output)
481- } )
482- . unwrap ( ) ;
483- let output = rows. first ( ) . unwrap ( ) ;
484- assert ! ( output. len( ) <= MAX_OUTPUT_LEN ) ;
485- assert ! ( output. contains( "…[truncated]" ) ) ;
486- }
487- }
488-
489219#[ derive( Debug ) ]
490220struct FuzzyHistoryModel {
491221 corpus : Vec < String > ,
0 commit comments