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
This commit is contained in:
SW 2026-07-16 12:21:09 +02:00
parent bc8f0de5c3
commit 498803fc55
5 changed files with 69 additions and 9 deletions

2
Cargo.lock generated
View file

@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "lines"
version = "0.1.17"
version = "0.1.19"
dependencies = [
"base64",
"chrono",

View file

@ -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"

View file

@ -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.

View file

@ -26,15 +26,32 @@ pub fn build_commands(state: &CustomState) -> Vec<String> {
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"]
);
}
}

View file

@ -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();