From 498803fc55c070abff5adfdb9b7b6c7a9c9c911d Mon Sep 17 00:00:00 2001 From: SW Date: Thu, 16 Jul 2026 12:21:09 +0200 Subject: [PATCH] feat(custom): escape variables so they expand on remote host feat(ui): show command in global search results chore: bump version to 0.1.19 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 ++ src/custom.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 14 ++++++++----- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76fbe3e..9ac323f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "lines" -version = "0.1.17" +version = "0.1.19" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5c0a00a..7442007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lines" -version = "0.1.18" +version = "0.1.19" edition = "2024" authors = ["janvete"] description = "TUI for running frequently used commands" diff --git a/README.md b/README.md index 24c6f2d..b10826d 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ Use `{}` as a placeholder if your lines are just addresses: ssh {} lsblk ``` +Variables like `$HOME` or `$USER` are treated as remote-shell variables, so they are expanded on the target host, not by your local shell. + ## Global search Press `/` to search across all commands, files, groups, and section names. Type your query, use `↑/↓` to select a result, and press `Enter` to run it. diff --git a/src/custom.rs b/src/custom.rs index 272e2b9..28133b8 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -26,15 +26,32 @@ pub fn build_commands(state: &CustomState) -> Vec { let built = if command.is_empty() { line.to_string() } else if command.contains("{}") { - command.replace("{}", line) + escape_dollars(&command.replace("{}", line)) } else { - format!("{} \"{}\"", line, command.replace('"', "\\\"")) + let escaped = escape_dollars(command).replace('"', "\\\""); + format!("{} \"{}\"", line, escaped) }; format!("{}{}", prefix, built) }) .collect() } +/// Escape `$` as `\$` unless it is already escaped, so that variables are +/// expanded by the remote shell rather than the local one when the built +/// command is passed to `ssh`. +fn escape_dollars(command: &str) -> String { + let mut result = String::with_capacity(command.len()); + let mut prev_backslash = false; + for c in command.chars() { + if c == '$' && !prev_backslash { + result.push('\\'); + } + result.push(c); + prev_backslash = c == '\\' && !prev_backslash; + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -125,4 +142,41 @@ mod tests { let state = make_state(vec!["ssh root@ip1", "ssh root@ip2"], vec![false, true], "", "lview"); assert_eq!(build_commands(&state), vec!["lview ssh root@ip2"]); } + + #[test] + fn test_build_commands_escapes_dollar_variables() { + let state = make_state( + vec!["ssh root@ip1"], + vec![true], + "echo $HOME && echo $USER", + "", + ); + assert_eq!( + build_commands(&state), + vec!["ssh root@ip1 \"echo \\$HOME && echo \\$USER\""] + ); + } + + #[test] + fn test_build_commands_preserves_already_escaped_dollars() { + let state = make_state( + vec!["ssh root@ip1"], + vec![true], + "echo \\$HOME", + "", + ); + assert_eq!( + build_commands(&state), + vec!["ssh root@ip1 \"echo \\$HOME\""] + ); + } + + #[test] + fn test_build_commands_placeholder_escapes_dollars() { + let state = make_state(vec!["root@ip1"], vec![true], "ssh {} echo $HOSTNAME", ""); + assert_eq!( + build_commands(&state), + vec!["ssh root@ip1 echo \\$HOSTNAME"] + ); + } } diff --git a/src/ui.rs b/src/ui.rs index ae234dd..9cb1f03 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -173,11 +173,15 @@ fn draw_search(f: &mut Frame, app: &mut App) { } else { Style::default() }; - ListItem::new(format!( - "[{}] {} — {}", - result.group, result.file, result.section - )) - .style(style) + let label = if result.command.is_empty() { + format!("[{}] {} — {}", result.group, result.file, result.section) + } else { + format!( + "[{}] {} — {} — {}", + result.group, result.file, result.section, result.command + ) + }; + ListItem::new(label).style(style) }) .collect();