aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-06-23 14:52:27 +0200
committersalmonik <raisersoft@proton.me>2026-06-23 14:52:27 +0200
commit1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd (patch)
tree575e28dc3da69ec8c1623b2bad542ec4a0c0f0ea
parentlast autocompletion fixes (diff)
downloadrsplayer-1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd.tar.gz
rsplayer-1fd56ff980dd80a24a3d2c90b692dc7c9f346bdd.zip
Add shell path finding as a fallback.
-rw-r--r--AGENTS.md2
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--completions/_rsp59
-rw-r--r--src/cli.rs138
-rw-r--r--src/daemon.rs2
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:
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
diff --git a/Cargo.lock b/Cargo.lock
index 0372809..43b17e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
4 4
5[[package]] 5[[package]]
6name = "RSPlayer" 6name = "RSPlayer"
7version = "0.8.5" 7version = "0.8.6"
8dependencies = [ 8dependencies = [
9 "clap", 9 "clap",
10 "clap_complete", 10 "clap_complete",
diff --git a/Cargo.toml b/Cargo.toml
index 0b31f88..b64a2ee 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "RSPlayer" 2name = "RSPlayer"
3version = "0.8.5" 3version = "0.8.6"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "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}
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;
10use clap::{Command, CommandFactory, Parser, Subcommand}; 10use clap::{Command, CommandFactory, Parser, Subcommand};
11use clap_complete::{Shell, generate}; 11use clap_complete::{Shell, generate};
12use std::path::PathBuf; 12use std::path::PathBuf;
13use 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.
360fn 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.
370fn 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.
433fn 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() {