Skip to content

Commit fa7e1d6

Browse files
committed
feat(tui): refactor TUI command execution and add loop for continuous interaction
1 parent 35ddd87 commit fa7e1d6

File tree

4 files changed

+91
-326
lines changed

4 files changed

+91
-326
lines changed

rust/src/core.rs

Lines changed: 4 additions & 274 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,19 @@ use anyhow::{bail, Context, Result};
33
use directories::UserDirs;
44
use fuzzy_matcher::skim::SkimMatcherV2;
55
use fuzzy_matcher::FuzzyMatcher;
6-
use hex::encode;
76
use libsql::Value;
8-
use log::warn;
97
use once_cell::sync::Lazy;
108
use ratatui::layout::Rect;
11-
use sha2::{Digest, Sha256};
129
use std::cmp::Ordering;
1310
use std::fs::File;
1411
use std::io::Read;
1512
use 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

2114
use crate::model::{
2215
AliasModel, EnsembleBuilder, FreqModel, PrefixModel, SqlitePool, SuggestModel, Suggestion,
2316
};
2417

2518
static 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
3020
pub 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)]
7565
pub 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

116102
impl 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

194157
pub 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-
263188
pub 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-
328198
fn 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)]
490220
struct FuzzyHistoryModel {
491221
corpus: Vec<String>,

rust/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ fn main() -> Result<()> {
5151
let _ = env_logger::Builder::from_env(Env::default().default_filter_or("info")).try_init();
5252
let cli = Cli::parse();
5353
match cli.cmd {
54-
Some(Cmd::Tui { files, top, unique }) => tui::run_tui(files, top, unique),
54+
Some(Cmd::Tui { files, top, unique }) => tui::run_tui_loop(files, top, unique),
5555
Some(Cmd::Search {
5656
files,
5757
query,

rust/src/model/sqlite.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ impl SqlitePool {
107107
Ok(out)
108108
}
109109

110+
#[cfg_attr(not(test), allow(dead_code))]
110111
pub fn execute<I>(&self, sql: &str, params: I) -> Result<()>
111112
where
112113
I: IntoIterator<Item = Value>,

0 commit comments

Comments
 (0)