Compare commits

..

27 commits
v0.1.7 ... main

Author SHA1 Message Date
SW
ed99f78ec9 ci: remove corrupted .deb files before dpkg-scanpackages
All checks were successful
Release / release (push) Successful in 33s
2026-09-10 15:43:17 +02:00
SW
588a425e2c ci: build .deb manually with dpkg-deb instead of cargo-deb
Some checks failed
Release / release (push) Failing after 34s
2026-09-10 15:38:08 +02:00
SW
7e577d641c ci: use cargo deb without --no-build, add .deb debug output
Some checks failed
Release / release (push) Failing after 1m13s
2026-09-10 15:29:14 +02:00
SW
42e9f2e865 ci: drop macOS build and Homebrew tap, keep Linux + .deb + APT only
Some checks failed
Release / release (push) Failing after 1m13s
2026-09-10 15:23:09 +02:00
SW
8b74afd8d0 ci: make macOS build optional, skip Homebrew tap if it fails
Some checks failed
Release / release (push) Failing after 2m24s
2026-09-10 15:14:55 +02:00
SW
b679a9e1a2 ci: install zig into /usr/local/bin with symlink
Some checks failed
Release / release (push) Failing after 2m26s
2026-09-10 15:01:05 +02:00
SW
28b571817c ci: download zig binary directly instead of apt
Some checks failed
Release / release (push) Failing after 52s
2026-09-10 14:58:43 +02:00
SW
be17563131 ci: install zig for cargo-zigbuild macOS cross-compile
Some checks failed
Release / release (push) Failing after 3s
2026-09-10 14:55:57 +02:00
SW
8c578e46a4 ci: fix SSH deploy key mount path for Forgejo runner
Some checks failed
Release / release (push) Failing after 2m1s
2026-09-10 14:51:47 +02:00
SW
13081b4d4a ci: switch release workflow to Forgejo, update repo URLs
Some checks failed
Release / release (push) Failing after 59s
- Add Forgejo release workflow with .deb, Homebrew tap and APT updates.
- Remove obsolete GitHub Actions release workflow.
- Update Cargo.toml repository and homepage URLs to git.janvete.com.
2026-09-10 14:37:48 +02:00
SW
57f734c41a docs: update install instructions for Forgejo 2026-09-10 14:22:09 +02:00
SW
a8fb33fa9f fix(main): enable bracketed paste so multi-line paste is handled as one event 2026-07-16 12:43:59 +02:00
SW
5906cddbbf fix(events): handle bracketed paste in custom/search inputs 2026-07-16 12:38:26 +02:00
SW
498803fc55 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
2026-07-16 12:21:09 +02:00
SW
bc8f0de5c3 fix: allow pre-command without command in custom mode 2026-07-10 10:41:58 +02:00
SW
0544eb0582 feat: mouse scroll and pre-command in custom mode 2026-07-10 10:35:27 +02:00
SW
2e29267432 fix(ui): auto-scroll lists when selection moves out of view
Use ratatui's ListState with render_stateful_widget in all list widgets
(groups, files, sections, custom lines, search results) so the viewport
follows the cursor instead of letting the selected item disappear behind
the border.

Bump version to 0.1.16.
2026-06-22 14:14:08 +02:00
SW
199fa5e652 chore: bump version to 0.1.15 2026-06-19 08:31:14 +02:00
SW
ae1ab0fba1 fix(custom): type 'a' in command input and enlarge input box 2026-06-19 08:29:03 +02:00
SW
542c1db67e Add cargo-deb metadata and GitHub Actions workflow for .deb releases 2026-06-17 19:50:16 +02:00
SW
c9a33e8bd1 Document global search in README 2026-06-17 18:41:06 +02:00
SW
cbce822e8c Add global search across all commands 2026-06-17 18:39:24 +02:00
SW
e0a4290743 Add 'a' key to select/deselect all lines in custom mode 2026-06-15 21:55:44 +02:00
SW
9d66553443 Quote custom commands in append mode to preserve shell operators 2026-06-15 20:37:14 +02:00
SW
bccd4ab65a Custom mode: focus between list and input, show section names, remove j/k 2026-06-15 20:19:57 +02:00
SW
55db8dbbdc Add custom command mode for running commands against selected lines 2026-06-15 19:55:37 +02:00
SW
aa3b5e09df Translate all UI strings to English 2026-06-15 13:47:05 +02:00
10 changed files with 1124 additions and 84 deletions

View file

@ -0,0 +1,165 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
container:
image: rust:bookworm
volumes:
- /opt/runner/keys:/keys:ro
steps:
- name: Install tools
run: |
apt-get update
apt-get install -y dpkg-dev git curl openssh-client
- name: Setup SSH
run: |
test -r /keys/forgejo_runner || { echo "Deploy key /keys/forgejo_runner chybi"; exit 1; }
install -m 600 /keys/forgejo_runner /tmp/forgejo_runner
echo "GIT_SSH_COMMAND=ssh -p 554 -i /tmp/forgejo_runner -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" >> "$GITHUB_ENV"
- name: Checkout source
run: |
git clone ssh://git@git-ssh.janvete.com:554/seth/lines.git src
cd src
git checkout "${{ github.ref_name }}"
- name: Build Linux release binary and .deb
working-directory: src
run: |
VERSION=${{ github.ref_name }}
VER=${VERSION#v}
mkdir -p dist
cargo build --release
# .deb package
DEB_DIR="lines_${VER}_amd64"
mkdir -p "${DEB_DIR}/DEBIAN" "${DEB_DIR}/usr/bin"
cp target/release/lines "${DEB_DIR}/usr/bin/lines"
chmod 755 "${DEB_DIR}/usr/bin/lines"
cat > "${DEB_DIR}/DEBIAN/control" << CONTROL
Package: lines
Version: ${VER}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: janvete <jan.vete@example.com>
Description: TUI for running frequently used commands from markdown files
CONTROL
dpkg-deb --build "${DEB_DIR}" "dist/${DEB_DIR}.deb"
# Linux tarball
STAGING=$(mktemp -d)
cp target/release/lines "${STAGING}/lines"
tar -czf "dist/lines-${VERSION}-linux-amd64.tar.gz" -C "${STAGING}" lines
rm -rf "${STAGING}"
- name: Print checksums
working-directory: src
run: |
echo "=== SHA256 checksums ==="
sha256sum dist/*
- name: Create Forgejo Release
working-directory: src
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: https://git.janvete.com
run: |
VERSION=${{ github.ref_name }}
API="${FORGEJO_URL}/api/v1/repos/seth/lines"
RELEASE_JSON=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"${VERSION}\",\"draft\":false,\"prerelease\":false}" \
"${API}/releases")
echo "Create release response: ${RELEASE_JSON}"
RELEASE_ID=$(echo "${RELEASE_JSON}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "${RELEASE_ID}" ]; then
echo "Release may already exist, fetching existing release..."
EXISTING=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API}/releases/tags/${VERSION}")
echo "Existing release response: ${EXISTING}"
RELEASE_ID=$(echo "${EXISTING}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to create or fetch release"
exit 1
fi
for ARCHIVE in dist/*; do
[ -e "${ARCHIVE}" ] || continue
NAME=$(basename "${ARCHIVE}")
case "${NAME}" in
*.tar.gz) CONTENT_TYPE="application/gzip" ;;
*.deb) CONTENT_TYPE="application/vnd.debian.binary-package" ;;
*) CONTENT_TYPE="application/octet-stream" ;;
esac
curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: ${CONTENT_TYPE}" \
--data-binary "@${ARCHIVE}" \
"${API}/releases/${RELEASE_ID}/assets?name=${NAME}"
echo "Uploaded ${NAME}"
done
- name: Update APT repository
working-directory: src
run: |
VERSION=${{ github.ref_name }}
git clone ssh://git@git-ssh.janvete.com:554/seth/apt.git apt-repo
cp dist/*.deb apt-repo/
cd apt-repo
# Remove any corrupted .deb files left from previous failed runs
for f in *.deb; do
[ -e "$f" ] || continue
if ! dpkg-deb -I "$f" >/dev/null 2>&1; then
echo "Removing corrupted package: $f"
rm -f "$f"
fi
done
dpkg-scanpackages . > Packages
gzip -k -f Packages
cat > Release << RELEASE
Origin: janvete
Label: janvete
Suite: stable
Codename: stable
Version: 1.0
Architectures: amd64
Components: main
Description: janvete tools APT repository
Date: $(date -Ru)
RELEASE
{
echo "SHA256:"
for f in Packages Packages.gz *.deb; do
[ -e "$f" ] || continue
printf " %s %16d %s\n" "$(sha256sum "$f" | cut -d' ' -f1)" "$(stat -c%s "$f")" "$f"
done
} >> Release
git config user.email "forgejo-actions[bot]@janvete.com"
git config user.name "forgejo-actions[bot]"
git add -A
git commit -m "chore: add lines ${VERSION}"
git push

2
Cargo.lock generated
View file

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

View file

@ -1,7 +1,14 @@
[package] [package]
name = "lines" name = "lines"
version = "0.1.0" version = "0.1.21"
edition = "2024" edition = "2024"
authors = ["janvete"]
description = "TUI for running frequently used commands"
license = "MIT"
repository = "https://git.janvete.com/seth/lines"
homepage = "https://git.janvete.com/seth/lines"
keywords = ["cli", "tui", "ssh", "commands"]
categories = ["command-line-utilities"]
[dependencies] [dependencies]
base64 = "0.22.1" base64 = "0.22.1"
@ -12,3 +19,15 @@ directories = "6.0.0"
ratatui = "0.30.1" ratatui = "0.30.1"
toml = "1.1.2" toml = "1.1.2"
walkdir = "2.5.0" walkdir = "2.5.0"
[package.metadata.deb]
maintainer = "janvete <jan.vete@example.com>"
copyright = "2026, janvete"
extended-description = "TUI application for quickly running frequently used commands from markdown files."
section = "utils"
priority = "optional"
assets = [
["target/release/lines", "usr/bin/lines", "755"],
["README.md", "usr/share/doc/lines/README.md", "644"],
]

View file

@ -4,11 +4,27 @@ A TUI app for quickly running frequently used commands — ideal for SSH scripts
## Installation ## Installation
### macOS (Homebrew)
```bash ```bash
brew tap janvete/tools brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
brew install lines brew install lines
``` ```
### Debian / Ubuntu (APT)
```bash
echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
sudo apt update
sudo apt install lines
```
### From source
```bash
cargo install --git ssh://git@git-ssh.janvete.com:554/seth/lines
```
## Usage ## Usage
```bash ```bash
@ -68,11 +84,49 @@ The default terminal is `Terminal` (macOS Terminal.app). The default shell is de
| `↑/↓` or `j/k` | move | | `↑/↓` or `j/k` | move |
| `←/→` or `h/l` | switch panel | | `←/→` or `h/l` | switch panel |
| `Enter` | run selected command / all commands in the current terminal | | `Enter` | run selected command / all commands in the current terminal |
| `/` | open global search across all commands |
| `o` | open selected command / all commands in a new terminal window | | `o` | open selected command / all commands in a new terminal window |
| `c` | open custom command mode for the current file |
| `e` | open current `.md` file in `$EDITOR` | | `e` | open current `.md` file in `$EDITOR` |
| `r` | reload data | | `r` | reload data |
| `q` / `Esc` | quit | | `q` / `Esc` | quit |
## Custom command mode
Press `c` on a selected file to run a custom command against specific lines from that file.
For a file containing:
```markdown
# PVE1
ssh root@host1.example.com
# PVE2
ssh root@host2.example.com
# PVE3
ssh root@host3.example.com
```
Press `c`, select lines with `Space`, type `lsblk`, and press `Enter`. `lines` will run:
```bash
ssh root@host2.example.com lsblk
ssh root@host3.example.com lsblk
```
Use `{}` as a placeholder if your lines are just addresses:
```bash
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.
## History ## History
Every command run through `lines` is appended to `~/.lines/history.log`: Every command run through `lines` is appended to `~/.lines/history.log`:

View file

@ -1,8 +1,33 @@
use std::path::PathBuf; use std::path::PathBuf;
use ratatui::widgets::ListState;
use crate::config::Terminal; use crate::config::Terminal;
use crate::parser::{load_groups, CommandFile, Group, Section}; use crate::parser::{load_groups, CommandFile, Group, Section};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Screen {
Main,
Custom,
Search,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub group: String,
pub file: String,
pub section: String,
pub command: String,
}
#[derive(Debug, Clone)]
pub struct SearchState {
pub query: String,
pub results: Vec<SearchResult>,
pub cursor: usize,
pub list_state: ListState,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PendingCommand { pub struct PendingCommand {
pub group: String, pub group: String,
@ -11,6 +36,107 @@ pub struct PendingCommand {
pub commands: Vec<String>, pub commands: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomFocus {
Lines,
Input,
PreCommand,
}
#[derive(Debug, Clone)]
pub struct CustomLine {
pub section: String,
pub command: String,
}
#[derive(Debug, Clone)]
pub struct CustomState {
pub lines: Vec<CustomLine>,
pub selected: Vec<bool>,
pub cursor: usize,
pub focus: CustomFocus,
pub command: String,
pub pre_command: String,
pub list_state: ListState,
}
impl CustomState {
pub fn from_file(file: &CommandFile) -> Self {
let mut lines = Vec::new();
for section in &file.sections {
if section.is_run_all() {
continue;
}
for cmd in &section.commands {
lines.push(CustomLine {
section: section.title.clone(),
command: cmd.clone(),
});
}
}
let selected = vec![false; lines.len()];
let mut list_state = ListState::default();
list_state.select(Some(0));
CustomState {
lines,
selected,
cursor: 0,
focus: CustomFocus::Lines,
command: String::new(),
pre_command: String::new(),
list_state,
}
}
pub fn toggle(&mut self) {
if self.focus != CustomFocus::Lines {
return;
}
if let Some(s) = self.selected.get_mut(self.cursor) {
*s = !*s;
}
}
pub fn next(&mut self) {
if !self.lines.is_empty() && self.focus == CustomFocus::Lines {
self.cursor = (self.cursor + 1) % self.lines.len();
}
}
pub fn previous(&mut self) {
if !self.lines.is_empty() && self.focus == CustomFocus::Lines {
self.cursor = self.cursor.checked_sub(1).unwrap_or(self.lines.len() - 1);
}
}
pub fn selected_lines(&self) -> Vec<String> {
self.lines
.iter()
.enumerate()
.filter(|(i, _)| self.selected.get(*i).copied().unwrap_or(false))
.map(|(_, line)| line.command.clone())
.collect()
}
pub fn cycle_focus(&mut self) {
self.focus = match self.focus {
CustomFocus::Lines => CustomFocus::Input,
CustomFocus::Input => CustomFocus::PreCommand,
CustomFocus::PreCommand => CustomFocus::Lines,
};
}
pub fn toggle_all(&mut self) {
if self.lines.is_empty() {
return;
}
let all_selected = self.selected.iter().all(|s| *s);
for s in &mut self.selected {
*s = !all_selected;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
Groups, Groups,
@ -27,13 +153,25 @@ pub struct App {
pub file_index: usize, pub file_index: usize,
pub section_index: usize, pub section_index: usize,
pub focus: Focus, pub focus: Focus,
pub screen: Screen,
pub custom_state: Option<CustomState>,
pub search_state: Option<SearchState>,
pub pending_command: Option<PendingCommand>, pub pending_command: Option<PendingCommand>,
pub message: Option<String>, pub message: Option<String>,
pub group_list_state: ListState,
pub file_list_state: ListState,
pub section_list_state: ListState,
} }
impl App { impl App {
pub fn new(data_dir: PathBuf, terminal: Terminal, shell: String) -> Self { pub fn new(data_dir: PathBuf, terminal: Terminal, shell: String) -> Self {
let groups = load_groups(&data_dir); let groups = load_groups(&data_dir);
let mut group_list_state = ListState::default();
let mut file_list_state = ListState::default();
let mut section_list_state = ListState::default();
group_list_state.select(Some(0));
file_list_state.select(Some(0));
section_list_state.select(Some(0));
App { App {
data_dir, data_dir,
terminal, terminal,
@ -43,8 +181,14 @@ impl App {
file_index: 0, file_index: 0,
section_index: 0, section_index: 0,
focus: Focus::Groups, focus: Focus::Groups,
screen: Screen::Main,
custom_state: None,
search_state: None,
pending_command: None, pending_command: None,
message: None, message: None,
group_list_state,
file_list_state,
section_list_state,
} }
} }
@ -55,6 +199,52 @@ impl App {
self.section_index = self.section_index.min(self.current_sections().len().saturating_sub(1)); self.section_index = self.section_index.min(self.current_sections().len().saturating_sub(1));
} }
pub fn enter_custom(&mut self) {
if let Some(file) = self.current_file() {
self.custom_state = Some(CustomState::from_file(file));
self.screen = Screen::Custom;
}
}
pub fn exit_custom(&mut self) {
self.screen = Screen::Main;
self.custom_state = None;
}
pub fn enter_search(&mut self) {
let results = build_search_index(&self.groups);
let mut list_state = ListState::default();
list_state.select(Some(0));
self.search_state = Some(SearchState {
query: String::new(),
results,
cursor: 0,
list_state,
});
self.screen = Screen::Search;
}
pub fn exit_search(&mut self) {
self.screen = Screen::Main;
self.search_state = None;
}
pub fn update_search(&mut self) {
if let Some(state) = self.search_state.as_mut() {
let query = state.query.to_lowercase();
state.results = build_search_index(&self.groups)
.into_iter()
.filter(|r| {
r.group.to_lowercase().contains(&query)
|| r.file.to_lowercase().contains(&query)
|| r.section.to_lowercase().contains(&query)
|| r.command.to_lowercase().contains(&query)
})
.collect();
state.cursor = state.cursor.min(state.results.len().saturating_sub(1));
}
}
pub fn current_group(&self) -> Option<&Group> { pub fn current_group(&self) -> Option<&Group> {
self.groups.get(self.group_index) self.groups.get(self.group_index)
} }
@ -147,3 +337,25 @@ impl App {
}; };
} }
} }
fn build_search_index(groups: &[Group]) -> Vec<SearchResult> {
let mut results = Vec::new();
for group in groups {
for file in &group.files {
for section in &file.sections {
if section.is_run_all() {
continue;
}
for command in &section.commands {
results.push(SearchResult {
group: group.name.clone(),
file: file.name.clone(),
section: section.title.clone(),
command: command.clone(),
});
}
}
}
}
results
}

182
src/custom.rs Normal file
View file

@ -0,0 +1,182 @@
use crate::app::CustomState;
pub fn build_commands(state: &CustomState) -> Vec<String> {
let command = state.command.trim();
let pre_command = state.pre_command.trim();
if command.is_empty() && pre_command.is_empty() {
return Vec::new();
}
let selected = state.selected_lines();
if selected.is_empty() {
return Vec::new();
}
let prefix = if pre_command.is_empty() {
String::new()
} else {
format!("{} ", pre_command)
};
selected
.iter()
.map(|line| {
let line = line.trim();
let built = if command.is_empty() {
line.to_string()
} else if command.contains("{}") {
escape_dollars(&command.replace("{}", line))
} else {
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::*;
use crate::app::{CustomLine, CustomState};
fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str, pre_command: &str) -> CustomState {
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(0));
CustomState {
lines: lines
.into_iter()
.map(|c| CustomLine {
section: "test".to_string(),
command: c.to_string(),
})
.collect(),
selected,
cursor: 0,
focus: crate::app::CustomFocus::Lines,
command: command.to_string(),
pre_command: pre_command.to_string(),
list_state,
}
}
#[test]
fn test_build_commands_append() {
let state = make_state(
vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"],
vec![false, true, true],
"lsblk",
"",
);
assert_eq!(
build_commands(&state),
vec!["ssh root@ip2 \"lsblk\"", "ssh root@ip3 \"lsblk\""]
);
}
#[test]
fn test_build_commands_placeholder() {
let state = make_state(vec!["root@ip1", "root@ip2"], vec![true, true], "ssh {} lsblk", "");
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"]
);
}
#[test]
fn test_build_commands_empty_selection() {
let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk", "");
assert!(build_commands(&state).is_empty());
}
#[test]
fn test_build_commands_pre_command() {
let state = make_state(
vec!["ssh root@ip1", "ssh root@ip2"],
vec![true, true],
"ping",
"lview",
);
assert_eq!(
build_commands(&state),
vec![
"lview ssh root@ip1 \"ping\"",
"lview ssh root@ip2 \"ping\""
]
);
}
#[test]
fn test_build_commands_pre_command_placeholder() {
let state = make_state(
vec!["root@ip1", "root@ip2"],
vec![true, true],
"ssh {} lsblk",
"lview",
);
assert_eq!(
build_commands(&state),
vec!["lview ssh root@ip1 lsblk", "lview ssh root@ip2 lsblk"]
);
}
#[test]
fn test_build_commands_pre_command_only() {
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

@ -1,20 +1,25 @@
use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, MouseEventKind};
use std::io; use std::io;
use std::time::Duration; use std::time::Duration;
use crate::app::{App, PendingCommand}; use crate::app::{App, CustomFocus, CustomState, PendingCommand, Screen};
use crate::commands::{edit_file, run_section}; use crate::commands::{edit_file, run_section};
use crate::custom::build_commands;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone)]
pub enum AppEvent { pub enum AppEvent {
Tick, Tick,
Key(event::KeyEvent), Key(event::KeyEvent),
Mouse(event::MouseEvent),
Paste(String),
} }
pub fn poll_event(timeout: Duration) -> io::Result<Option<AppEvent>> { pub fn poll_event(timeout: Duration) -> io::Result<Option<AppEvent>> {
if event::poll(timeout)? { if event::poll(timeout)? {
match event::read()? { match event::read()? {
Event::Key(key) => Ok(Some(AppEvent::Key(key))), Event::Key(key) => Ok(Some(AppEvent::Key(key))),
Event::Mouse(mouse) => Ok(Some(AppEvent::Mouse(mouse))),
Event::Paste(text) => Ok(Some(AppEvent::Paste(text))),
_ => Ok(Some(AppEvent::Tick)), _ => Ok(Some(AppEvent::Tick)),
} }
} else { } else {
@ -29,18 +34,79 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
if key.kind != KeyEventKind::Press { if key.kind != KeyEventKind::Press {
return true; return true;
} }
match app.screen {
Screen::Main => handle_main_event(app, key),
Screen::Custom => handle_custom_event(app, key),
Screen::Search => handle_search_event(app, key),
}
}
AppEvent::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollDown => {
scroll_down(app);
true
}
MouseEventKind::ScrollUp => {
scroll_up(app);
true
}
_ => true,
},
AppEvent::Paste(text) => handle_paste_event(app, &text),
}
}
fn scroll_down(app: &mut App) {
match app.screen {
Screen::Main => app.next(),
Screen::Custom => {
if let Some(state) = app.custom_state.as_mut() {
state.next();
}
}
Screen::Search => {
if let Some(state) = app.search_state.as_mut() && !state.results.is_empty() {
state.cursor = (state.cursor + 1) % state.results.len();
}
}
}
}
fn scroll_up(app: &mut App) {
match app.screen {
Screen::Main => app.previous(),
Screen::Custom => {
if let Some(state) = app.custom_state.as_mut() {
state.previous();
}
}
Screen::Search => {
if let Some(state) = app.search_state.as_mut() && !state.results.is_empty() {
state.cursor = state.cursor.checked_sub(1).unwrap_or(state.results.len() - 1);
}
}
}
}
fn handle_main_event(app: &mut App, key: event::KeyEvent) -> bool {
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => return false, KeyCode::Char('q') | KeyCode::Esc => return false,
KeyCode::Char('j') | KeyCode::Down => app.next(), KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(), KeyCode::Up => app.previous(),
KeyCode::Char('h') | KeyCode::Left => app.focus_left(), KeyCode::Left => app.focus_left(),
KeyCode::Char('l') | KeyCode::Right => app.focus_right(), KeyCode::Right => app.focus_right(),
KeyCode::Char('r') => app.reload(), KeyCode::Char('r') => app.reload(),
KeyCode::Char('e') => { KeyCode::Char('e') => {
if let Some(file) = app.current_file() { if let Some(file) = app.current_file() {
edit_file(&file.path, &app.terminal, &app.shell); edit_file(&file.path, &app.terminal, &app.shell);
} }
} }
KeyCode::Char('c') => {
app.enter_custom();
}
KeyCode::Char('/') => {
app.enter_search();
}
KeyCode::Char('o') => { KeyCode::Char('o') => {
if let (Some(section), Some(file), Some(group)) = if let (Some(section), Some(file), Some(group)) =
(app.current_section(), app.current_file(), app.current_group()) (app.current_section(), app.current_file(), app.current_group())
@ -85,5 +151,166 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
} }
true true
} }
fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
let Some(state) = app.custom_state.as_mut() else {
app.exit_custom();
return true;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.exit_custom();
}
KeyCode::Tab => {
state.cycle_focus();
}
KeyCode::Down => {
if state.focus == CustomFocus::Lines {
state.next();
} }
} }
KeyCode::Up => {
if state.focus == CustomFocus::Lines {
state.previous();
}
}
KeyCode::Char(' ') => {
if state.focus == CustomFocus::Lines {
state.toggle();
} else {
push_to_active_input(state, ' ');
}
}
KeyCode::Char('a') => {
if state.focus == CustomFocus::Lines {
state.toggle_all();
} else {
push_to_active_input(state, 'a');
}
}
KeyCode::Backspace => {
pop_from_active_input(state);
}
KeyCode::Enter => {
if state.focus == CustomFocus::Input || state.focus == CustomFocus::PreCommand {
let commands = build_commands(state);
if !commands.is_empty()
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group())
{
app.pending_command = Some(PendingCommand {
group: group.name.clone(),
file: file.name.clone(),
section: "custom".to_string(),
commands,
});
return false;
}
} else {
state.focus = CustomFocus::Input;
}
}
KeyCode::Char(c) => {
if state.focus == CustomFocus::Lines {
state.command.push(c);
} else {
push_to_active_input(state, c);
}
}
_ => {}
}
true
}
fn push_to_active_input(state: &mut CustomState, c: char) {
match state.focus {
CustomFocus::Input => state.command.push(c),
CustomFocus::PreCommand => state.pre_command.push(c),
CustomFocus::Lines => {}
}
}
fn pop_from_active_input(state: &mut CustomState) {
match state.focus {
CustomFocus::Input => {
state.command.pop();
}
CustomFocus::PreCommand => {
state.pre_command.pop();
}
CustomFocus::Lines => {}
}
}
fn handle_paste_event(app: &mut App, text: &str) -> bool {
match app.screen {
Screen::Main => true,
Screen::Custom => {
let Some(state) = app.custom_state.as_mut() else {
app.exit_custom();
return true;
};
match state.focus {
CustomFocus::Input => state.command.push_str(text),
CustomFocus::PreCommand => state.pre_command.push_str(text),
CustomFocus::Lines => {}
}
true
}
Screen::Search => {
let Some(state) = app.search_state.as_mut() else {
app.exit_search();
return true;
};
// Search queries are single-line; keep only the first pasted line.
let query = text.lines().next().unwrap_or(text);
state.query.push_str(query);
app.update_search();
true
}
}
}
fn handle_search_event(app: &mut App, key: event::KeyEvent) -> bool {
let Some(state) = app.search_state.as_mut() else {
app.exit_search();
return true;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.exit_search();
}
KeyCode::Down => {
if !state.results.is_empty() {
state.cursor = (state.cursor + 1) % state.results.len();
}
}
KeyCode::Up => {
if !state.results.is_empty() {
state.cursor = state.cursor.checked_sub(1).unwrap_or(state.results.len() - 1);
}
}
KeyCode::Backspace => {
state.query.pop();
app.update_search();
}
KeyCode::Enter => {
if let Some(result) = state.results.get(state.cursor) {
app.pending_command = Some(PendingCommand {
group: result.group.clone(),
file: result.file.clone(),
section: result.section.clone(),
commands: vec![result.command.clone()],
});
return false;
}
}
KeyCode::Char(c) => {
state.query.push(c);
app.update_search();
}
_ => {}
}
true
}

View file

@ -1,6 +1,7 @@
mod app; mod app;
mod commands; mod commands;
mod config; mod config;
mod custom;
mod events; mod events;
mod logger; mod logger;
mod parser; mod parser;
@ -13,7 +14,9 @@ use std::time::Duration;
use clap::Parser; use clap::Parser;
use crossterm::{ use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture}, event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
@ -28,10 +31,10 @@ use crate::events::{handle_event, poll_event};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "lines")] #[command(name = "lines")]
#[command(about = "TUI pro rychlé spouštění často používaných příkazů")] #[command(about = "TUI for running frequently used commands")]
#[command(version)] #[command(version)]
struct Args { struct Args {
/// Cesta k datové složce (výchozí: ~/.lines) /// Path to the data directory (default: ~/.lines)
#[arg(short, long)] #[arg(short, long)]
dir: Option<PathBuf>, dir: Option<PathBuf>,
} }
@ -43,7 +46,7 @@ fn main() -> io::Result<()> {
// Ensure data directory exists // Ensure data directory exists
if let Err(e) = std::fs::create_dir_all(&config.data_dir) { if let Err(e) = std::fs::create_dir_all(&config.data_dir) {
eprintln!( eprintln!(
"Nepodařilo se vytvořit datovou složku {}: {}", "Failed to create data directory {}: {}",
config.data_dir.display(), config.data_dir.display(),
e e
); );
@ -53,7 +56,12 @@ fn main() -> io::Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
@ -63,7 +71,8 @@ fn main() -> io::Result<()> {
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),
LeaveAlternateScreen, LeaveAlternateScreen,
DisableMouseCapture DisableMouseCapture,
DisableBracketedPaste
)?; )?;
terminal.show_cursor()?; terminal.show_cursor()?;
@ -89,7 +98,7 @@ where
let tick_rate = Duration::from_millis(100); let tick_rate = Duration::from_millis(100);
loop { loop {
terminal.draw(|f| ui::draw(f, &app))?; terminal.draw(|f| ui::draw(f, &mut app))?;
if let Some(event) = poll_event(tick_rate)? if let Some(event) = poll_event(tick_rate)?
&& !handle_event(&mut app, event) && !handle_event(&mut app, event)

View file

@ -157,10 +157,10 @@ mod tests {
#[test] #[test]
fn test_parse_markdown_run_all() { fn test_parse_markdown_run_all() {
let input = "# Spustí vše\n\n# Docker update\nssh root@192.168.53.3 /opt/mealie/update.sh\nssh root@192.168.53.5 /opt/yamtrack/update.sh\n"; let input = "# Run all\n\n# Docker update\nssh admin@host1.example.com /opt/app/update.sh\nssh admin@host2.example.com /opt/app/update.sh\n";
let sections = parse_markdown(input); let sections = parse_markdown(input);
assert_eq!(sections.len(), 2); assert_eq!(sections.len(), 2);
assert_eq!(sections[0].title, "Spustí vše"); assert_eq!(sections[0].title, "Run all");
assert!(sections[0].commands.is_empty()); assert!(sections[0].commands.is_empty());
assert_eq!(sections[1].commands.len(), 2); assert_eq!(sections[1].commands.len(), 2);
} }

204
src/ui.rs
View file

@ -6,9 +6,17 @@ use ratatui::{
Frame, Frame,
}; };
use crate::app::{App, Focus}; use crate::app::{App, CustomFocus, Focus, Screen};
pub fn draw(f: &mut Frame, app: &App) { pub fn draw(f: &mut Frame, app: &mut App) {
match app.screen {
Screen::Main => draw_main(f, app),
Screen::Custom => draw_custom(f, app),
Screen::Search => draw_search(f, app),
}
}
fn draw_main(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -35,7 +43,168 @@ pub fn draw(f: &mut Frame, app: &App) {
draw_status(f, app, chunks[2]); draw_status(f, app, chunks[2]);
} }
fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_custom(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Min(5),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(1),
])
.split(f.area());
let Some(state) = app.custom_state.as_mut() else {
return;
};
let items: Vec<ListItem> = state
.lines
.iter()
.enumerate()
.map(|(i, line)| {
let checked = if state.selected.get(i).copied().unwrap_or(false) {
"[x]"
} else {
"[ ]"
};
let label = format!(
"{} {} — {}",
checked,
line.command,
line.section
);
let mut style = Style::default();
if i == state.cursor && state.focus == CustomFocus::Lines {
style = style
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD);
}
ListItem::new(label).style(style)
})
.collect();
let list_block = Block::default()
.title(" Custom command — select lines with Space ")
.borders(Borders::ALL)
.border_style(if state.focus == CustomFocus::Lines {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
});
let list = List::new(items).block(list_block);
state.list_state.select(Some(state.cursor));
f.render_stateful_widget(list, chunks[0], &mut state.list_state);
let input = Paragraph::new(state.command.as_str())
.block(
Block::default()
.title(" Command (use {} for selected line) ")
.borders(Borders::ALL)
.border_style(if state.focus == CustomFocus::Input {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}),
)
.wrap(Wrap { trim: true });
f.render_widget(input, chunks[1]);
let pre_command = Paragraph::new(state.pre_command.as_str())
.block(
Block::default()
.title(" Pre-command ")
.borders(Borders::ALL)
.border_style(if state.focus == CustomFocus::PreCommand {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}),
)
.wrap(Wrap { trim: true });
f.render_widget(pre_command, chunks[2]);
let help = "↑/↓: move | Space: toggle | a: select all | Tab: focus | Enter: run | Esc: back | scroll: move";
let text = match &app.message {
Some(msg) => Text::from(msg.as_str()),
None => Text::from(help),
};
let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray));
f.render_widget(paragraph, chunks[3]);
}
fn draw_search(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(1),
])
.split(f.area());
let Some(state) = app.search_state.as_mut() else {
return;
};
let input = Paragraph::new(state.query.as_str())
.block(
Block::default()
.title(" Search ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(input, chunks[0]);
let items: Vec<ListItem> = state
.results
.iter()
.enumerate()
.map(|(i, result)| {
let style = if i == state.cursor {
Style::default()
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
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();
let list = List::new(items).block(
Block::default()
.title(format!(" Results ({}) ", state.results.len()))
.borders(Borders::ALL),
);
state.list_state.select(Some(state.cursor));
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
let preview_text = state
.results
.get(state.cursor)
.map(|r| Text::from(r.command.as_str()))
.unwrap_or_else(|| Text::from("No result selected."));
let preview = Paragraph::new(preview_text)
.block(Block::default().title(" Preview ").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(preview, chunks[2]);
}
fn draw_groups(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.groups .groups
.iter() .iter()
@ -54,15 +223,16 @@ fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect(); .collect();
let block = Block::default() let block = Block::default()
.title(" Skupiny ") .title(" Groups ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Groups)); .border_style(border_style(app.focus == Focus::Groups));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.group_list_state.select(Some(app.group_index));
f.render_stateful_widget(list, area, &mut app.group_list_state);
} }
fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_files(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.current_files() .current_files()
.iter() .iter()
@ -81,22 +251,23 @@ fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect(); .collect();
let block = Block::default() let block = Block::default()
.title(" Soubory ") .title(" Files ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Files)); .border_style(border_style(app.focus == Focus::Files));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.file_list_state.select(Some(app.file_index));
f.render_stateful_widget(list, area, &mut app.file_list_state);
} }
fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_sections(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.current_sections() .current_sections()
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, section)| { .map(|(i, section)| {
let label = if section.is_run_all() { let label = if section.is_run_all() {
format!("{} (vše)", section.title) format!("{} (all)", section.title)
} else { } else {
section.title.clone() section.title.clone()
}; };
@ -113,19 +284,20 @@ fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect(); .collect();
let block = Block::default() let block = Block::default()
.title(" Příkazy ") .title(" Commands ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Sections)); .border_style(border_style(app.focus == Focus::Sections));
let list = List::new(items).block(block); let list = List::new(items).block(block);
f.render_widget(list, area); app.section_list_state.select(Some(app.section_index));
f.render_stateful_widget(list, area, &mut app.section_list_state);
} }
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let text = match app.current_section() { let text = match app.current_section() {
Some(section) => { Some(section) => {
let title = if section.is_run_all() { let title = if section.is_run_all() {
format!("{} — spustí všechny příkazy v souboru", section.title) format!("{} — runs all commands in this file", section.title)
} else { } else {
section.title.clone() section.title.clone()
}; };
@ -161,11 +333,11 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
Text::from(lines) Text::from(lines)
} }
None => Text::from("Žádný příkaz k zobrazení."), None => Text::from("No command to display."),
}; };
let block = Block::default() let block = Block::default()
.title(" Náhled ") .title(" Preview ")
.borders(Borders::ALL); .borders(Borders::ALL);
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
@ -173,7 +345,7 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
} }
fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let help = "↑/↓ nebo j/k: pohyb | →/←: panel | Enter: spustit | e: editovat | r: reload | q: konec"; let help = "↑/↓: move | →/←: panel | Enter: run | /: search | o: new window | c: custom | e: edit | r: reload | q: quit";
let text = match &app.message { let text = match &app.message {
Some(msg) => Text::from(msg.as_str()), Some(msg) => Text::from(msg.as_str()),
None => Text::from(help), None => Text::from(help),