diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 138 | ||||
| -rw-r--r-- | src/daemon.rs | 2 |
2 files changed, 128 insertions, 12 deletions
| @@ -10,6 +10,7 @@ use crate::database::Database; | |||
| 10 | use clap::{Command, CommandFactory, Parser, Subcommand}; | 10 | use clap::{Command, CommandFactory, Parser, Subcommand}; |
| 11 | use clap_complete::{Shell, generate}; | 11 | use clap_complete::{Shell, generate}; |
| 12 | use std::path::PathBuf; | 12 | use std::path::PathBuf; |
| 13 | use std::time::Duration; | ||
| 13 | 14 | ||
| 14 | #[derive(Parser)] | 15 | #[derive(Parser)] |
| 15 | #[command(name = "rsp", infer_long_args = true)] | 16 | #[command(name = "rsp", infer_long_args = true)] |
| @@ -261,19 +262,44 @@ impl Cli { | |||
| 261 | unescaped | 262 | unescaped |
| 262 | }; | 263 | }; |
| 263 | 264 | ||
| 264 | let config = match Config::load() { | 265 | // Empty query: synchronously query the DB directly (no timeout thread) |
| 265 | Ok(c) => c, | 266 | // and never fall back to a directory listing, which would be confusing. |
| 266 | Err(_) => return Ok(()), | 267 | if query.is_empty() { |
| 267 | }; | 268 | if let Ok(config) = Config::load() |
| 268 | let db = match Database::open(&config) { | 269 | && let Ok(db) = Database::open(&config) |
| 269 | Ok(d) => d, | 270 | { |
| 270 | Err(_) => return Ok(()), | 271 | let results = db.complete("").unwrap_or_default(); |
| 271 | }; | 272 | for r in &results { |
| 273 | println!("{r}"); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | return Ok(()); | ||
| 277 | } | ||
| 278 | |||
| 279 | // If query looks like a filesystem path, skip DB entirely | ||
| 280 | if looks_like_path(&query) { | ||
| 281 | for c in complete_path(&query) { | ||
| 282 | println!("{c}"); | ||
| 283 | } | ||
| 284 | return Ok(()); | ||
| 285 | } | ||
| 286 | |||
| 287 | // Try DB completion with a timeout; if nothing returned, fall back | ||
| 288 | // to filesystem path completion (e.g. empty library, no match). | ||
| 289 | let db_results = db_complete_with_timeout(&query, Duration::from_millis(100)); | ||
| 272 | 290 | ||
| 273 | let results = db.complete(&query).unwrap_or_default(); | 291 | if let Some(ref results) = db_results |
| 292 | && !results.is_empty() | ||
| 293 | { | ||
| 294 | for r in results { | ||
| 295 | println!("{r}"); | ||
| 296 | } | ||
| 297 | return Ok(()); | ||
| 298 | } | ||
| 274 | 299 | ||
| 275 | for r in &results { | 300 | // Fallback: filesystem path completion |
| 276 | println!("{r}"); | 301 | for c in complete_path(&query) { |
| 302 | println!("{c}"); | ||
| 277 | } | 303 | } |
| 278 | } | 304 | } |
| 279 | Commands::Completions { shell } => { | 305 | Commands::Completions { shell } => { |
| @@ -329,3 +355,93 @@ impl Cli { | |||
| 329 | Ok(()) | 355 | Ok(()) |
| 330 | } | 356 | } |
| 331 | } | 357 | } |
| 358 | |||
| 359 | /// Returns true if the query looks like a filesystem path. | ||
| 360 | fn looks_like_path(query: &str) -> bool { | ||
| 361 | query.starts_with("./") | ||
| 362 | || query.starts_with("../") | ||
| 363 | || query.starts_with("~/") | ||
| 364 | || query.starts_with('/') | ||
| 365 | } | ||
| 366 | |||
| 367 | /// List files/directories matching `query`, formatted as completion candidates. | ||
| 368 | /// | ||
| 369 | /// Directories get a trailing `/` so the shell knows they can be further expanded. | ||
| 370 | fn complete_path(query: &str) -> Vec<String> { | ||
| 371 | if query.is_empty() { | ||
| 372 | return Vec::new(); | ||
| 373 | } | ||
| 374 | |||
| 375 | // Split at the last '/' to separate directory and filename prefix | ||
| 376 | let (dir_part, file_prefix) = match query.rfind('/') { | ||
| 377 | Some(pos) => (&query[..=pos], &query[pos + 1..]), | ||
| 378 | None => ("", query), | ||
| 379 | }; | ||
| 380 | |||
| 381 | // Resolve the directory to read. For ~/… we need the expanded path; | ||
| 382 | // everything else works as-is from the CWD. | ||
| 383 | let read_dir = if let Some(rest) = dir_part.strip_prefix("~/") { | ||
| 384 | if let Some(home) = dirs::home_dir() { | ||
| 385 | if rest.is_empty() { | ||
| 386 | home | ||
| 387 | } else { | ||
| 388 | home.join(rest.trim_end_matches('/')) | ||
| 389 | } | ||
| 390 | } else { | ||
| 391 | PathBuf::from(dir_part) | ||
| 392 | } | ||
| 393 | } else if dir_part.is_empty() { | ||
| 394 | PathBuf::from(".") | ||
| 395 | } else { | ||
| 396 | PathBuf::from(dir_part) | ||
| 397 | }; | ||
| 398 | |||
| 399 | let mut results: Vec<String> = Vec::new(); | ||
| 400 | if let Ok(entries) = std::fs::read_dir(&read_dir) { | ||
| 401 | for entry in entries.flatten() { | ||
| 402 | let name = entry.file_name(); | ||
| 403 | let name_str = name.to_string_lossy().to_string(); | ||
| 404 | |||
| 405 | // Skip hidden entries unless the query explicitly asks for them | ||
| 406 | if name_str.starts_with('.') && !file_prefix.starts_with('.') { | ||
| 407 | continue; | ||
| 408 | } | ||
| 409 | if name_str == "." || name_str == ".." { | ||
| 410 | continue; | ||
| 411 | } | ||
| 412 | |||
| 413 | if !name_str.starts_with(file_prefix) { | ||
| 414 | continue; | ||
| 415 | } | ||
| 416 | |||
| 417 | let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); | ||
| 418 | if is_dir { | ||
| 419 | results.push(format!("{dir_part}{name_str}/")); | ||
| 420 | } else { | ||
| 421 | results.push(format!("{dir_part}{name_str}")); | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | results.sort(); | ||
| 426 | results | ||
| 427 | } | ||
| 428 | |||
| 429 | /// Try the DB completion query in a separate thread with a timeout. | ||
| 430 | /// | ||
| 431 | /// Opens its own database connection so the caller doesn't need to keep one | ||
| 432 | /// alive. Returns `None` on timeout or when the DB can't be opened. | ||
| 433 | fn db_complete_with_timeout(query: &str, timeout: Duration) -> Option<Vec<String>> { | ||
| 434 | let query = query.to_string(); | ||
| 435 | let (tx, rx) = std::sync::mpsc::channel(); | ||
| 436 | |||
| 437 | std::thread::spawn(move || { | ||
| 438 | if let Ok(config) = Config::load() | ||
| 439 | && let Ok(db) = Database::open(&config) | ||
| 440 | { | ||
| 441 | let results = db.complete(&query).unwrap_or_default(); | ||
| 442 | let _ = tx.send(results); | ||
| 443 | } | ||
| 444 | }); | ||
| 445 | |||
| 446 | rx.recv_timeout(timeout).ok() | ||
| 447 | } | ||
diff --git a/src/daemon.rs b/src/daemon.rs index a0de698..1434555 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -550,7 +550,7 @@ fn handle_lyrics_stream(stream: &mut UnixStream, audio: &Arc<AudioEngine>) { | |||
| 550 | if prev_line_count > 0 { | 550 | if prev_line_count > 0 { |
| 551 | frame.push_str(&format!("\x1b[{}A\x1b[1B", prev_line_count)); | 551 | frame.push_str(&format!("\x1b[{}A\x1b[1B", prev_line_count)); |
| 552 | } else { | 552 | } else { |
| 553 | frame.push_str(&format!("\x1b[2K")); | 553 | frame.push_str("\x1b[2K"); |
| 554 | } | 554 | } |
| 555 | frame.push_str(&output); | 555 | frame.push_str(&output); |
| 556 | if stream.write_all(frame.as_bytes()).is_err() { | 556 | if stream.write_all(frame.as_bytes()).is_err() { |
