From 1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 23 Jun 2026 14:52:27 +0200 Subject: Add shell path finding as a fallback. --- AGENTS.md | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- completions/_rsp | 59 +++++++++++------------- src/cli.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++----- src/daemon.rs | 2 +- 6 files changed, 159 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7ce695..76f8e2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Run these before completing any task: ## Task-Specific Guidelines - **CLI changes**: Update clap subcommands (including `rsp scan` and `rsp settings`) and regenerate shell completion scripts +- **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 - **Database changes**: Update SQLite schema and `rsp scan` logic - **Playback changes**: Modify daemon audio engine or queue management code - Audio engine uses cpal for output and symphonia for decoding @@ -50,6 +51,7 @@ Run these before completing any task: - [x] `rsp settings` subcommands (get/set/list/unset) - [x] Shell completions (`rsp completions bash|zsh|fish|powershell|elvish`) - [x] Unit tests for Player, Track, and ShuffleMode +- [x] Smart autocompletion: DB completion with 100ms timeout, filesystem path fallback, path-prefix detection (`./`, `../`, `~/`, `/`) - [x] Desktop notifications for track changes, queue end, and errors ## Remaining Work diff --git a/Cargo.lock b/Cargo.lock index 0372809..43b17e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "RSPlayer" -version = "0.8.5" +version = "0.8.6" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 0b31f88..b64a2ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "RSPlayer" -version = "0.8.5" +version = "0.8.6" edition = "2024" description = "Rust music player daemon with Unix socket IPC" 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() { local cur="${(j: :)args}" # No completion if this is a flag (handled above) [[ "$words[2]" == "play" && "$cur" == -* ]] && return 1 - local cur_clean="${cur//\\/}" + local -a suggestions suggestions=("${(@f)"$(rsp db-complete "$cur" 2>/dev/null)"}") local -a filtered=() @@ -34,39 +34,34 @@ _rsp_complete_args() { filtered+=("$s") fi done - suggestions=("${filtered[@]}") - if (( ${#suggestions} )); then - local noslash="${cur_clean//\/}" - local n_slashes=$(( ${#cur_clean} - ${#noslash} )) - local -a display=() - local -a vals=() - for s in "${suggestions[@]}"; do - vals+=("$s") - if [[ "$cur_clean" == */ ]]; then - local stripped="$s" - local i=0 - while (( i < n_slashes )); do - stripped="${stripped#*/}" - (( i++ )) - done - display+=("$stripped") - else - display+=("$s") - fi - done - if (( ${#suggestions} > 1 )); then - if [[ -n "$compstate[old_list]" ]]; then - compstate[insert]=menu - else - compstate[insert]= - fi - fi - if [[ ${vals[1]} == */ ]]; then - compadd -U -S '' -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" + + if (( ! ${#filtered} )); then + return 1 + fi + + local cur_clean="${cur//\\/}" + local noslash="${cur_clean//\/}" + local n_slashes=$(( ${#cur_clean} - ${#noslash} )) + local -a display=() + local -a vals=() + for s in "${filtered[@]}"; do + vals+=("$s") + if [[ "$cur_clean" == */ ]]; then + local stripped="$s" + local i=0 + while (( i < n_slashes )); do + stripped="${stripped#*/}" + (( i++ )) + done + display+=("$stripped") else - compadd -U -M 'm:{a-z}={A-Z} l:|=*' -d display -- "${vals[@]}" + display+=("$s") fi + done + + if [[ ${vals[1]} == */ ]]; then + compadd -S '' -d display -- "${vals[@]}" else - compadd -U -S '' + compadd -d display -- "${vals[@]}" fi } diff --git a/src/cli.rs b/src/cli.rs index 37b0825..de342e4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,6 +10,7 @@ use crate::database::Database; use clap::{Command, CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; use std::path::PathBuf; +use std::time::Duration; #[derive(Parser)] #[command(name = "rsp", infer_long_args = true)] @@ -261,19 +262,44 @@ impl Cli { unescaped }; - let config = match Config::load() { - Ok(c) => c, - Err(_) => return Ok(()), - }; - let db = match Database::open(&config) { - Ok(d) => d, - Err(_) => return Ok(()), - }; + // Empty query: synchronously query the DB directly (no timeout thread) + // and never fall back to a directory listing, which would be confusing. + if query.is_empty() { + if let Ok(config) = Config::load() + && let Ok(db) = Database::open(&config) + { + let results = db.complete("").unwrap_or_default(); + for r in &results { + println!("{r}"); + } + } + return Ok(()); + } + + // If query looks like a filesystem path, skip DB entirely + if looks_like_path(&query) { + for c in complete_path(&query) { + println!("{c}"); + } + return Ok(()); + } + + // Try DB completion with a timeout; if nothing returned, fall back + // to filesystem path completion (e.g. empty library, no match). + let db_results = db_complete_with_timeout(&query, Duration::from_millis(100)); - let results = db.complete(&query).unwrap_or_default(); + if let Some(ref results) = db_results + && !results.is_empty() + { + for r in results { + println!("{r}"); + } + return Ok(()); + } - for r in &results { - println!("{r}"); + // Fallback: filesystem path completion + for c in complete_path(&query) { + println!("{c}"); } } Commands::Completions { shell } => { @@ -329,3 +355,93 @@ impl Cli { Ok(()) } } + +/// Returns true if the query looks like a filesystem path. +fn looks_like_path(query: &str) -> bool { + query.starts_with("./") + || query.starts_with("../") + || query.starts_with("~/") + || query.starts_with('/') +} + +/// List files/directories matching `query`, formatted as completion candidates. +/// +/// Directories get a trailing `/` so the shell knows they can be further expanded. +fn complete_path(query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + // Split at the last '/' to separate directory and filename prefix + let (dir_part, file_prefix) = match query.rfind('/') { + Some(pos) => (&query[..=pos], &query[pos + 1..]), + None => ("", query), + }; + + // Resolve the directory to read. For ~/… we need the expanded path; + // everything else works as-is from the CWD. + let read_dir = if let Some(rest) = dir_part.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + if rest.is_empty() { + home + } else { + home.join(rest.trim_end_matches('/')) + } + } else { + PathBuf::from(dir_part) + } + } else if dir_part.is_empty() { + PathBuf::from(".") + } else { + PathBuf::from(dir_part) + }; + + let mut results: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&read_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + + // Skip hidden entries unless the query explicitly asks for them + if name_str.starts_with('.') && !file_prefix.starts_with('.') { + continue; + } + if name_str == "." || name_str == ".." { + continue; + } + + if !name_str.starts_with(file_prefix) { + continue; + } + + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + results.push(format!("{dir_part}{name_str}/")); + } else { + results.push(format!("{dir_part}{name_str}")); + } + } + } + results.sort(); + results +} + +/// Try the DB completion query in a separate thread with a timeout. +/// +/// Opens its own database connection so the caller doesn't need to keep one +/// alive. Returns `None` on timeout or when the DB can't be opened. +fn db_complete_with_timeout(query: &str, timeout: Duration) -> Option> { + let query = query.to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + if let Ok(config) = Config::load() + && let Ok(db) = Database::open(&config) + { + let results = db.complete(&query).unwrap_or_default(); + let _ = tx.send(results); + } + }); + + rx.recv_timeout(timeout).ok() +} 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) { if prev_line_count > 0 { frame.push_str(&format!("\x1b[{}A\x1b[1B", prev_line_count)); } else { - frame.push_str(&format!("\x1b[2K")); + frame.push_str("\x1b[2K"); } frame.push_str(&output); if stream.write_all(frame.as_bytes()).is_err() { -- cgit v1.2.3