Compare commits

..

17 commits

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
10 changed files with 491 additions and 99 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

View file

@ -1,34 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-deb:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build release binary
run: cargo build --release
- name: Build .deb package
run: cargo deb --no-build
- name: Upload .deb to release
uses: softprops/action-gh-release@v2
with:
files: target/debian/*.deb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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,12 +1,12 @@
[package] [package]
name = "lines" name = "lines"
version = "0.1.15" version = "0.1.21"
edition = "2024" edition = "2024"
authors = ["janvete"] authors = ["janvete"]
description = "TUI for running frequently used commands" description = "TUI for running frequently used commands"
license = "MIT" license = "MIT"
repository = "https://github.com/janvete/lines" repository = "https://git.janvete.com/seth/lines"
homepage = "https://github.com/janvete/lines" homepage = "https://git.janvete.com/seth/lines"
keywords = ["cli", "tui", "ssh", "commands"] keywords = ["cli", "tui", "ssh", "commands"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]

View file

@ -7,24 +7,22 @@ A TUI app for quickly running frequently used commands — ideal for SSH scripts
### macOS (Homebrew) ### 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 (.deb) ### Debian / Ubuntu (APT)
Download the latest `.deb` from the [GitHub Releases](https://github.com/janvete/lines/releases) page and install it:
```bash ```bash
sudo apt install ./lines_*.deb echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
# or sudo apt update
sudo dpkg -i lines_*.deb sudo apt install lines
``` ```
### From source ### From source
```bash ```bash
cargo install --git https://github.com/janvete/lines cargo install --git ssh://git@git-ssh.janvete.com:554/seth/lines
``` ```
## Usage ## Usage
@ -123,6 +121,8 @@ Use `{}` as a placeholder if your lines are just addresses:
ssh {} lsblk 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 ## 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. Press `/` to search across all commands, files, groups, and section names. Type your query, use `↑/↓` to select a result, and press `Enter` to run it.

View file

@ -1,5 +1,7 @@
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};
@ -23,6 +25,7 @@ pub struct SearchState {
pub query: String, pub query: String,
pub results: Vec<SearchResult>, pub results: Vec<SearchResult>,
pub cursor: usize, pub cursor: usize,
pub list_state: ListState,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -37,6 +40,7 @@ pub struct PendingCommand {
pub enum CustomFocus { pub enum CustomFocus {
Lines, Lines,
Input, Input,
PreCommand,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -52,6 +56,8 @@ pub struct CustomState {
pub cursor: usize, pub cursor: usize,
pub focus: CustomFocus, pub focus: CustomFocus,
pub command: String, pub command: String,
pub pre_command: String,
pub list_state: ListState,
} }
impl CustomState { impl CustomState {
@ -69,12 +75,16 @@ impl CustomState {
} }
} }
let selected = vec![false; lines.len()]; let selected = vec![false; lines.len()];
let mut list_state = ListState::default();
list_state.select(Some(0));
CustomState { CustomState {
lines, lines,
selected, selected,
cursor: 0, cursor: 0,
focus: CustomFocus::Lines, focus: CustomFocus::Lines,
command: String::new(), command: String::new(),
pre_command: String::new(),
list_state,
} }
} }
@ -108,12 +118,12 @@ impl CustomState {
.collect() .collect()
} }
pub fn focus_input(&mut self) { pub fn cycle_focus(&mut self) {
self.focus = CustomFocus::Input; self.focus = match self.focus {
} CustomFocus::Lines => CustomFocus::Input,
CustomFocus::Input => CustomFocus::PreCommand,
pub fn focus_lines(&mut self) { CustomFocus::PreCommand => CustomFocus::Lines,
self.focus = CustomFocus::Lines; };
} }
pub fn toggle_all(&mut self) { pub fn toggle_all(&mut self) {
@ -148,11 +158,20 @@ pub struct App {
pub search_state: Option<SearchState>, 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,
@ -167,6 +186,9 @@ impl App {
search_state: None, search_state: None,
pending_command: None, pending_command: None,
message: None, message: None,
group_list_state,
file_list_state,
section_list_state,
} }
} }
@ -191,10 +213,13 @@ impl App {
pub fn enter_search(&mut self) { pub fn enter_search(&mut self) {
let results = build_search_index(&self.groups); let results = build_search_index(&self.groups);
let mut list_state = ListState::default();
list_state.select(Some(0));
self.search_state = Some(SearchState { self.search_state = Some(SearchState {
query: String::new(), query: String::new(),
results, results,
cursor: 0, cursor: 0,
list_state,
}); });
self.screen = Screen::Search; self.screen = Screen::Search;
} }

View file

@ -2,7 +2,9 @@ use crate::app::CustomState;
pub fn build_commands(state: &CustomState) -> Vec<String> { pub fn build_commands(state: &CustomState) -> Vec<String> {
let command = state.command.trim(); let command = state.command.trim();
if command.is_empty() { let pre_command = state.pre_command.trim();
if command.is_empty() && pre_command.is_empty() {
return Vec::new(); return Vec::new();
} }
@ -11,25 +13,53 @@ pub fn build_commands(state: &CustomState) -> Vec<String> {
return Vec::new(); return Vec::new();
} }
let prefix = if pre_command.is_empty() {
String::new()
} else {
format!("{} ", pre_command)
};
selected selected
.iter() .iter()
.map(|line| { .map(|line| {
let line = line.trim(); let line = line.trim();
if command.contains("{}") { let built = if command.is_empty() {
command.replace("{}", line) line.to_string()
} else if command.contains("{}") {
escape_dollars(&command.replace("{}", line))
} else { } else {
format!("{} \"{}\"", line, command.replace('"', "\\\"")) let escaped = escape_dollars(command).replace('"', "\\\"");
} format!("{} \"{}\"", line, escaped)
};
format!("{}{}", prefix, built)
}) })
.collect() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{CustomLine, CustomState}; use crate::app::{CustomLine, CustomState};
fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str) -> 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 { CustomState {
lines: lines lines: lines
.into_iter() .into_iter()
@ -42,6 +72,8 @@ mod tests {
cursor: 0, cursor: 0,
focus: crate::app::CustomFocus::Lines, focus: crate::app::CustomFocus::Lines,
command: command.to_string(), command: command.to_string(),
pre_command: pre_command.to_string(),
list_state,
} }
} }
@ -51,6 +83,7 @@ mod tests {
vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"], vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"],
vec![false, true, true], vec![false, true, true],
"lsblk", "lsblk",
"",
); );
assert_eq!( assert_eq!(
build_commands(&state), build_commands(&state),
@ -60,7 +93,7 @@ mod tests {
#[test] #[test]
fn test_build_commands_placeholder() { fn test_build_commands_placeholder() {
let state = make_state(vec!["root@ip1", "root@ip2"], vec![true, true], "ssh {} lsblk"); let state = make_state(vec!["root@ip1", "root@ip2"], vec![true, true], "ssh {} lsblk", "");
assert_eq!( assert_eq!(
build_commands(&state), build_commands(&state),
vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"] vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"]
@ -69,7 +102,81 @@ mod tests {
#[test] #[test]
fn test_build_commands_empty_selection() { fn test_build_commands_empty_selection() {
let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk"); let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk", "");
assert!(build_commands(&state).is_empty()); 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,21 +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, CustomFocus, PendingCommand, Screen}; 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; 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 {
@ -37,6 +41,50 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
Screen::Search => handle_search_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);
}
}
} }
} }
@ -115,11 +163,7 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
app.exit_custom(); app.exit_custom();
} }
KeyCode::Tab => { KeyCode::Tab => {
if state.focus == CustomFocus::Lines { state.cycle_focus();
state.focus_input();
} else {
state.focus_lines();
}
} }
KeyCode::Down => { KeyCode::Down => {
if state.focus == CustomFocus::Lines { if state.focus == CustomFocus::Lines {
@ -135,21 +179,21 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
if state.focus == CustomFocus::Lines { if state.focus == CustomFocus::Lines {
state.toggle(); state.toggle();
} else { } else {
state.command.push(' '); push_to_active_input(state, ' ');
} }
} }
KeyCode::Char('a') => { KeyCode::Char('a') => {
if state.focus == CustomFocus::Lines { if state.focus == CustomFocus::Lines {
state.toggle_all(); state.toggle_all();
} else { } else {
state.command.push('a'); push_to_active_input(state, 'a');
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => {
state.command.pop(); pop_from_active_input(state);
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.focus == CustomFocus::Input { if state.focus == CustomFocus::Input || state.focus == CustomFocus::PreCommand {
let commands = build_commands(state); let commands = build_commands(state);
if !commands.is_empty() if !commands.is_empty()
&& let (Some(file), Some(group)) = (app.current_file(), app.current_group()) && let (Some(file), Some(group)) = (app.current_file(), app.current_group())
@ -163,17 +207,70 @@ fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool {
return false; return false;
} }
} else { } else {
state.focus_input(); state.focus = CustomFocus::Input;
} }
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
if state.focus == CustomFocus::Lines {
state.command.push(c); state.command.push(c);
} else {
push_to_active_input(state, c);
}
} }
_ => {} _ => {}
} }
true 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 { fn handle_search_event(app: &mut App, key: event::KeyEvent) -> bool {
let Some(state) = app.search_state.as_mut() else { let Some(state) = app.search_state.as_mut() else {
app.exit_search(); app.exit_search();

View file

@ -14,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},
}; };
@ -54,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)?;
@ -64,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()?;
@ -90,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

@ -8,7 +8,7 @@ use ratatui::{
use crate::app::{App, CustomFocus, Focus, Screen}; 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 { match app.screen {
Screen::Main => draw_main(f, app), Screen::Main => draw_main(f, app),
Screen::Custom => draw_custom(f, app), Screen::Custom => draw_custom(f, app),
@ -16,7 +16,7 @@ pub fn draw(f: &mut Frame, app: &App) {
} }
} }
fn draw_main(f: &mut Frame, app: &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)
@ -43,18 +43,19 @@ fn draw_main(f: &mut Frame, app: &App) {
draw_status(f, app, chunks[2]); draw_status(f, app, chunks[2]);
} }
fn draw_custom(f: &mut Frame, app: &App) { fn draw_custom(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
.constraints([ .constraints([
Constraint::Min(5), Constraint::Min(5),
Constraint::Length(5), Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(f.area()); .split(f.area());
let Some(state) = app.custom_state.as_ref() else { let Some(state) = app.custom_state.as_mut() else {
return; return;
}; };
@ -94,7 +95,8 @@ fn draw_custom(f: &mut Frame, app: &App) {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}); });
let list = List::new(items).block(list_block); let list = List::new(items).block(list_block);
f.render_widget(list, chunks[0]); 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()) let input = Paragraph::new(state.command.as_str())
.block( .block(
@ -110,16 +112,30 @@ fn draw_custom(f: &mut Frame, app: &App) {
.wrap(Wrap { trim: true }); .wrap(Wrap { trim: true });
f.render_widget(input, chunks[1]); f.render_widget(input, chunks[1]);
let help = "↑/↓: move | Space: toggle | a: select all | Tab: focus | Enter: run | Esc: back"; 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 { 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),
}; };
let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray)); let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray));
f.render_widget(paragraph, chunks[2]); f.render_widget(paragraph, chunks[3]);
} }
fn draw_search(f: &mut Frame, app: &App) { fn draw_search(f: &mut Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -130,7 +146,7 @@ fn draw_search(f: &mut Frame, app: &App) {
]) ])
.split(f.area()); .split(f.area());
let Some(state) = app.search_state.as_ref() else { let Some(state) = app.search_state.as_mut() else {
return; return;
}; };
@ -157,11 +173,15 @@ fn draw_search(f: &mut Frame, app: &App) {
} else { } else {
Style::default() Style::default()
}; };
ListItem::new(format!( let label = if result.command.is_empty() {
"[{}] {} — {}", format!("[{}] {} — {}", result.group, result.file, result.section)
result.group, result.file, result.section } else {
)) format!(
.style(style) "[{}] {} — {} — {}",
result.group, result.file, result.section, result.command
)
};
ListItem::new(label).style(style)
}) })
.collect(); .collect();
@ -170,7 +190,8 @@ fn draw_search(f: &mut Frame, app: &App) {
.title(format!(" Results ({}) ", state.results.len())) .title(format!(" Results ({}) ", state.results.len()))
.borders(Borders::ALL), .borders(Borders::ALL),
); );
f.render_widget(list, chunks[1]); state.list_state.select(Some(state.cursor));
f.render_stateful_widget(list, chunks[1], &mut state.list_state);
let preview_text = state let preview_text = state
.results .results
@ -183,7 +204,7 @@ fn draw_search(f: &mut Frame, app: &App) {
f.render_widget(preview, chunks[2]); f.render_widget(preview, chunks[2]);
} }
fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { 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()
@ -207,10 +228,11 @@ fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.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()
@ -234,10 +256,11 @@ fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.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()
@ -266,7 +289,8 @@ fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
.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) {