Compare commits

..

34 commits
v0.1.4 ... 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
SW
8d92110c58 Add history.log for tracking command executions 2026-06-15 13:39:10 +02:00
SW
f15be840ca Update controls for same-terminal execution and new-window shortcut 2026-06-15 13:21:33 +02:00
SW
a09b8b5573 Run commands in current terminal by default; 'o' opens in new window 2026-06-15 13:18:42 +02:00
SW
378b6d3645 Add brew tap step to installation instructions 2026-06-15 13:10:35 +02:00
SW
874c966f63 Simplify install command to brew install lines 2026-06-15 13:09:38 +02:00
SW
3b2c13c803 Document shell setting in README 2026-06-15 13:05:17 +02:00
SW
d495d23afe Add configurable shell (default from /bin/bash) for command execution 2026-06-15 13:04:01 +02:00
13 changed files with 1419 additions and 87 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

130
Cargo.lock generated
View file

@ -17,6 +17,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@ -166,6 +175,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -178,6 +197,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
@ -247,6 +279,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -475,6 +513,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "finl_unicode"
version = "1.4.0"
@ -618,6 +662,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "id-arena"
version = "2.3.0"
@ -757,9 +825,10 @@ dependencies = [
[[package]]
name = "lines"
version = "0.1.0"
version = "0.1.21"
dependencies = [
"base64",
"chrono",
"clap",
"crossterm",
"directories",
@ -1410,6 +1479,12 @@ dependencies = [
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
@ -1973,12 +2048,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View file

@ -1,13 +1,33 @@
[package]
name = "lines"
version = "0.1.0"
version = "0.1.21"
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]
base64 = "0.22.1"
chrono = "0.4.45"
clap = { version = "4.6.1", features = ["derive"] }
crossterm = "0.29.0"
directories = "6.0.0"
ratatui = "0.30.1"
toml = "1.1.2"
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,8 +4,25 @@ A TUI app for quickly running frequently used commands — ideal for SSH scripts
## Installation
### macOS (Homebrew)
```bash
brew install janvete/tools/lines
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
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
@ -55,9 +72,10 @@ Create `~/.lines/config.toml`:
```toml
terminal = "Ghostty" # options: Terminal, Ghostty, iTerm
shell = "zsh" # options: any shell name, default is taken from $SHELL
```
The default terminal is `Terminal` (macOS Terminal.app).
The default terminal is `Terminal` (macOS Terminal.app). The default shell is detected from the `$SHELL` environment variable.
## Controls
@ -65,11 +83,59 @@ The default terminal is `Terminal` (macOS Terminal.app).
|-----|--------|
| `↑/↓` or `j/k` | move |
| `←/→` or `h/l` | switch panel |
| `Enter` | run selected command / all commands |
| `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 |
| `c` | open custom command mode for the current file |
| `e` | open current `.md` file in `$EDITOR` |
| `r` | reload data |
| `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
Every command run through `lines` is appended to `~/.lines/history.log`:
```
2026-06-15 08:30:15 [Servers/web.md] Main server: ssh admin@web.example.com
2026-06-15 08:31:02 [Scripts/update.md] Run all: ssh admin@host1.example.com /opt/app/update.sh; ssh admin@host2.example.com /opt/app/update.sh
```
## Backup
The entire `~/.lines` folder is plain text. Copy it to a disk, Git repo, or iCloud. On a new Mac, move it to the same location and `lines` works immediately.

View file

@ -1,8 +1,142 @@
use std::path::PathBuf;
use ratatui::widgets::ListState;
use crate::config::Terminal;
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)]
pub struct PendingCommand {
pub group: String,
pub file: String,
pub section: 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)]
pub enum Focus {
Groups,
@ -13,26 +147,48 @@ pub enum Focus {
pub struct App {
pub data_dir: PathBuf,
pub terminal: Terminal,
pub shell: String,
pub groups: Vec<Group>,
pub group_index: usize,
pub file_index: usize,
pub section_index: usize,
pub focus: Focus,
pub screen: Screen,
pub custom_state: Option<CustomState>,
pub search_state: Option<SearchState>,
pub pending_command: Option<PendingCommand>,
pub message: Option<String>,
pub group_list_state: ListState,
pub file_list_state: ListState,
pub section_list_state: ListState,
}
impl App {
pub fn new(data_dir: PathBuf, terminal: Terminal) -> Self {
pub fn new(data_dir: PathBuf, terminal: Terminal, shell: String) -> Self {
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 {
data_dir,
terminal,
shell,
groups,
group_index: 0,
file_index: 0,
section_index: 0,
focus: Focus::Groups,
screen: Screen::Main,
custom_state: None,
search_state: None,
pending_command: None,
message: None,
group_list_state,
file_list_state,
section_list_state,
}
}
@ -43,6 +199,52 @@ impl App {
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> {
self.groups.get(self.group_index)
}
@ -135,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
}

View file

@ -7,9 +7,20 @@ use std::process::Command;
use base64::{engine::general_purpose::STANDARD, Engine};
use crate::config::Terminal;
use crate::logger;
use crate::parser::{CommandFile, Section};
pub fn run_section(section: &Section, file: &CommandFile, terminal: &Terminal, data_dir: &Path) {
#[allow(clippy::too_many_arguments)]
pub fn run_section(
section: &Section,
file: &CommandFile,
terminal: &Terminal,
shell: &str,
data_dir: &Path,
group_name: &str,
file_name: &str,
section_name: &str,
) {
let commands = if section.is_run_all() {
file.sections
.iter()
@ -24,13 +35,22 @@ pub fn run_section(section: &Section, file: &CommandFile, terminal: &Terminal, d
return;
}
logger::log_run(
data_dir,
group_name,
file_name,
section_name,
&commands,
true,
);
match terminal {
Terminal::Ghostty => run_in_ghostty(&commands),
_ => run_via_script(&commands, terminal, data_dir),
Terminal::Ghostty => run_in_ghostty(&commands, shell),
_ => run_via_script(&commands, terminal, shell, data_dir),
}
}
fn run_via_script(commands: &[String], terminal: &Terminal, data_dir: &Path) {
fn run_via_script(commands: &[String], terminal: &Terminal, shell: &str, data_dir: &Path) {
let script = commands.join("\n");
let run_dir = data_dir.join(".run");
if fs::create_dir_all(&run_dir).is_err() {
@ -57,35 +77,36 @@ fn run_via_script(commands: &[String], terminal: &Terminal, data_dir: &Path) {
let _ = fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755));
match terminal {
Terminal::Iterm => run_in_iterm(&script_path),
Terminal::Builtin => run_in_terminal_app(&script_path),
Terminal::Iterm => run_in_iterm(&script_path, shell),
Terminal::Builtin => run_in_terminal_app(&script_path, shell),
Terminal::Ghostty => unreachable!(),
}
}
fn run_in_ghostty(commands: &[String]) {
fn run_in_ghostty(commands: &[String], shell: &str) {
let script = commands.join("\n");
let encoded = STANDARD.encode(script);
let shell_command = format!("echo {} | base64 -D | bash", encoded);
let shell_command = format!("echo {} | base64 -D | {}", encoded, shell);
let _ = Command::new("open")
.arg("-na")
.arg("Ghostty")
.arg("--args")
.arg("-e")
.arg("bash")
.arg(shell)
.arg("-c")
.arg(shell_command)
.spawn();
}
fn run_in_terminal_app(script_path: &Path) {
fn run_in_terminal_app(script_path: &Path, shell: &str) {
let path_str = script_path.to_string_lossy();
let applescript = format!(
r#"tell application "Terminal"
do script "clear; bash {}"
do script "clear; {} {}"
activate
end tell"#,
shell,
escape_applescript(&path_str)
);
@ -95,16 +116,17 @@ end tell"#,
.spawn();
}
fn run_in_iterm(script_path: &Path) {
fn run_in_iterm(script_path: &Path, shell: &str) {
let path_str = script_path.to_string_lossy();
let applescript = format!(
r#"tell application "iTerm"
create window with default profile
tell current session of current window
write text "clear; bash {}"
write text "clear; {} {}"
end tell
activate
end tell"#,
shell,
escape_applescript(&path_str)
);
@ -114,7 +136,7 @@ end tell"#,
.spawn();
}
pub fn edit_file(path: &Path, terminal: &Terminal) {
pub fn edit_file(path: &Path, terminal: &Terminal, shell: &str) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
match terminal {
@ -124,13 +146,14 @@ pub fn edit_file(path: &Path, terminal: &Terminal) {
.arg("Ghostty")
.arg("--args")
.arg("-e")
.arg("bash")
.arg(shell)
.arg("-c")
.arg(format!(
"cd {} && {} {}; exec bash",
"cd {} && {} {}; exec {}",
escape_shell(&std::env::current_dir().unwrap_or_default().to_string_lossy()),
escape_shell(&editor),
escape_shell(&path.to_string_lossy())
escape_shell(&path.to_string_lossy()),
shell
))
.spawn();
}

View file

@ -1,11 +1,11 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
pub data_dir: PathBuf,
pub terminal: Terminal,
pub shell: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@ -30,8 +30,12 @@ impl Terminal {
impl Config {
pub fn new(data_dir: Option<PathBuf>) -> Self {
let data_dir = data_dir.unwrap_or_else(default_data_dir);
let terminal = load_terminal_preference(&data_dir);
Config { data_dir, terminal }
let (terminal, shell) = load_preferences(&data_dir);
Config {
data_dir,
terminal,
shell,
}
}
}
@ -41,19 +45,39 @@ fn default_data_dir() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".lines"))
}
fn load_terminal_preference(data_dir: &Path) -> Terminal {
fn default_shell() -> String {
std::env::var("SHELL")
.ok()
.and_then(|s| {
Path::new(&s)
.file_name()
.and_then(|n| n.to_str())
.map(String::from)
})
.unwrap_or_else(|| "zsh".to_string())
}
fn load_preferences(data_dir: &Path) -> (Terminal, String) {
let config_path = data_dir.join("config.toml");
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return Terminal::default(),
Err(_) => return (Terminal::default(), default_shell()),
};
match toml::from_str::<toml::Value>(&content) {
Ok(value) => value
.get("terminal")
.and_then(|v| v.as_str())
.and_then(Terminal::from_str)
.unwrap_or_default(),
Err(_) => Terminal::default(),
Ok(value) => {
let terminal = value
.get("terminal")
.and_then(|v| v.as_str())
.and_then(Terminal::from_str)
.unwrap_or_default();
let shell = value
.get("shell")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(default_shell);
(terminal, shell)
}
Err(_) => (Terminal::default(), default_shell()),
}
}

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::time::Duration;
use crate::app::App;
use crate::app::{App, CustomFocus, CustomState, PendingCommand, Screen};
use crate::commands::{edit_file, run_section};
use crate::custom::build_commands;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub enum AppEvent {
Tick,
Key(event::KeyEvent),
Mouse(event::MouseEvent),
Paste(String),
}
pub fn poll_event(timeout: Duration) -> io::Result<Option<AppEvent>> {
if event::poll(timeout)? {
match event::read()? {
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)),
}
} else {
@ -29,26 +34,283 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
if key.kind != KeyEventKind::Press {
return true;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return false,
KeyCode::Char('j') | KeyCode::Down => app.next(),
KeyCode::Char('k') | KeyCode::Up => app.previous(),
KeyCode::Char('h') | KeyCode::Left => app.focus_left(),
KeyCode::Char('l') | KeyCode::Right => app.focus_right(),
KeyCode::Char('r') => app.reload(),
KeyCode::Char('e') => {
if let Some(file) = app.current_file() {
edit_file(&file.path, &app.terminal);
}
}
KeyCode::Enter => {
if let (Some(section), Some(file)) = (app.current_section(), app.current_file()) {
run_section(section, file, &app.terminal, &app.data_dir);
}
}
_ => {}
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 {
KeyCode::Char('q') | KeyCode::Esc => return false,
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
KeyCode::Left => app.focus_left(),
KeyCode::Right => app.focus_right(),
KeyCode::Char('r') => app.reload(),
KeyCode::Char('e') => {
if let Some(file) = app.current_file() {
edit_file(&file.path, &app.terminal, &app.shell);
}
}
KeyCode::Char('c') => {
app.enter_custom();
}
KeyCode::Char('/') => {
app.enter_search();
}
KeyCode::Char('o') => {
if let (Some(section), Some(file), Some(group)) =
(app.current_section(), app.current_file(), app.current_group())
{
run_section(
section,
file,
&app.terminal,
&app.shell,
&app.data_dir,
&group.name,
&file.name,
&section.title,
);
}
}
KeyCode::Enter => {
if let (Some(section), Some(file), Some(group)) =
(app.current_section(), app.current_file(), app.current_group())
{
let commands = if section.is_run_all() {
file.sections
.iter()
.filter(|s| !s.is_run_all())
.flat_map(|s| s.commands.clone())
.collect()
} else {
section.commands.clone()
};
if !commands.is_empty() {
app.pending_command = Some(PendingCommand {
group: group.name.clone(),
file: file.name.clone(),
section: section.title.clone(),
commands,
});
return false;
}
}
}
_ => {}
}
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
}

38
src/logger.rs Normal file
View file

@ -0,0 +1,38 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
pub fn log_run(
data_dir: &Path,
group: &str,
file: &str,
section: &str,
commands: &[String],
in_new_window: bool,
) {
let log_path = data_dir.join("history.log");
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let window_note = if in_new_window { " [new-window]" } else { "" };
let commands_str = commands.join("; ");
let line = format!(
"{} [{}/{}] {}{}: {}\n",
timestamp, group, file, section, window_note, commands_str
);
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut file = match OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(f) => f,
Err(_) => return,
};
let _ = file.write_all(line.as_bytes());
}

View file

@ -1,17 +1,22 @@
mod app;
mod commands;
mod config;
mod custom;
mod events;
mod logger;
mod parser;
mod ui;
use std::io;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
@ -26,10 +31,10 @@ use crate::events::{handle_event, poll_event};
#[derive(Parser, Debug)]
#[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)]
struct Args {
/// Cesta k datové složce (výchozí: ~/.lines)
/// Path to the data directory (default: ~/.lines)
#[arg(short, long)]
dir: Option<PathBuf>,
}
@ -40,47 +45,70 @@ fn main() -> io::Result<()> {
// Ensure data directory exists
if let Err(e) = std::fs::create_dir_all(&config.data_dir) {
eprintln!("Nepodařilo se vytvořit datovou složku {}: {}", config.data_dir.display(), e);
eprintln!(
"Failed to create data directory {}: {}",
config.data_dir.display(),
e
);
}
let app = App::new(config.data_dir, config.terminal);
let app = App::new(config.data_dir, config.terminal, config.shell);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal, app);
let app = run_app(&mut terminal, app)?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("{}", err);
if let Some(pending) = app.pending_command {
logger::log_run(
&app.data_dir,
&pending.group,
&pending.file,
&pending.section,
&pending.commands,
false,
);
run_in_current_terminal(&pending.commands, &app.shell);
}
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()>
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<App>
where
io::Error: From<<B as Backend>::Error>,
{
let tick_rate = Duration::from_millis(100);
loop {
terminal.draw(|f| ui::draw(f, &app))?;
terminal.draw(|f| ui::draw(f, &mut app))?;
if let Some(event) = poll_event(tick_rate)?
&& !handle_event(&mut app, event)
{
return Ok(());
return Ok(app);
}
}
}
fn run_in_current_terminal(commands: &[String], shell: &str) {
let script = commands.join("\n");
let _ = Command::new(shell).arg("-c").arg(script).status();
}

View file

@ -157,10 +157,10 @@ mod tests {
#[test]
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);
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_eq!(sections[1].commands.len(), 2);
}

204
src/ui.rs
View file

@ -6,9 +6,17 @@ use ratatui::{
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()
.direction(Direction::Vertical)
.margin(1)
@ -35,7 +43,168 @@ pub fn draw(f: &mut Frame, app: &App) {
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
.groups
.iter()
@ -54,15 +223,16 @@ fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect();
let block = Block::default()
.title(" Skupiny ")
.title(" Groups ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Groups));
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
.current_files()
.iter()
@ -81,22 +251,23 @@ fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect();
let block = Block::default()
.title(" Soubory ")
.title(" Files ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Files));
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
.current_sections()
.iter()
.enumerate()
.map(|(i, section)| {
let label = if section.is_run_all() {
format!("{} (vše)", section.title)
format!("{} (all)", section.title)
} else {
section.title.clone()
};
@ -113,19 +284,20 @@ fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.collect();
let block = Block::default()
.title(" Příkazy ")
.title(" Commands ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Sections));
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) {
let text = match app.current_section() {
Some(section) => {
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 {
section.title.clone()
};
@ -161,11 +333,11 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
Text::from(lines)
}
None => Text::from("Žádný příkaz k zobrazení."),
None => Text::from("No command to display."),
};
let block = Block::default()
.title(" Náhled ")
.title(" Preview ")
.borders(Borders::ALL);
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) {
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 {
Some(msg) => Text::from(msg.as_str()),
None => Text::from(help),