5 Commits
Author SHA1 Message Date
swaitsandClaude Opus 4.7 cbc54ba685 chore(release): 0.4.0
CI / check / clippy / fmt (push) Canceled after 0s
CI / build (1.85) (push) Canceled after 0s
CI / build (stable) (push) Canceled after 0s
Restores `jj show` support, broken by the 0.3.0 dispatch rewrite.
`jj show` accepts its revision as a positional `[REVSET]` argument
rather than a `-r` option, which the help-driven dispatch didn't
recognize — so bare `jjf` (which defaults to `show`) errored instead
of opening the picker. A revision named as a bare positional now also
bypasses the picker, like an explicit `-r`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:58:43 -06:00
swaitsandClaude Opus 4.7 7390b92a40 fix(dispatch): detect jj show's positional revset argument
The 0.3.0 help-driven dispatch only scanned a leaf's `Options:` block
for a revset flag. `jj show` takes its revision as a positional
`[REVSET]` argument, not an option, so it looked like it accepted no
revset — and bare `jjf` (which defaults to `show`) errored with
"takes no revset flag" instead of opening the picker.

- Read the `Arguments:` block too: a positional `[REVSET]`/`[REVSETS]`
  that exposes `-r` as an alias is recognized, and jjf injects
  `-r <picked>` for it.
- A bare positional in the passthrough now counts as the user having
  named the revision, so `jjf show @-` bypasses the picker like an
  explicit `-r` would. Option values are stepped over (parsing every
  `Options:` section, including globals) so they aren't mistaken for
  the positional.
- The picker-bypass check also accepts the inline `--revision=<rev>` /
  `--to=<rev>` form, not just space-separated flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:58:00 -06:00
swaits 40a83b388a chore(release): 0.3.0
Subcommand dispatch is now help-driven instead of substring-matching
"REVSET", so multi-level subcommands (`tag set`, `bookmark move`, …)
and non-`-r` revset flags (`--to`) work, and pre-supplied revset
flags bypass the picker instead of being double-injected.
2026-05-04 12:58:47 -06:00
swaits 6ce9cb2b17 feat(dispatch): walk subcommands and detect revset flag from help
Two real failures motivated this:

- `jjf bm main` (where `bm` is a user alias for `bookmark move`) ran
  `jj bm main -r <id>` — but bookmark move uses `--to`, not `-r`.
- `jjf tag set v0.2.0` errored with "doesn't take revisions" because
  jjf only inspected `jj tag --help` (parent) instead of walking
  into the `set` leaf, which does take `-r`.

Replace the boolean `supports_revisions()` (a substring match on
"REVSET") with `RevsetTarget { leaf, passthrough, flag }`:

- Walks multi-level subcommands by parsing each parent's `Commands:`
  block — including `[aliases: …]` annotations, so `jjf tag s` walks
  to `tag set`. Aliases like `bm` work transparently because `jj bm
  --help` already emits the resolved leaf's help.
- Parses the leaf's `Options:` block and picks the revset flag in
  priority order: `-r`/`--revision[s]` first, then `-t, --to`.
  `--from` is intentionally not a fallback (different semantics from
  "the revision the picker chose").
- Bypasses the picker entirely when the user already passed a revset
  flag in their args, so `jjf describe -r @-` runs verbatim instead
  of double-injecting `-r`.

`exec` and `command_line` now take `&RevsetTarget` and emit the
resolved flag; signatures thread through `tui.rs` and `main.rs`.

New unit tests cover the help parsers and command-line construction
against captured fixtures of `jj tag set --help`, `jj bookmark move
--help`, and `jj tag --help`.
2026-05-04 12:58:14 -06:00
swaitsandClaude Opus 4.7 1c4ae09646 chore(release): 0.2.0
Picker now renders jj's real graph (merge connectors and all). Cursor
and filter skip decorative connector rows; hint row drops trailing
key/label pairs to fit a single line on narrow terminals; cursor
arrow glyphs changed to ●──▶.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:38:20 -06:00
7 changed files with 1008 additions and 95 deletions

No files matched your search

+100
View File
@@ -5,6 +5,106 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] — 2026-05-22
Restores `jj show` support, which the 0.3.0 dispatch rewrite broke:
bare `jjf` (which defaults to `show`) works again, and a revision
named as a bare positional bypasses the picker.
### Fixed
- Bare `jjf` (which defaults to the `show` subcommand) no longer errors
with "'jj show' takes no revset flag (-r/--to)". The 0.3.0 help-driven
dispatch only scanned a leaf's `Options:` block, but `jj show` accepts
its revision as a positional `[REVSET]` argument. jjf now also reads
the `Arguments:` block and recognizes a positional revset that exposes
`-r` as an alias, injecting `-r <picked>` as before.
- `jjf show <rev>` (a revision passed as a bare positional, e.g.
`jjf show @-`) now bypasses the picker and runs verbatim, instead of
launching the picker and appending a second, conflicting `-r`. The
bypass check previously only recognized explicit revset *flags*; it
now also recognizes a bare positional for positional-revset leaves.
Option values are not mistaken for positionals — jjf reads the leaf's
full option set (including `Global Options:`) to step over them, so
`jjf show -T <template>` still opens the picker.
### Added
- The bypass check also recognizes the inline `--revision=<rev>` /
`--to=<rev>` form, not just space-separated flags.
[0.4.0]: https://github.com/swaits/jjf/releases/tag/v0.4.0
## [0.3.0] — 2026-05-04
Subcommand dispatch is now help-driven instead of substring-matching
"REVSET", so multi-level subcommands and non-`-r` revset flags work.
### Fixed
- `jjf bm main` (where `bm` is a user alias for `bookmark move`) now
correctly emits `jj bm main --to <picked>` instead of `-r <picked>`,
which jj rejected. jjf parses the leaf's `Options:` block and picks
`-r`/`--revision[s]` first, then `-t, --to`. `--from` is intentionally
not a fallback (different semantics).
- `jjf tag set v0.2.0` no longer errors with "doesn't take revisions".
jjf now walks into multi-level subcommands by parsing each parent's
`Commands:` block, so `tag set`, `bookmark move`, `git push`, etc.
resolve to their actual leaf and read its real flag set.
### Added
- Bypass mode when the user already passed a revset flag in their args.
`jjf describe -r @-` now runs verbatim instead of double-injecting
`-r`. Triggers on any of `-r`, `--revision`, `--revisions`, `-t`,
`--to` in passthrough.
- Refusal message for leaves with no revset flag now includes the
resolved leaf path (e.g. `jjf workspace list` → "'jj workspace list'
takes no revset flag — run 'jj workspace list' directly") so users
see exactly which command jjf landed on.
- Unit tests for the help parsers and command-line construction
against captured fixtures of `jj tag set --help`,
`jj bookmark move --help`, and `jj tag --help`.
[0.3.0]: https://github.com/swaits/jjf/releases/tag/v0.3.0
## [0.2.0] — 2026-04-29
Picker now renders jj's real graph (merge connectors and all), with the
cursor/filter aware of decorative rows.
### Changed
- Graph rendering now uses jj's native `--graph` output instead of a
custom one-row-per-commit reconstruction. Merge connectors
(`├─┬─╮`, `├─╯`, `╰─╯`) appear exactly as `jj log` draws them, so the
picker is a 1:1 visual match for the real log.
- Cursor row arrow glyphs changed from `├──►` to `●──▶` so the
selection arrow doesn't visually clash with the graph chrome.
- Hint row at the bottom of the picker drops trailing key/label pairs
(`esc quit`, `^U clear`, …) until it fits on one row. Previously a
narrow terminal would wrap the hint and push the search bar off the
top of the screen.
### Added
- Connector / decorative graph rows (`├─╮`, `╰─╯`, `~`, …) are kept in
the picker for visual fidelity with `jj log`. Cursor navigation
(arrows, Page{Up,Down}, Home, End, Ctrl-N/P/J/K, Ctrl-A/E) skips over
them; selection is silently ignored on connector rows.
- Filtering (any non-empty query) excludes connector rows, since they
can't match a query and a stray `├─╮` between filtered hits would
carry no useful information.
### Fixed
- Long descriptions no longer split across rows when the user has
`ui.log-word-wrap = true` configured globally — the internal
`jj log` invocation now forces `ui.log-word-wrap=false` so a single
commit always renders on a single picker row.
[0.2.0]: https://github.com/swaits/jjf/releases/tag/v0.2.0
## [0.1.0] — 2026-04-29
Initial release.
Generated
+1 -1
View File
@@ -99,7 +99,7 @@ dependencies = [
[[package]]
name = "jjf"
version = "0.1.0"
version = "0.4.0"
dependencies = [
"anyhow",
"crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jjf"
version = "0.1.0"
version = "0.4.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
+1 -1
View File
@@ -12,7 +12,7 @@ command that's about to run.
❯ vx ┌────────────────────────────────
○ tovs chore(release): prepare 0.1.0 │ - Add LICENSE (MIT)
@ xnkx feat(ux): more refinements │ - Add README with install,
○ ssnu feat(ux): batch UX improvements ├──►│ usage, keybindings…
○ ssnu feat(ux): batch UX improvements ●──▶│ usage, keybindings…
○ lyxo fix(history): drop jjf entry │
○ tqvr feat: two-column layout │ M Cargo.toml
○ yksz feat: default to 'show' │ A LICENSE
+768 -37
View File
@@ -4,10 +4,24 @@ use anyhow::{Context, Result};
const SEP: u8 = 0x1f;
// Custom oneline template: drop the timestamp and commit id (rarely useful in
// the picker, and the commit-id hex steals real estate), keep change-id with
// shortest-unique-prefix highlighting, bookmarks, conflict/empty labels, and
// the first line of the description.
// Custom oneline template emitting field-separated values. We run jj with
// its native `--graph` so the merge connectors (`├─┬─╮`, `╰─╯`) render
// exactly as they do in `jj log` — the picker treats those connector-only
// rows as decoration and keeps the cursor on commit rows.
//
// The template MUST emit a single line per commit (no embedded `\n`,
// `ui.log-word-wrap=false`), or jj's graph drawer would inject continuation
// rows into our one-line invariant.
//
// Fields (\x1f-separated, leading `\x1f` separates jj's graph chrome from
// our fields):
// 0 graph chrome (everything jj prepended before the first \x1f)
// 1 change_id.short() — full 12-char change id
// 2 change_id.shortest().prefix() — shortest unique prefix
// 3 commit_id.short() — full 12-char commit id
// 4 commit_id.shortest().prefix() — shortest unique prefix
// 5 payload — change-id-prefix-highlighted, bookmarks, conflict/empty,
// description.first_line()
const TEMPLATE: &str = concat!(
"\"\\x1f\" ++ ",
"change_id.short() ++ \"\\x1f\" ++ ",
@@ -23,6 +37,8 @@ const TEMPLATE: &str = concat!(
))",
);
/// Either a commit row (selectable) or a connector row (decoration only —
/// `├─┬─╮`, `│ │`, `~`, …). Connectors have empty `change_id_short`.
pub struct Row {
pub change_id_short: String,
pub change_id_prefix: String,
@@ -32,10 +48,22 @@ pub struct Row {
pub styled: Vec<u8>,
}
impl Row {
pub fn is_connector(&self) -> bool {
self.change_id_short.is_empty()
}
}
pub fn capture_log() -> Result<Vec<Row>> {
let output = Command::new("jj")
.args([
"--ignore-working-copy",
// Force off any user `ui.log-word-wrap = true` — wrapping would
// split a description into a continuation row that looks
// syntactically identical to a graph connector row, breaking the
// one-row-per-commit invariant the picker relies on.
"--config",
"ui.log-word-wrap=false",
"log",
"--color=always",
"-r",
@@ -58,14 +86,15 @@ pub fn capture_log() -> Result<Vec<Row>> {
if raw.is_empty() {
continue;
}
if let Some(row) = parse_row(raw) {
rows.push(row);
}
rows.push(parse_row(raw));
}
Ok(rows)
}
fn parse_row(bytes: &[u8]) -> Option<Row> {
/// Parse one line of `jj log --graph` output. A line with our 5 `\x1f`
/// separators is a commit row; anything else is a connector / `~` /
/// continuation row that we keep as decoration but render unselectable.
fn parse_row(bytes: &[u8]) -> Row {
let mut parts: Vec<&[u8]> = Vec::with_capacity(6);
let mut start = 0;
for (i, &b) in bytes.iter().enumerate() {
@@ -78,7 +107,17 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
}
}
if parts.len() < 5 {
return None;
// Connector / `~` / pure-graph row — keep as decoration only.
let styled = bytes.to_vec();
let plain = strip_ansi(&styled);
return Row {
change_id_short: String::new(),
change_id_prefix: String::new(),
commit_id_short: String::new(),
commit_id_prefix: String::new(),
plain,
styled,
};
}
parts.push(&bytes[start..]);
@@ -89,24 +128,19 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
let commit_id_prefix = strip_ansi(parts[4]);
let payload = parts[5];
if change_id_short.is_empty() {
return None;
}
let mut styled = Vec::with_capacity(graph.len() + payload.len());
styled.extend_from_slice(graph);
styled.extend_from_slice(payload);
let plain = strip_ansi(&styled);
Some(Row {
Row {
change_id_short,
change_id_prefix,
commit_id_short,
commit_id_prefix,
plain,
styled,
})
}
}
/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice.
@@ -231,42 +265,459 @@ fn parse_show_summary(bytes: &[u8]) -> PreviewParts {
PreviewParts { description, files }
}
pub fn supports_revisions(subcommand: &str) -> Result<bool> {
/// Which flag a leaf jj subcommand uses to accept a revset, picked in
/// priority order: `-r`/`--revision[s]` first, then `-t, --to`. `--from` is
/// intentionally not a fallback — its semantics ("filter source revisions")
/// differ enough from "the revision the picker chose" that silently injecting
/// it would surprise users.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RevsetFlag {
Revision,
To,
}
impl RevsetFlag {
pub fn as_str(self) -> &'static str {
match self {
RevsetFlag::Revision => "-r",
RevsetFlag::To => "--to",
}
}
}
/// A jj invocation jjf has analyzed and is ready to dispatch on. `leaf` is
/// the resolved subcommand path (preserving the user's tokens, including
/// aliases like `bm` — jj resolves them again at exec time). `passthrough`
/// is everything after the leaf. `flag` is `None` when the leaf accepts no
/// revset; in that case jjf refuses to run the picker. `user_supplied_revset`
/// is true when the passthrough already pins down the revision (a revset
/// flag, or a bare positional for a positional-revset leaf like `jj show`),
/// in which case jjf bypasses the picker and runs the command verbatim.
#[derive(Debug, Clone)]
pub struct RevsetTarget {
pub leaf: Vec<String>,
pub passthrough: Vec<String>,
pub flag: Option<RevsetFlag>,
pub user_supplied_revset: bool,
}
pub fn resolve_target(args: &[String]) -> Result<RevsetTarget> {
debug_assert!(!args.is_empty(), "resolve_target requires at least one arg");
let first_help = match run_help(std::slice::from_ref(&args[0]))? {
Some(h) => h,
None => {
// Unknown subcommand or jj help failed for some reason. Fall
// through optimistically so jj surfaces its own error message at
// exec time.
return Ok(RevsetTarget {
leaf: vec![args[0].clone()],
passthrough: args[1..].to_vec(),
flag: Some(RevsetFlag::Revision),
// No help to consult — fall back to a plain revset-flag scan.
user_supplied_revset: has_revset_flag(&args[1..]),
});
}
};
let (leaf, leaf_help) = resolve_leaf(args, first_help)?;
let passthrough = args[leaf.len()..].to_vec();
let flag = parse_revset_flag(&leaf_help);
let user_supplied_revset = passthrough_pins_revset(&passthrough, &leaf_help);
Ok(RevsetTarget {
leaf,
passthrough,
flag,
user_supplied_revset,
})
}
fn resolve_leaf(args: &[String], first_help: String) -> Result<(Vec<String>, String)> {
let mut leaf = vec![args[0].clone()];
let mut help = first_help;
for arg in &args[1..] {
if arg.starts_with('-') {
break;
}
let children = parse_commands_section(&help);
if !children.contains(arg.as_str()) {
break;
}
let mut candidate = leaf.clone();
candidate.push(arg.clone());
match run_help(&candidate)? {
Some(next_help) => {
leaf = candidate;
help = next_help;
}
None => break,
}
}
Ok((leaf, help))
}
fn run_help(path: &[String]) -> Result<Option<String>> {
let output = Command::new("jj")
.arg(subcommand)
.args(path)
.arg("--help")
.stderr(Stdio::null())
.output()
.context("failed to spawn jj for preflight")?;
if !output.status.success() {
// unknown subcommand or other error — let the main flow surface jj's error
return Ok(true);
return Ok(None);
}
let help = String::from_utf8_lossy(&output.stdout);
Ok(help.contains("REVSET"))
Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
}
pub fn exec(subcommand: &str, passthrough: &[String], ids: &[String]) -> Result<ExitStatus> {
let revset = ids.join("|");
let status = Command::new("jj")
.arg(subcommand)
.args(passthrough)
.arg("-r")
.arg(&revset)
.status()
.context("failed to spawn jj")?;
/// Parse the `Commands:` block of a jj help output. Returns the set of
/// subcommand names AND their `[aliases: …]` annotations, so a user typing
/// `jjf tag s` (where `s` is the alias for `set`) walks correctly.
fn parse_commands_section(help: &str) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
let mut in_commands = false;
for line in help.lines() {
if line == "Commands:" {
in_commands = true;
continue;
}
if !in_commands {
continue;
}
if line.is_empty() {
break;
}
if !line.starts_with(' ') {
break;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
// Primary entries have exactly 2-space indent. Anything deeper is
// a description continuation we don't care about.
if indent != 2 {
continue;
}
if let Some(name) = trimmed.split_whitespace().next() {
set.insert(name.to_string());
}
scan_aliases(line, &mut set);
}
set
}
fn scan_aliases(line: &str, names: &mut std::collections::HashSet<String>) {
let Some(start) = line.find("[aliases:") else {
return;
};
let after = &line[start + "[aliases:".len()..];
let Some(end) = after.find(']') else {
return;
};
for a in after[..end].split(',') {
let n = a.trim();
if !n.is_empty() {
names.insert(n.to_string());
}
}
}
/// Pick the right revset-accepting flag from a leaf's help, preferring
/// `-r`/`--revision[s]` over `-t, --to`. Falls back to a positional
/// `[REVSET]` argument that exposes `-r` as an alias (this is how `jj show`
/// takes a revision). Returns `None` if the leaf has none of those.
pub fn parse_revset_flag(help: &str) -> Option<RevsetFlag> {
let opts = parse_options(help);
for opt in &opts {
if opt.is_revset
&& opt
.longs
.iter()
.any(|n| n == "--revision" || n == "--revisions")
{
return Some(RevsetFlag::Revision);
}
}
for opt in &opts {
if opt.is_revset && opt.longs.iter().any(|n| n == "--to") {
return Some(RevsetFlag::To);
}
}
// No revset *option* — fall back to a positional `[REVSET]` argument.
// `jj show` accepts its revision this way; clap annotates the positional
// with `[aliases: -r]` precisely so tools can inject it as `-r <rev>`.
if positional_revset_accepts_r(help) {
return Some(RevsetFlag::Revision);
}
None
}
/// True when the leaf takes its revset as a positional `[REVSET]`/`[REVSETS]`
/// argument that exposes `-r` (or `--revision`) as an alias. Such a positional
/// lives in the `Arguments:` block, which `parse_options` never scans, so
/// without this `jj show` would look like it accepts no revset at all.
///
/// The `-r` alias is required: jjf's dispatch injects the picked revision via
/// a flag, so a bare positional with no alias can't be targeted.
fn positional_revset_accepts_r(help: &str) -> bool {
let mut in_args = false;
let mut current_is_revset = false;
for line in help.lines() {
if line == "Arguments:" {
in_args = true;
continue;
}
if !in_args {
continue;
}
// A non-indented non-empty line ends the `Arguments:` block.
if !line.is_empty() && !line.starts_with(' ') {
break;
}
if line.is_empty() {
continue;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
// New positional entry: 2-space indent, `[NAME]` or `<NAME>` (e.g.
// `[REVSET]`, `<NAMES>...`, `[FILESETS]...`). Deeper indent is a
// description / continuation line for the current entry.
if indent == 2 && trimmed.starts_with(['[', '<']) {
let name = trimmed
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(['[', ']', '<', '>'])
.trim_end_matches("...")
.trim_matches(['[', ']', '<', '>']);
current_is_revset = matches!(name, "REVSET" | "REVSETS");
}
// `[aliases: …]` for a positional appears on its description line.
if current_is_revset && line.contains("[aliases:") {
let mut aliases = std::collections::HashSet::new();
scan_aliases(line, &mut aliases);
if aliases.iter().any(|a| a == "-r" || a == "--revision") {
return true;
}
}
}
false
}
/// Whether the user's own args already pin down the revision, so jjf should
/// skip the picker and run their command verbatim. True when the passthrough
/// carries an explicit revset flag, or — for a leaf whose revset is a
/// positional `[REVSET]` argument (`jj show`) — when it carries a bare
/// positional token.
fn passthrough_pins_revset(passthrough: &[String], leaf_help: &str) -> bool {
if has_revset_flag(passthrough) {
return true;
}
if positional_revset_accepts_r(leaf_help) {
return passthrough_has_bare_positional(passthrough, &value_flags(leaf_help));
}
false
}
/// True when the passthrough already carries an explicit revset flag
/// (`-r`/`--revision`/`--revisions`/`-t`/`--to`), whether as its own token or
/// in long-flag `--flag=value` form.
fn has_revset_flag(passthrough: &[String]) -> bool {
passthrough.iter().any(|a| {
let name = a.split('=').next().unwrap_or(a);
matches!(name, "-r" | "--revision" | "--revisions" | "-t" | "--to")
})
}
/// Scan a leaf's passthrough args for a bare positional token — one that is
/// neither an option flag nor consumed as the value of one. `value_flags` is
/// the set of option spellings known to take a following value, so `-T tmpl`
/// or `--color always` aren't mistaken for a positional. For a positional-
/// revset leaf, a bare positional means the user already named the revision.
fn passthrough_has_bare_positional(
passthrough: &[String],
value_flags: &std::collections::HashSet<String>,
) -> bool {
let mut i = 0;
while i < passthrough.len() {
let tok = passthrough[i].as_str();
if tok == "--" {
// Everything after a bare `--` is positional.
return i + 1 < passthrough.len();
}
if let Some(body) = tok.strip_prefix("--") {
// Long flag. `--flag=value` carries its value inline; otherwise a
// value-taking flag consumes the next token.
let name = format!("--{}", body.split('=').next().unwrap_or(body));
if !body.contains('=') && value_flags.contains(&name) {
i += 1;
}
i += 1;
continue;
}
if tok.starts_with('-') && tok.len() > 1 {
// Short flag. `-r value` consumes the next token; an attached
// value (`-rvalue`) or bundled bool shorts (`-sw`) do not.
if value_flags.contains(tok) {
i += 1;
}
i += 1;
continue;
}
// Not a flag and not consumed as a value — a positional argument.
return true;
}
false
}
/// Every option spelling (short `-x` and long `--long`) that consumes a
/// following value token, gathered across all of a leaf's `Options:` sections.
fn value_flags(help: &str) -> std::collections::HashSet<String> {
let mut flags = std::collections::HashSet::new();
for opt in parse_options(help) {
if opt.takes_value {
for f in opt.shorts.into_iter().chain(opt.longs) {
flags.insert(f);
}
}
}
flags
}
#[derive(Debug)]
struct OptionEntry {
shorts: Vec<String>,
longs: Vec<String>,
/// True when the header carried a `<PLACEHOLDER>` — i.e. the option
/// consumes a following value token.
takes_value: bool,
/// True when the placeholder is `<REVSET>` or `<REVSETS>`.
is_revset: bool,
}
/// Parse every option section of a leaf's help — the primary `Options:`, any
/// categorized `<Category> Options:`, and `Global Options:` — into a flat list
/// of entries. `[aliases: …]` continuation lines are folded into the owning
/// entry's name lists.
fn parse_options(help: &str) -> Vec<OptionEntry> {
let mut opts = Vec::new();
let mut in_options = false;
let mut current: Option<OptionEntry> = None;
for line in help.lines() {
// Section headers are non-indented, non-empty lines. Option entries
// live only under a header ending in `Options:` — `Arguments:`,
// `Commands:`, and the intro paragraph switch parsing back off.
if !line.is_empty() && !line.starts_with(' ') {
if let Some(o) = current.take() {
opts.push(o);
}
in_options = line.ends_with("Options:");
continue;
}
if !in_options {
continue;
}
if line.is_empty() {
// Blank lines separate options inside the section; don't end it.
continue;
}
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
// New entry: 2-space indent with `-X,` (short + long) OR 6-space
// indent with `--` (long-only flag, e.g. `--allow-move`).
let is_new_entry = (indent == 2 && trimmed.starts_with('-'))
|| (indent == 6 && trimmed.starts_with("--"));
if is_new_entry {
if let Some(o) = current.take() {
opts.push(o);
}
current = Some(parse_option_header(trimmed));
} else if let Some(ref mut o) = current {
// Continuation line. Pull any `[aliases: --foo, -x]` into the
// current option's name sets so callers see alias spellings too.
let mut alias_set = std::collections::HashSet::new();
scan_aliases(line, &mut alias_set);
for n in alias_set {
if n.starts_with("--") {
o.longs.push(n);
} else if n.starts_with('-') {
o.shorts.push(n);
}
}
}
}
if let Some(o) = current {
opts.push(o);
}
opts
}
fn parse_option_header(s: &str) -> OptionEntry {
let mut shorts = Vec::new();
let mut longs = Vec::new();
let mut placeholder: Option<String> = None;
for token in s.split(|c: char| c == ',' || c.is_whitespace()) {
let t = token.trim();
if t.is_empty() {
continue;
}
if let Some(rest) = t.strip_prefix("--") {
// Strip a trailing `=...` if clap ever emits one (it doesn't
// today, but cheap insurance).
let name = rest.split('=').next().unwrap_or(rest);
longs.push(format!("--{name}"));
} else if t.starts_with('-') {
// Short flag, e.g. `-r`.
shorts.push(t.to_string());
} else if let Some(stripped) = t.strip_prefix('<') {
// Placeholder: `<REVSET>`, `<REVSETS>`, `<NAMES>...`, etc.
let inner = stripped.trim_end_matches("...");
if let Some(end) = inner.find('>') {
placeholder = Some(inner[..end].to_string());
}
}
}
let is_revset = matches!(placeholder.as_deref(), Some("REVSET") | Some("REVSETS"));
OptionEntry {
shorts,
longs,
takes_value: placeholder.is_some(),
is_revset,
}
}
pub fn exec(target: &RevsetTarget, ids: &[String]) -> Result<ExitStatus> {
let mut cmd = Command::new("jj");
cmd.args(&target.leaf);
cmd.args(&target.passthrough);
if !ids.is_empty() {
if let Some(flag) = target.flag {
cmd.arg(flag.as_str());
cmd.arg(ids.join("|"));
}
}
let status = cmd.status().context("failed to spawn jj")?;
Ok(status)
}
pub fn command_line(subcommand: &str, passthrough: &[String], ids: &[String]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(4 + passthrough.len());
pub fn command_line(target: &RevsetTarget, ids: &[String]) -> String {
let mut parts: Vec<String> =
Vec::with_capacity(2 + target.leaf.len() + target.passthrough.len() + 2);
parts.push("jj".into());
parts.push(subcommand.into());
for a in passthrough {
for w in &target.leaf {
parts.push(w.clone());
}
for a in &target.passthrough {
parts.push(shell_quote(a));
}
if !ids.is_empty() {
parts.push("-r".into());
parts.push(shell_quote(&ids.join("|")));
if let Some(flag) = target.flag {
parts.push(flag.as_str().into());
parts.push(shell_quote(&ids.join("|")));
}
}
parts.join(" ")
}
@@ -292,3 +743,283 @@ pub fn shell_quote(s: &str) -> String {
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
const TAG_SET_HELP: &str = "Create or update tags
Usage: jj tag set [OPTIONS] <NAMES>...
Arguments:
<NAMES>...
Tag names to create or update
Options:
-r, --revision <REVSET>
Target revision to point to
[default: @]
[aliases: --to]
--allow-move
Allow moving existing tags
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const BOOKMARK_MOVE_HELP: &str = "Move existing bookmarks to target revision
Usage: jj bookmark move [OPTIONS] <NAMES|--from <REVSETS>>
Arguments:
[NAMES]...
Move bookmarks matching the given name patterns
Options:
-f, --from <REVSETS>
Move bookmarks from the given revisions
-t, --to <REVSET>
Move bookmarks to this revision
[default: @]
-B, --allow-backwards
Allow moving bookmarks backwards or sideways
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const TAG_PARENT_HELP: &str = "Manage tags
Usage: jj tag [OPTIONS] <COMMAND>
Commands:
delete Delete existing tags [aliases: d]
list List tags and their targets [aliases: l]
set Create or update tags [aliases: s]
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
const SHOW_HELP: &str = "Show commit description and changes in a revision
Usage: jj show [OPTIONS] [REVSET]
Arguments:
[REVSET]
Show changes in this revision, compared to its parent(s) [default: @] [aliases: -r]
Options:
-T, --template <TEMPLATE>
Render a revision using the given template
-h, --help
Print help (see a summary with '-h')
Diff Formatting Options:
-s, --summary
For each path, show only whether it was modified, added, or deleted
--tool <TOOL>
Generate diff by external command
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
// A leaf with a positional revset but no `-r` alias jjf could inject.
const LOG_HELP: &str = "Show revision history
Usage: jj log [OPTIONS] [FILESETS]...
Arguments:
[FILESETS]...
Show revisions modifying the given paths
Options:
-r, --revision <REVSETS>
Which revisions to show
-h, --help
Print help (see a summary with '-h')
Global Options:
-R, --repository <REPOSITORY>
Path to repository
";
#[test]
fn revset_flag_detects_show_positional() {
// `jj show` takes its revset as the positional `[REVSET]` argument,
// annotated `[aliases: -r]`. jjf must inject `-r <picked>`.
assert_eq!(parse_revset_flag(SHOW_HELP), Some(RevsetFlag::Revision));
}
#[test]
fn revset_flag_log_uses_option_not_positional() {
// `jj log` has a `[FILESETS]...` positional (not a revset) and a real
// `-r, --revision` option — the option must drive the result.
assert_eq!(parse_revset_flag(LOG_HELP), Some(RevsetFlag::Revision));
assert!(!positional_revset_accepts_r(LOG_HELP));
}
#[test]
fn revset_flag_prefers_revision_over_to_alias() {
// tag set has `-r, --revision <REVSET>` with `[aliases: --to]`.
// The Revision-class match must win over the To-class match.
assert_eq!(parse_revset_flag(TAG_SET_HELP), Some(RevsetFlag::Revision));
}
#[test]
fn revset_flag_falls_back_to_to_when_no_revision() {
// bookmark move has only `-f, --from <REVSETS>` and `-t, --to <REVSET>`.
assert_eq!(parse_revset_flag(BOOKMARK_MOVE_HELP), Some(RevsetFlag::To));
}
#[test]
fn revset_flag_none_for_parent_without_options() {
assert_eq!(parse_revset_flag(TAG_PARENT_HELP), None);
}
#[test]
fn commands_section_includes_names_and_aliases() {
let cmds = parse_commands_section(TAG_PARENT_HELP);
for name in ["delete", "list", "set", "d", "l", "s"] {
assert!(cmds.contains(name), "missing {name} in {cmds:?}");
}
}
#[test]
fn commands_section_empty_for_leaf() {
let cmds = parse_commands_section(BOOKMARK_MOVE_HELP);
assert!(cmds.is_empty(), "expected no commands, got {cmds:?}");
}
#[test]
fn command_line_emits_revision_flag() {
let target = RevsetTarget {
leaf: vec!["tag".into(), "set".into()],
passthrough: vec!["v0.2.0".into()],
flag: Some(RevsetFlag::Revision),
user_supplied_revset: false,
};
let s = command_line(&target, &["abcd1234".into()]);
assert_eq!(s, "jj tag set v0.2.0 -r abcd1234");
}
#[test]
fn command_line_emits_to_flag() {
let target = RevsetTarget {
leaf: vec!["bm".into()],
passthrough: vec!["main".into()],
flag: Some(RevsetFlag::To),
user_supplied_revset: false,
};
let s = command_line(&target, &["abcd1234".into()]);
assert_eq!(s, "jj bm main --to abcd1234");
}
#[test]
fn command_line_omits_flag_when_no_ids() {
let target = RevsetTarget {
leaf: vec!["describe".into()],
passthrough: vec!["-r".into(), "@".into()],
flag: Some(RevsetFlag::Revision),
user_supplied_revset: true,
};
// When the user already supplied -r, jjf calls command_line with empty
// ids — no extra flag should be appended.
let s = command_line(&target, &[]);
assert_eq!(s, "jj describe -r @");
}
fn pins(passthrough: &[&str], help: &str) -> bool {
let owned: Vec<String> = passthrough.iter().map(|s| s.to_string()).collect();
passthrough_pins_revset(&owned, help)
}
#[test]
fn show_bare_positional_pins_revset() {
// `jjf show @` / `jjf show abc123` — the revision is already named as
// the positional `[REVSET]`, so jjf must bypass the picker.
assert!(pins(&["@"], SHOW_HELP));
assert!(pins(&["abc123"], SHOW_HELP));
assert!(pins(&["@", "--summary"], SHOW_HELP));
assert!(pins(&["--summary", "@"], SHOW_HELP));
}
#[test]
fn show_without_revision_runs_picker() {
// No revision named — jjf should fall through to the picker. Bare
// `jjf show`, and `jjf show` with only flags, must NOT count as pinned.
assert!(!pins(&[], SHOW_HELP));
assert!(!pins(&["--summary"], SHOW_HELP));
assert!(!pins(&["-s"], SHOW_HELP));
}
#[test]
fn show_option_value_is_not_a_positional() {
// The value of a value-taking option must not be mistaken for the
// positional revset: `-T tmpl` / `--tool meld` still want the picker.
assert!(!pins(&["-T", "mytemplate"], SHOW_HELP));
assert!(!pins(&["--tool", "meld"], SHOW_HELP));
// ...but a real positional alongside such an option still pins it.
assert!(pins(&["-T", "mytemplate", "@"], SHOW_HELP));
}
#[test]
fn show_explicit_revset_flag_pins_revset() {
// The `-r` alias and the `--revision=@` inline form both count.
assert!(pins(&["-r", "@"], SHOW_HELP));
assert!(pins(&["--revision=@"], SHOW_HELP));
}
#[test]
fn dashdash_separates_positionals() {
assert!(pins(&["--", "@"], SHOW_HELP));
assert!(!pins(&["--"], SHOW_HELP));
}
#[test]
fn option_leaf_bare_positional_does_not_pin_revset() {
// `jj tag set v0.2.0` — `v0.2.0` is the `<NAMES>` positional, NOT a
// revset. Only an explicit `-r`/`--to` flag pins the revision here, so
// jjf still runs the picker for the target revision.
assert!(!pins(&["v0.2.0"], TAG_SET_HELP));
assert!(pins(&["v0.2.0", "-r", "@"], TAG_SET_HELP));
}
#[test]
fn value_flags_span_all_option_sections() {
// Value-taking flags are gathered from `Options:`, the categorized
// `Diff Formatting Options:`, and `Global Options:` alike; bool flags
// and `--help` are excluded.
let vf = value_flags(SHOW_HELP);
for f in ["-T", "--template", "--tool", "-R", "--repository"] {
assert!(vf.contains(f), "missing value flag {f} in {vf:?}");
}
for f in ["-s", "--summary", "-h", "--help"] {
assert!(!vf.contains(f), "{f} should not be a value flag");
}
}
}
+40 -20
View File
@@ -25,19 +25,19 @@ fn real_main() -> Result<u8> {
match first.as_str() {
"init" => run_init(args),
"--emit" => match args.next() {
Some(sub) => {
let passthrough: Vec<String> = args.collect();
run_pick(&sub, &passthrough, /* emit = */ true)
}
None => {
"--emit" => {
let rest: Vec<String> = args.collect();
if rest.is_empty() {
eprintln!("usage: jjf --emit <jj-subcommand> [args...]");
Ok(2)
return Ok(2);
}
},
sub => {
let passthrough: Vec<String> = args.collect();
run_pick(sub, &passthrough, /* emit = */ false)
run_pick(&rest, /* emit = */ true)
}
_ => {
let mut all = Vec::with_capacity(1 + args.size_hint().0);
all.push(first);
all.extend(args);
run_pick(&all, /* emit = */ false)
}
}
}
@@ -65,10 +65,30 @@ fn run_init(mut args: impl Iterator<Item = String>) -> Result<u8> {
}
}
fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8> {
if !jj::supports_revisions(subcommand)? {
fn run_pick(args: &[String], emit: bool) -> Result<u8> {
let target = jj::resolve_target(args)?;
// Case 1: user already pinned the revision — a revset flag, or a bare
// positional for a positional-revset leaf like `jj show`. Bypass the
// picker entirely and run their command verbatim. Avoids double-`-r` and
// respects what they explicitly asked for.
if target.user_supplied_revset {
if emit {
let cmd = jj::command_line(&target, &[]);
println!("{cmd}");
return Ok(0);
}
echo_command(&target, &[]);
let status = jj::exec(&target, &[])?;
return Ok(status.code().unwrap_or(1) as u8);
}
// Case 2: leaf accepts no revset flag at all (e.g. `jj operation`,
// `jj workspace list`). Refuse to run the picker.
if target.flag.is_none() {
let leaf = target.leaf.join(" ");
eprintln!(
"jjf: 'jj {subcommand}' doesn't take revisions — run 'jj {subcommand}' directly."
"jjf: 'jj {leaf}' takes no revset flag (-r/--to) — run 'jj {leaf}' directly."
);
return Ok(2);
}
@@ -89,7 +109,7 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
None
};
let tui_result = tui::run(rows, subcommand, passthrough);
let tui_result = tui::run(rows, &target);
if let Some(saved) = saved_stdout {
restore_stdout(saved)?;
@@ -103,12 +123,12 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
}
if emit {
let cmd = jj::command_line(subcommand, passthrough, &ids);
let cmd = jj::command_line(&target, &ids);
println!("{cmd}");
Ok(0)
} else {
echo_command(subcommand, passthrough, &ids);
let status = jj::exec(subcommand, passthrough, &ids)?;
echo_command(&target, &ids);
let status = jj::exec(&target, &ids)?;
Ok(status.code().unwrap_or(1) as u8)
}
}
@@ -142,9 +162,9 @@ fn restore_stdout(saved: libc::c_int) -> Result<()> {
Ok(())
}
fn echo_command(subcommand: &str, passthrough: &[String], ids: &[String]) {
fn echo_command(target: &jj::RevsetTarget, ids: &[String]) {
use std::io::IsTerminal;
let cmd = jj::command_line(subcommand, passthrough, ids);
let cmd = jj::command_line(target, ids);
if std::io::stdout().is_terminal() {
println!("\x1b[2m$\x1b[0m {cmd}");
} else {
+97 -35
View File
@@ -68,7 +68,7 @@ struct App {
impl App {
fn new(rows: Vec<Row>) -> Self {
let n = rows.len();
Self {
let mut app = Self {
rows,
filter: String::new(),
cursor: 0,
@@ -78,7 +78,39 @@ impl App {
matcher: Matcher::new(Config::DEFAULT),
last_height: 0,
preview_cache: HashMap::new(),
};
// Land the initial cursor on the first commit (connectors are
// unselectable, so a commit log starting with a `~` placeholder
// shouldn't park the cursor on it).
app.cursor = app.next_selectable(0, 1).unwrap_or(0);
app
}
/// Returns whether row `i` (in `self.rows`) is a connector / decorative
/// graph row that the cursor should skip over.
fn row_is_connector(&self, row_idx: usize) -> bool {
self.rows
.get(row_idx)
.is_some_and(|r| r.is_connector())
}
/// Find the nearest selectable position in `self.filtered` starting at
/// `from`, scanning by `step` (`1` = down, `-1` = up). Returns `None` if
/// every entry in the search direction is a connector.
fn next_selectable(&self, from: usize, step: isize) -> Option<usize> {
let len = self.filtered.len();
if len == 0 {
return None;
}
let mut idx = from as isize;
while (0..len as isize).contains(&idx) {
let row_idx = self.filtered[idx as usize];
if !self.row_is_connector(row_idx) {
return Some(idx as usize);
}
idx += step;
}
None
}
fn preview_for(&mut self, row_idx: usize) -> &PreviewParts {
@@ -91,8 +123,13 @@ impl App {
fn refilter(&mut self) {
if self.filter.is_empty() {
// Empty query: show every row including connectors so the user
// sees the full graph chrome.
self.filtered = (0..self.rows.len()).collect();
} else {
// Non-empty query: only commits can match. Connectors carry no
// searchable content, so dropping them here also keeps the
// result list compact and free of orphaned graph fragments.
let App {
rows,
matcher,
@@ -105,6 +142,7 @@ impl App {
let mut scored: Vec<(usize, u32)> = rows
.iter()
.enumerate()
.filter(|(_, row)| !row.is_connector())
.filter_map(|(i, row)| {
score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s))
})
@@ -112,7 +150,7 @@ impl App {
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
self.filtered = scored.into_iter().map(|(i, _)| i).collect();
}
self.cursor = 0;
self.cursor = self.next_selectable(0, 1).unwrap_or(0);
self.view_offset = 0;
}
@@ -121,28 +159,34 @@ impl App {
if len == 0 {
return;
}
let mut c = self.cursor as isize + delta;
if c < 0 {
c = 0;
}
let max = len as isize - 1;
if c > max {
c = max;
let target_pos = (self.cursor as isize + delta).clamp(0, max) as usize;
// Scan past connector rows in the direction of motion. If we hit a
// wall (e.g. PageDown near the end with only connectors below), fall
// back to scanning the opposite direction so we always land on a
// commit.
let primary_step: isize = if delta >= 0 { 1 } else { -1 };
if let Some(t) = self
.next_selectable(target_pos, primary_step)
.or_else(|| self.next_selectable(target_pos, -primary_step))
{
self.cursor = t;
self.adjust_view();
}
self.cursor = c as usize;
self.adjust_view();
}
fn jump_top(&mut self) {
self.cursor = 0;
self.cursor = self.next_selectable(0, 1).unwrap_or(0);
self.adjust_view();
}
fn jump_bottom(&mut self) {
if !self.filtered.is_empty() {
self.cursor = self.filtered.len() - 1;
self.adjust_view();
if self.filtered.is_empty() {
return;
}
let last = self.filtered.len() - 1;
self.cursor = self.next_selectable(last, -1).unwrap_or(last);
self.adjust_view();
}
fn adjust_view(&mut self) {
@@ -158,6 +202,7 @@ impl App {
fn toggle_select(&mut self) {
if let Some(&row_idx) = self.filtered.get(self.cursor)
&& !self.row_is_connector(row_idx)
&& !self.selected.insert(row_idx)
{
self.selected.remove(&row_idx);
@@ -233,8 +278,7 @@ fn score_row(
pub fn run(
rows: Vec<Row>,
subcommand: &str,
passthrough: &[String],
target: &jj::RevsetTarget,
) -> Result<Option<Vec<String>>> {
install_panic_hook();
let mut tty = OpenOptions::new()
@@ -250,7 +294,7 @@ pub fn run(
tty.write_all(HIDE_CURSOR).context("hide cursor")?;
tty.flush().ok();
let result = run_loop(&mut tty, rows, subcommand, passthrough);
let result = run_loop(&mut tty, rows, target);
let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
@@ -275,15 +319,14 @@ fn install_panic_hook() {
fn run_loop(
tty: &mut File,
rows: Vec<Row>,
subcommand: &str,
passthrough: &[String],
target: &jj::RevsetTarget,
) -> Result<Option<Vec<String>>> {
let mut app = App::new(rows);
let (mut term_cols, mut term_rows) = crossterm::terminal::size().unwrap_or((80, 24));
// Chrome rows: input(1) + cmd_preview(1) + hint(1) = 3.
app.last_height = (term_rows as usize).saturating_sub(3);
loop {
render(tty, &mut app, term_rows, term_cols, subcommand, passthrough)?;
render(tty, &mut app, term_rows, term_cols, target)?;
if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
continue;
}
@@ -409,8 +452,7 @@ fn render(
app: &mut App,
term_rows: u16,
term_cols: u16,
subcommand: &str,
passthrough: &[String],
target: &jj::RevsetTarget,
) -> Result<()> {
let viewport_y: u16 = 0;
let viewport_height = term_rows;
@@ -431,9 +473,9 @@ fn render(
// arrow fills from the text end through the separator and peeks into
// the box by exactly one column:
// col Y+1 ` ` (one space after text)
// col Y+2 `├` (arrow start)
// col Y+2 `●` (arrow start)
// cols Y+3 .. left_w+3 `─` (variable-length body)
// col left_w+4 `►` (peeks 1 col into the box)
// col left_w+4 `▶` (peeks 1 col into the box)
// col left_w+5 ` `
// col left_w+6 preview content (aligned with non-cursor)
let (left_w, right_x, right_w): (usize, u16, usize) = if preview_enabled {
@@ -448,7 +490,7 @@ fn render(
// Preview pane layout: top border (`┌─────`) sits on the *input row*,
// not the first log row, so log rows align 1:1 with preview content
// rows. Each preview content row is `│ <line>` (3-char gutter) or
// `├─►<line>` on the cursor's row, drawing the eye from picker
// `●─▶<line>` on the cursor's row, drawing the eye from picker
// selection into preview content.
let preview_gutter_w = 3;
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
@@ -521,17 +563,17 @@ fn render(
if preview_enabled {
if on_cursor {
// Dynamic arrow inline, starting at col visible_used+1:
// ` ` ├ ─×N ► ` ` then preview content at col left_w+6.
// ` ` ● ─×N ▶ ` ` then preview content at col left_w+6.
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("├".as_bytes());
buf.extend_from_slice("●".as_bytes());
let dash_count = (left_w + 1).saturating_sub(visible_used);
for _ in 0..dash_count {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(SGR_FG_CYAN);
buf.extend_from_slice("►".as_bytes());
buf.extend_from_slice("▶".as_bytes());
buf.extend_from_slice(SGR_RESET);
buf.push(b' ');
} else {
@@ -556,7 +598,7 @@ fn render(
let cmd_y = viewport_y + 1 + log_height as u16 + 1;
write!(buf, "\x1b[{};1H", cmd_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let cmd = jj::command_line(subcommand, passthrough, &app.pending_ids());
let cmd = jj::command_line(target, &app.pending_ids());
let cmd_max = (term_cols as usize).saturating_sub(2); // for "▶ "
let cmd_visible: String = if cmd.chars().count() <= cmd_max {
cmd
@@ -572,7 +614,9 @@ fn render(
buf.extend_from_slice(cmd_visible.as_bytes());
buf.extend_from_slice(SGR_RESET);
// Hint row (full width, below cmd preview).
// Hint row — must fit on a single line. If it wrapped, the wrapped
// tail would push the input row off the top of the terminal. So drop
// pairs from the right until the row fits in `term_cols`.
let hint_y = cmd_y + 1;
write!(buf, "\x1b[{};1H", hint_y)?;
let counts = if app.selected.is_empty() {
@@ -585,14 +629,32 @@ fn render(
app.selected.len()
)
};
let pairs: &[(&str, &str)] = &[
("↑↓/^N^P", "nav"),
("tab", "select"),
("enter", "run"),
("^U", "clear"),
("esc", "quit"),
];
let base_w = counts.chars().count() + "type filter".chars().count();
let max_w = term_cols as usize;
let mut used = base_w;
let mut keep = 0usize;
for (key, label) in pairs {
// " · " (3) + key + " " (1) + label
let w = 3 + key.chars().count() + 1 + label.chars().count();
if used + w > max_w {
break;
}
used += w;
keep += 1;
}
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice(counts.as_bytes());
buf.extend_from_slice(b"type filter");
write_hint_pair(&mut buf, "↑↓/^N^P".as_bytes(), b"nav");
write_hint_pair(&mut buf, b"tab", b"select");
write_hint_pair(&mut buf, b"enter", b"run");
write_hint_pair(&mut buf, b"^U", b"clear");
write_hint_pair(&mut buf, b"esc", b"quit");
for (key, label) in pairs.iter().take(keep) {
write_hint_pair(&mut buf, key.as_bytes(), label.as_bytes());
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);