diff options
| author | salmonik <raisersoft@proton.me> | 2026-06-23 14:52:27 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-06-23 14:52:27 +0200 |
| commit | 1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd (patch) | |
| tree | 575e28dc3da69ec8c1623b2bad542ec4a0c0f0ea | |
| parent | last autocompletion fixes (diff) | |
| download | rsplayer-1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd.tar.gz rsplayer-1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd.zip | |
Add shell path finding as a fallback.
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | Cargo.lock | 2 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | completions/_rsp | 59 | ||||
| -rw-r--r-- | src/cli.rs | 138 | ||||
| -rw-r--r-- | src/daemon.rs | 2 |
6 files changed, 159 insertions, 46 deletions
| @@ -24,6 +24,7 @@ Run these before completing any task: | |||
| 24 | 24 | ||
| 25 | ## Task-Specific Guidelines | 25 | ## Task-Specific Guidelines |
| 26 | - **CLI changes**: Update clap subcommands (including `rsp scan` and `rsp settings`) and regenerate shell completion scripts | 26 | - **CLI changes**: Update clap subcommands (including `rsp scan` and `rsp settings`) and regenerate shell completion scripts |
| 27 | - **Completion changes**: `rsp db-complete` has a two-tier strategy — if the query looks like a path (`./`, `../`, `~/`, or `/`), it lists filesystem entries directly; otherwise it queries the DB in a thread with a 100ms timeout and falls back to filesystem completion on timeout or no match. The zsh script (`completions/_rsp`) is manually edited (not clap-generated) and concatenated after the clap output during install | ||
| 27 | - **Database changes**: Update SQLite schema and `rsp scan` logic | 28 | - **Database changes**: Update SQLite schema and `rsp scan` logic |
| 28 | - **Playback changes**: Modify daemon audio engine or queue management code | 29 | - **Playback changes**: Modify daemon audio engine or queue management code |
| 29 | - Audio engine uses cpal for output and symphonia for decoding | 30 | - Audio engine uses cpal for output and symphonia for decoding |
| @@ -50,6 +51,7 @@ Run these before completing any task: | |||
| 50 | - [x] `rsp settings` subcommands (get/set/list/unset) | 51 | - [x] `rsp settings` subcommands (get/set/list/unset) |
| 51 | - [x] Shell completions (`rsp completions bash|zsh|fish|powershell|elvish`) | 52 | - [x] Shell completions (`rsp completions bash|zsh|fish|powershell|elvish`) |
| 52 | - [x] Unit tests for Player, Track, and ShuffleMode | 53 | - [x] Unit tests for Player, Track, and ShuffleMode |
| 54 | - [x] Smart autocompletion: DB completion with 100ms timeout, filesystem path fallback, path-prefix detection (`./`, `../`, `~/`, `/`) | ||
| 53 | - [x] Desktop notifications for track changes, queue end, and errors | 55 | - [x] Desktop notifications for track changes, queue end, and errors |
| 54 | 56 | ||
| 55 | ## Remaining Work | 57 | ## Remaining Work |
| @@ -4,7 +4,7 @@ version = 4 | |||
| 4 | 4 | ||
| 5 | [[package]] | 5 | [[package]] |
| 6 | name = "RSPlayer" | 6 | name = "RSPlayer" |
| 7 | version = "0.8.5" | 7 | version = "0.8.6" |
| 8 | dependencies = [ | 8 | dependencies = [ |
| 9 | "clap", | 9 | "clap", |
| 10 | "clap_complete", | 10 | "clap_complete", |
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "RSPlayer" | 2 | name = "RSPlayer" |
| 3 | version = "0.8.5" | 3 | version = "0.8.6" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | description = "Rust music player daemon with Unix socket IPC" | 5 | description = "Rust music player daemon with Unix socket IPC" |
| 6 | license = "GPL-3.0 or later" | 6 | license = "GPL-3.0 or later" |
diff --git a/completions/_rsp b/completions/_rsp index 7ac2323..ade1850 100644 --- a/completions/_rsp +++ b/completions/_rsp | |||
| @@ -25,7 +25,7 @@ _rsp_complete_args() { | |||
| 25 | local cur="${(j: :)args}" | 25 | local cur="${(j: :)args}" |
| 26 | # No completion if this is a flag (handled above) | 26 | # No completion if this is a flag (handled above) |
| 27 | [[ "$words[2]" == "play" && "$cur" == -* ]] && return 1 | 27 | [[ "$words[2]" == "play" && "$cur" == -* ]] && return 1 |
| 28 | local cur_clean="${cur//\\/}" | 28 | |
| 29 | local -a suggestions | 29 | local -a suggestions |
| 30 | suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}") | 30 | suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}") |
| 31 | local -a filtered=() | 31 | local -a filtered=() |
| @@ -34,39 +34,34 @@ _rsp_complete_args() { | |||
| 34 | filtered+=("$s") | 34 | filtered+=("$s") |
| 35 | fi | 35 | fi |
| 36 | done | 36 | done |
| 37 | suggestions=("${filtered[@]}") | 37 | |
| 38 | if (( ${#suggestions} )); then | 38 | if (( ! ${#filtered} )); then |
| 39 | local noslash="${cur_clean//\/}" | 39 | return 1 |
| 40 | local n_slashes=$(( ${#cur_clean} - ${#noslash} )) | 40 | fi |
| 41 | local -a display=() | 41 | |
| 42 | local -a vals=() | 42 | local cur_clean="${cur//\\/}" |
| 43 | for s in "${suggestions[@]}"; do | 43 | local noslash="${cur_clean//\/}" |
| 44 | vals+=("$s") | 44 | local n_slashes=$(( ${#cur_clean} - ${#noslash} )) |
| 45 | if [[ "$cur_clean" == */ ]]; then | 45 | local -a display=() |
| 46 | local stripped="$s" | 46 | local -a vals=() |
| 47 | local i=0 | 47 | for s in "${filtered[@]}"; do |
| 48 | while (( i < n_slashes )); do | 48 | vals+=("$s") |
| 49 | stripped="${stripped#*/}" | 49 | if [[ "$cur_clean" == */ ]]; then |
| 50 | (( i++ )) | 50 | local stripped="$s" |
| 51 | done | 51 | local i=0 |
| 52 | display+=("$stripped") | 52 | while (( i < n_slashes )); do |
| 53 | else | 53 | stripped="${stripped#*/}" |
| 54 | display+=("$s") | 54 | (( i++ )) |
| 55 | fi | 55 | done |
| 56 | done | 56 | display+=("$stripped") |
| 57 | if (( ${#suggestions} > 1 )); then | ||
| 58 | if [[ -n "$compstate[old_list]" ]]; then | ||
| 59 | compstate[insert]=menu | ||
| 60 | else | ||
| 61 | compstate[insert]= | ||
| 62 | fi | ||
| 63 | fi | ||
| 64 | if [[ ${vals[1]} == */ ]]; then | ||
| 65 | compadd -U -S '' -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" | ||
| 66 | else | 57 | else |
| 67 | compadd -U -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" | 58 | display+=("$s") |
| 68 | fi | 59 | fi |
| 60 | done | ||
| 61 | |||
| 62 | if [[ ${vals[1]} == */ ]]; then | ||
| 63 | compadd -S '' -d display -- "${vals[@]}" | ||
| 69 | else | 64 | else |
| 70 | compadd -U -S '' | 65 | compadd -d display -- "${vals[@]}" |
| 71 | fi | 66 | fi |
| 72 | } | 67 | } |
| @@ -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() { |
