refactor(watch): rename -f/config flag to -p/preset, default watch dir to src/
This commit is contained in:
@@ -115,9 +115,9 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("watch") => {
|
Some("watch") => {
|
||||||
let dir = env::args().nth(2).unwrap_or_else(|| ".".to_string());
|
let dir = env::args().nth(2).unwrap_or_else(|| "src/".to_string());
|
||||||
let config = parse_flag("-f");
|
let preset = parse_flag("-p");
|
||||||
if let Err(e) = aishell::watch::run(&dir, config.as_deref()) {
|
if let Err(e) = aishell::watch::run(&dir, preset.as_deref()) {
|
||||||
eprintln!("aishell: {}", e);
|
eprintln!("aishell: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ fn print_help() {
|
|||||||
println!(" aishell history [id] Show past sessions");
|
println!(" aishell history [id] Show past sessions");
|
||||||
println!(" aishell history cmd [grep] Command history (filterable)");
|
println!(" aishell history cmd [grep] Command history (filterable)");
|
||||||
println!(" aishell history clean Remove old sessions (keep 10)");
|
println!(" aishell history clean Remove old sessions (keep 10)");
|
||||||
println!(" aishell watch <dir> [-f cfg] Watch files, auto-trigger loop");
|
println!(" aishell watch [dir] [-p preset] Watch files → run preset on change");
|
||||||
println!(" aishell next Resume loop");
|
println!(" aishell next Resume loop");
|
||||||
println!(" aishell stop Stop the loop");
|
println!(" aishell stop Stop the loop");
|
||||||
println!(" aishell help This help");
|
println!(" aishell help This help");
|
||||||
|
|||||||
85
src/watch.rs
85
src/watch.rs
@@ -3,65 +3,59 @@ use std::sync::mpsc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use notify::{Watcher, RecursiveMode, Event, EventKind};
|
use notify::{Watcher, RecursiveMode, Event, EventKind};
|
||||||
|
|
||||||
const STATE_DIR: &str = "/tmp/aishell";
|
|
||||||
const DEBOUNCE_MS: u64 = 2000;
|
const DEBOUNCE_MS: u64 = 2000;
|
||||||
|
|
||||||
/// Watch a directory for file changes.
|
/// Watch directory, run preset on change.
|
||||||
/// On change, write next=true to loop.json with changed file list.
|
/// `aishell watch src/ -p review` → file change → review → auto-commit
|
||||||
pub fn run(dir: &str, config: Option<&str>) -> Result<(), String> {
|
pub fn run(dir: &str, preset: Option<&str>) -> Result<(), String> {
|
||||||
let dir = crate::config::expand_tilde(dir);
|
let dir = crate::config::expand_tilde(dir);
|
||||||
let dir_path = Path::new(&dir);
|
let dir_path = Path::new(&dir);
|
||||||
if !dir_path.is_dir() {
|
if !dir_path.is_dir() {
|
||||||
return Err(format!("not a directory: {dir}"));
|
return Err(format!("not a directory: {dir}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start headless run in background if config provided
|
let preset = preset.unwrap_or("review");
|
||||||
if let Some(cfg) = config {
|
eprintln!("watching: {dir} (preset: {preset})");
|
||||||
let cfg = cfg.to_string();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = crate::headless::run(&cfg, None, None);
|
|
||||||
});
|
|
||||||
// Give it time to start
|
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("watching: {dir}");
|
|
||||||
eprintln!("Ctrl+C to stop.\n");
|
eprintln!("Ctrl+C to stop.\n");
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Event>();
|
let (tx, rx) = mpsc::channel::<Event>();
|
||||||
|
|
||||||
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
||||||
if let Ok(event) = res {
|
if let Ok(event) = res { let _ = tx.send(event); }
|
||||||
let _ = tx.send(event);
|
|
||||||
}
|
|
||||||
}).map_err(|e| format!("watcher error: {e}"))?;
|
}).map_err(|e| format!("watcher error: {e}"))?;
|
||||||
|
|
||||||
watcher.watch(dir_path, RecursiveMode::Recursive)
|
watcher.watch(dir_path, RecursiveMode::Recursive)
|
||||||
.map_err(|e| format!("watch error: {e}"))?;
|
.map_err(|e| format!("watch error: {e}"))?;
|
||||||
|
|
||||||
let mut last_trigger = Instant::now();
|
let mut last_trigger = Instant::now();
|
||||||
let mut changed_files: Vec<String> = Vec::new();
|
let mut changed: Vec<String> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(Duration::from_millis(500)) {
|
match rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if !is_relevant(&event) { continue; }
|
if !is_relevant(&event) { continue; }
|
||||||
|
for p in &event.paths {
|
||||||
for path in &event.paths {
|
let s = p.display().to_string();
|
||||||
let p = path.display().to_string();
|
if !changed.contains(&s) { changed.push(s); }
|
||||||
if !changed_files.contains(&p) {
|
|
||||||
changed_files.push(p);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
last_trigger = Instant::now();
|
last_trigger = Instant::now();
|
||||||
}
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
// Debounce: if we have changes and enough time passed
|
if !changed.is_empty() && last_trigger.elapsed() > Duration::from_millis(DEBOUNCE_MS) {
|
||||||
if !changed_files.is_empty()
|
let short: Vec<&str> = changed.iter()
|
||||||
&& last_trigger.elapsed() > Duration::from_millis(DEBOUNCE_MS)
|
.map(|f| f.rsplit('/').next().unwrap_or(f))
|
||||||
{
|
.take(5).collect();
|
||||||
trigger_cycle(&changed_files);
|
eprintln!(" changed: {}", short.join(", "));
|
||||||
changed_files.clear();
|
|
||||||
|
// Run preset
|
||||||
|
if preset == "review" {
|
||||||
|
crate::headless::review();
|
||||||
|
} else {
|
||||||
|
let _ = crate::headless::run_preset(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
changed.clear();
|
||||||
|
eprintln!("\n watching...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -76,35 +70,6 @@ fn is_relevant(event: &Event) -> bool {
|
|||||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||||
) && event.paths.iter().any(|p| {
|
) && event.paths.iter().any(|p| {
|
||||||
let s = p.display().to_string();
|
let s = p.display().to_string();
|
||||||
// Ignore hidden files, target/, /tmp/
|
|
||||||
!s.contains("/.") && !s.contains("/target/") && !s.contains("/tmp/")
|
!s.contains("/.") && !s.contains("/target/") && !s.contains("/tmp/")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trigger_cycle(files: &[String]) {
|
|
||||||
let short: Vec<&str> = files.iter()
|
|
||||||
.map(|f| f.rsplit('/').next().unwrap_or(f))
|
|
||||||
.take(5)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
eprintln!(" changed: {}", short.join(", "));
|
|
||||||
|
|
||||||
// Write to loop.json to signal the running headless loop
|
|
||||||
let loop_path = format!("{STATE_DIR}/loop.json");
|
|
||||||
if Path::new(&loop_path).exists() {
|
|
||||||
let control = serde_json::json!({
|
|
||||||
"ready": true,
|
|
||||||
"next": true,
|
|
||||||
"quit": false,
|
|
||||||
"changed_files": files,
|
|
||||||
});
|
|
||||||
let json = serde_json::to_string_pretty(&control).unwrap_or_default();
|
|
||||||
// atomic write
|
|
||||||
let tmp = format!("{loop_path}.tmp");
|
|
||||||
if std::fs::write(&tmp, &json).is_ok() {
|
|
||||||
let _ = std::fs::rename(&tmp, &loop_path);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!(" (no active loop to signal)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user