Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
resolver = "3"
# Rust plugins must be listed here. Plugins in other languages live under
# plugins/ too but are not cargo members.
members = ["crates/herdr-client", "plugins/command-palette", "plugins/git-glance"]
members = ["crates/herdr-client", "plugins/command-palette", "plugins/git-glance", "plugins/project-runner"]

[workspace.package]
edition = "2024"
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,32 @@ no editor opens. A failed commit keeps your message in the prompt.
never takes the index lock from an agent or editor working in the same repo.
- Git errors show in the footer. The full text goes to
`~/.local/state/herdr/plugins/jacob.git-glance/glance.log`.

## Project runner (`plugins/project-runner`)

Popup picker for the `package.json` scripts of the focused pane's project.
Pick one and it runs in a new tab (or split) as `<pm> run <script>`, so output
stays visible and `ctrl-c` stops it.

- Walks up from the focused pane's cwd to the repo root; the nearest
`package.json` **with scripts** wins, so a pane deep in an nx lib still finds
the root's helper scripts.
- Package manager: `packageManager` field, else lockfile (pnpm, yarn, bun,
npm), else npm.
- Scripts keep their `package.json` order; typing fuzzy-filters names and
commands (`serve api` finds `"start:api": "nx serve api"`).

### Keybinding

```toml
[[keys.command]]
key = "cmd+r"
type = "plugin_action"
command = "jacob.project-runner.open"
description = "run project script"
```

### Keys

`↑`/`↓` or `ctrl-p`/`ctrl-n` move · `enter` runs in a new tab · `ctrl-v` split
right · `ctrl-x` split down · `esc` closes · `ctrl-u` clears.
15 changes: 15 additions & 0 deletions plugins/project-runner/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "project-runner"
version = "0.1.0"
edition.workspace = true

[dependencies]
herdr-client.workspace = true
nucleo-matcher.workspace = true
ratatui.workspace = true
serde.workspace = true
serde_json.workspace = true
unicode-segmentation.workspace = true

[dev-dependencies]
tempfile.workspace = true
24 changes: 24 additions & 0 deletions plugins/project-runner/herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
id = "jacob.project-runner"
name = "Project Runner"
version = "0.1.0"
min_herdr_version = "0.9.0"
description = "Popup picker for package.json scripts; runs the chosen one in a new tab or split"
platforms = ["macos", "linux"]

[[build]]
command = ["cargo", "build", "--release", "--target-dir", "target"]

# Keybindings target actions, not panes, so this action opens the popup.
[[actions]]
id = "open"
title = "Run a project script"
contexts = ["workspace"]
command = ["sh", "-c", "exec \"$HERDR_BIN_PATH\" plugin pane open --plugin jacob.project-runner --entrypoint runner"]

[[panes]]
id = "runner"
title = "Project Runner"
placement = "popup"
width = "60%"
height = "50%"
command = ["target/release/project-runner", "ui"]
183 changes: 183 additions & 0 deletions plugins/project-runner/src/app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! Runner state and key handling, independent of the terminal.

use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::launch::Target;
use crate::matcher::{Ranked, ScriptMatcher};
use crate::project::Project;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Continue,
Quit,
/// Run `project.scripts[script]` in `target`.
Run {
script: usize,
target: Target,
},
}

pub struct App {
pub project: Project,
pub query: String,
pub ranked: Vec<Ranked>,
/// Index into `ranked`.
pub selected: usize,
pub error: Option<String>,
matcher: ScriptMatcher,
}

impl App {
pub fn new(project: Project) -> Self {
let mut app = Self {
project,
query: String::new(),
ranked: Vec::new(),
selected: 0,
error: None,
matcher: ScriptMatcher::default(),
};
app.refilter();
app
}

pub fn handle_key(&mut self, key: KeyEvent) -> Command {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => return Command::Quit,
KeyCode::Char('c') if ctrl => return Command::Quit,
KeyCode::Enter => return self.run(Target::Tab),
KeyCode::Char('v') if ctrl => return self.run(Target::SplitRight),
KeyCode::Char('x') if ctrl => return self.run(Target::SplitDown),
KeyCode::Up => self.move_selection(-1),
KeyCode::Down => self.move_selection(1),
KeyCode::Char('p') if ctrl => self.move_selection(-1),
KeyCode::Char('n') if ctrl => self.move_selection(1),
KeyCode::Char('u') if ctrl => self.edit(String::clear),
KeyCode::Backspace => self.edit(|q| {
q.pop();
}),
KeyCode::Char(c) if !ctrl => self.edit(|q| q.push(c)),
_ => {}
}
Command::Continue
}

pub fn fail(&mut self, message: String) {
self.error = Some(message);
}

fn run(&self, target: Target) -> Command {
match self.ranked.get(self.selected) {
Some(ranked) => Command::Run { script: ranked.index, target },
None => Command::Continue,
}
}

fn edit(&mut self, change: impl FnOnce(&mut String)) {
change(&mut self.query);
self.error = None;
self.refilter();
}

fn refilter(&mut self) {
self.ranked = self.matcher.rank(&self.query, &self.project.scripts);
self.selected = 0;
}

/// Wraps around at either end.
fn move_selection(&mut self, delta: isize) {
let len = self.ranked.len();
if len > 0 {
self.selected = (self.selected as isize + delta).rem_euclid(len as isize) as usize;
}
}
}

#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::project::{Manager, Script};
use std::path::PathBuf;

pub fn project() -> Project {
let scripts = [("start:web", "nx serve web"), ("start:api", "nx serve api"), ("lint", "nx run-many -t lint")];
Project {
dir: PathBuf::from("/repo"),
name: "mono".into(),
manager: Manager::Pnpm,
scripts: scripts.iter().map(|(n, c)| Script { name: n.to_string(), command: c.to_string() }).collect(),
}
}

fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}

fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}

fn type_str(app: &mut App, text: &str) {
for c in text.chars() {
app.handle_key(key(KeyCode::Char(c)));
}
}

#[test]
fn enter_runs_the_selected_script_in_a_tab() {
let mut app = App::new(project());
assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::Run { script: 0, target: Target::Tab });
}

#[test]
fn ctrl_v_and_ctrl_x_run_in_splits() {
let mut app = App::new(project());
app.handle_key(key(KeyCode::Down));
assert_eq!(app.handle_key(ctrl('v')), Command::Run { script: 1, target: Target::SplitRight });
assert_eq!(app.handle_key(ctrl('x')), Command::Run { script: 1, target: Target::SplitDown });
}

#[test]
fn typing_filters_and_resets_selection() {
let mut app = App::new(project());
app.handle_key(key(KeyCode::Down));
type_str(&mut app, "lint");
assert_eq!(app.selected, 0);
assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::Run { script: 2, target: Target::Tab });
}

#[test]
fn selection_wraps() {
let mut app = App::new(project());
app.handle_key(key(KeyCode::Up));
assert_eq!(app.selected, 2);
app.handle_key(ctrl('n'));
assert_eq!(app.selected, 0);
}

#[test]
fn no_matches_makes_enter_a_no_op() {
let mut app = App::new(project());
type_str(&mut app, "zzzz");
assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::Continue);
}

#[test]
fn editing_clears_the_error_and_ctrl_u_clears_the_query() {
let mut app = App::new(project());
type_str(&mut app, "ap");
app.fail("boom".into());
app.handle_key(key(KeyCode::Backspace));
assert_eq!((app.query.as_str(), app.error.as_deref()), ("a", None));
app.handle_key(ctrl('u'));
assert_eq!(app.ranked.len(), 3);
}

#[test]
fn esc_and_ctrl_c_quit() {
let mut app = App::new(project());
assert_eq!(app.handle_key(key(KeyCode::Esc)), Command::Quit);
assert_eq!(app.handle_key(ctrl('c')), Command::Quit);
}
}
Loading
Loading