Compare commits

..
12 Commits
Author SHA1 Message Date
swaits 13025f2d3b chore: repoint URLs at git.swaits.com and bump for release
CI / check / clippy / fmt (push) Canceled after 0s
CI / build (1.85) (push) Canceled after 0s
CI / build (stable) (push) Canceled after 0s
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:33 -06:00
swaits 49bcd4f501 chore: point repository at git.swaits.com
CI / check / clippy / fmt (push) Canceled after 0s
CI / build (1.85) (push) Canceled after 0s
CI / build (stable) (push) Canceled after 0s
2026-08-31 17:19:19 -06:00
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
swaits 3b63d67737 chore(release): 0.1.0
- Add LICENSE (MIT, copyright 2026 Stephen Waits)
- Add README.md reflecting the current TUI: two-column preview pane
  with jj show --summary, live command preview row, default-to-show
  behavior, full keybinding table, shell integration matrix, internals
  overview, attribution to oppi.li's original bash+fzf concept
- Add CHANGELOG.md (keep-a-changelog format) with 0.1.0 release notes
- Add .github/workflows/ci.yml — fmt + clippy + check on every push/PR;
  build matrix on stable + 1.85 (MSRV)
- Update Cargo.toml: refined description, repository/homepage/docs
  URLs, keywords + categories, rust-version=1.85, exclude .github and
  ux.txt from the published tarball
- Update .gitignore to also exclude ux.txt (scratch UX mockup)

Verified clean:
  cargo fmt --all -- --check
  cargo clippy --all-targets -- -D warnings
  cargo publish --dry-run --allow-dirty  (16 files, 20.6 KiB compressed)
2026-04-29 19:49:11 -06:00
swaits d2740008e9 feat(ux): more refinements
- Custom oneline template (drops timestamp + commit-id hash from the
  left pane); keeps change-id with shortest-unique-prefix highlighting,
  bookmarks, conflict/empty labels, and the description first-line.
  More room for the actually-useful columns.
- Drop the dim '── <change_id> ────' header above the preview; the
  cursor row tee (├) now carries the 'this is what you're looking at'
  signal.
- Box-char frame for the preview pane: top border (┌────) and left
  border (│), with ├ replacing │ on the cursor row to draw the eye
  from picker selection into preview content.
- Dynamic description clipping: the description fills whatever rows
  are left after the file list (ellipsis on overflow). Replaces the
  hard 3-line cap, which under-used vertical space for short file
  lists and was buggy (didn't count blank lines within description).
- Bump VIEWPORT_MIN from 15 to 20 so the preview pane has enough
  vertical room to be useful by default.
2026-04-29 18:12:40 -06:00
swaits 8605528960 feat(ux): batch UX improvements
- Reverse-video caret in input row (was cyan █ block — collided with
  the cyan ❯ prompt and read as 'unfocused-pane cursor')
- Synchronized-output brackets (DEC mode 2026) around each redraw —
  eliminates tearing on Kitty/WezTerm/foot/recent iTerm; ignored elsewhere
- Two-tone hint row (keys at normal weight, labels dim) gives the eye
  a scan anchor
- Dim header above preview pane (── <change_id> ────) anchors the right
  column to the cursor row's revision
- Light-terminal detection via COLORFGBG: switch cursor-row highlight
  from indexed-237 dark-gray bg to reverse-video on themes where the
  dark bg would be invisible
- Shell wrappers echo the resolved 'jj …' command (dim '$' prefix)
  before running it, so it's visible in scrollback
2026-04-29 16:29:01 -06:00
swaits 5650a14d55 fix(history): drop jjf entry on cancel/error paths too
Previously, the original `jjf …` invocation only got dropped from
history on successful confirm. A cancelled or errored picker left a
useless `jjf …` entry behind. Now bash drops it unconditionally via
`_jjf_drop_last_history`; fish arms the postexec hook before the
`--emit` call so the deletion happens regardless of exit path.
2026-04-29 16:20:57 -06:00
swaits 6479867e61 feat: two-column layout with jj show preview pane
When term_cols >= 80, right side shows jj show --summary of the
currently highlighted revision (header stripped — just description and
the M/A/D file list, with description capped at 3 lines). Per-row
preview is cached, so navigating back to previously-viewed revs is
instant. Bumps the viewport floor to 15 rows (max = full terminal) so
the preview has room to be useful.
2026-04-29 16:20:57 -06:00
15 changed files with 1869 additions and 166 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
check:
name: check / clippy / fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo check --all-targets
build:
name: build (${{ matrix.toolchain }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
toolchain: [stable, "1.85"]
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --release
+2
View File
@@ -1 +1,3 @@
/target /target
/Cargo.lock.bak
ux.txt
+144
View File
@@ -0,0 +1,144 @@
# Changelog
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://git.swaits.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://git.swaits.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://git.swaits.com/swaits/jjf/releases/tag/v0.2.0
## [0.1.0] — 2026-04-29
Initial release.
### Added
- Fuzzy picker over `jj log` for any jj subcommand that accepts `-r`.
- Smart prefix matching: typing the bold-highlighted portion of a change
ID jumps to that revision instantly. Falls back to nucleo fuzzy
matching against description text.
- Multi-select via `Tab`; selections joined into `-r 'A|B|C'` revset
union.
- Two-column layout (when terminal ≥ 80 cols): picker on left, live
`jj show --summary` preview of the cursor row on right, cached per
revision.
- Live command preview row showing the exact `jj …` invocation that
Enter will run.
- Preflight check via `jj <sub> --help`: subcommands that don't accept
revisions (`status`, `log`, etc.) are rejected upfront with a
friendly message.
- `--ignore-working-copy` on the internal `jj log` call so the picker
doesn't race with prompt/editor auto-snapshot integrations.
- Shell integration via `jjf init <bash|zsh|fish|nu>`:
- rewrites shell history so up-arrow recalls the resolved `jj …`
command rather than the `jjf …` invocation,
- drops the `jjf …` entry on cancel/error so cancelled invocations
don't pollute history,
- defaults `jjf` (no args) to `jjf show`,
- passes meta-commands and bare flags through to the binary directly.
- Vim-style and emacs-style navigation: `↑↓`, `Ctrl-N/P`, `Ctrl-J/K`,
`PageUp/PageDown`, `Home/End`, `Ctrl-A/E`.
- Light-terminal detection via `COLORFGBG`: cursor-row highlight uses
reverse video on light themes (where the dark gray bg is invisible).
- Synchronized output (DEC mode 2026) around each redraw to eliminate
tearing on Kitty / WezTerm / foot / recent iTerm.
[0.1.0]: https://git.swaits.com/swaits/jjf/releases/tag/v0.1.0
Generated
+1 -1
View File
@@ -99,7 +99,7 @@ dependencies = [
[[package]] [[package]]
name = "jjf" name = "jjf"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"crossterm", "crossterm",
+15 -1
View File
@@ -1,7 +1,21 @@
[package] [package]
name = "jjf" name = "jjf"
version = "0.1.0" version = "0.4.1"
edition = "2024" edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
description = "Fuzzy revision picker for jujutsu (jj). Wraps any jj subcommand with an interactive picker over jj log, with a live jj show preview pane."
license = "MIT"
repository = "https://git.swaits.com/swaits/jjf"
homepage = "https://git.swaits.com/swaits/jjf"
documentation = "https://docs.rs/jjf"
readme = "README.md"
keywords = ["jj", "jujutsu", "vcs", "picker", "fuzzy-finder"]
categories = ["command-line-utilities", "development-tools"]
exclude = [
"/.github/",
"/ux.txt",
]
[dependencies] [dependencies]
crossterm = "0.29" crossterm = "0.29"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166
View File
@@ -0,0 +1,166 @@
# jjf
A fuzzy revision picker for [jujutsu (jj)](https://github.com/jj-vcs/jj).
Wraps any jj subcommand with an interactive picker over `jj log`. Type to
filter, hit Enter, and `jjf` runs your command with `-r '<id>'` filled in
from the revision you picked. The right pane shows a live `jj show
--summary` of whatever's under the cursor; the bottom row shows the exact
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…
○ lyxo fix(history): drop jjf entry │
○ tqvr feat: two-column layout │ M Cargo.toml
○ yksz feat: default to 'show' │ A LICENSE
○ lsqz feat: broaden picker revset │ A README.md
○ nzwx feat: initial implementation
▶ jj describe -m 'fix typo' -r 'ss'
[8/8] type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit
```
## Install
```sh
cargo install jjf
```
Requires Rust 1.85+ (edition 2024) and `jj` 0.40+ on `PATH`.
## Usage
```sh
jjf <jj-subcommand> [args...]
```
Some examples:
```sh
jjf describe -m 'fix: validate input'
jjf abandon
jjf edit
jjf squash
jjf rebase -d main
```
`jjf` runs `jj log`, lets you fuzzy-pick a revision (or several), then
invokes the subcommand with `-r '<id>'` appended. Subcommands that don't
accept revisions (`status`, `log`, `git fetch`, etc.) are detected
upfront and rejected with a friendly message — no wasted picker cycle.
With shell integration installed (see below), `jjf` with no arguments
defaults to `jjf show` so it doubles as a quick rev browser.
## Smart prefix matching
Type the bold portion of any change ID shown in `jj log` and that
revision is highlighted instantly. Falls back to fuzzy-matching the
description text:
| Query matches | Score |
| ----------------------------------- | ------------ |
| Prefix of `change_id.shortest()` | 1,000,000 |
| Prefix of full short change ID (12) | 100,000 |
| Prefix of `commit_id.shortest()` | 10,000 |
| Prefix of full short commit ID (12) | 1,000 |
| Fuzzy match of description text | nucleo score |
Empty filter shows all revisions in original `jj log` order.
## Keybindings
| Key | Action |
| --------------------------------------------------- | ----------------------------------- |
| Any printable char | Append to filter |
| `↑` / `↓`, `Ctrl-N` / `Ctrl-P`, `Ctrl-J` / `Ctrl-K` | Move cursor |
| `PageUp` / `PageDown` | Page up / down |
| `Home` / `End`, `Ctrl-A` / `Ctrl-E` | Jump to first / last |
| `Tab` | Toggle selection on cursor row |
| `Enter` | Confirm: selected rows, else cursor |
| `Backspace`, `Ctrl-H` | Delete char from filter |
| `Ctrl-U` | Clear filter |
| `Ctrl-W` | Delete word from filter |
| `Esc`, `Ctrl-C` | Cancel |
Multi-select with `Tab` joins picks into a `-r 'A|B|C'` revset union.
Subcommands that require exactly one revision (`describe`, `edit`) will
surface jj's own error if multi-selected.
## Shell integration
Add to your shell rc:
```sh
# bash
eval "$(jjf init bash)"
# zsh
eval "$(jjf init zsh)"
# fish
jjf init fish | source
# nushell — save once, source from your config.nu
jjf init nu | save -f ~/.config/nushell/jjf.nu
# then add: source ~/.config/nushell/jjf.nu
```
With this in place:
- **History rewriting** — `jjf describe -m 'foo'` records the resolved
`jj describe -m 'foo' -r '<id>'` in your shell history rather than the
`jjf …` invocation. Up-arrow recalls the resolved command, ready to
re-run or edit.
- **`jjf` alone** runs `jjf show` so it's a quick rev browser.
- **`jjf init …`, `jjf -h`, `jjf --version`** bypass the picker and
pass through to the binary directly.
| Shell | History append | Original `jjf …` entry |
| ------- | ------------------------ | --------------------------------------------- |
| bash | `history -s` | deleted via `history -d` |
| zsh | `print -s` | suppressed via `zshaddhistory` hook |
| fish | `builtin history append` | deleted via `fish_postexec` hook |
| nushell | `history import` | remains (sqlite history; no clean delete API) |
Without shell integration, `jjf` still works — it just execs `jj`
directly and your shell history shows the original `jjf …` invocation.
## How it works
`jjf` invokes `jj log --ignore-working-copy --color=always -r 'all()'`
with a custom template that emits per-revision metadata (short change
ID, shortest unique change-ID prefix, short commit ID, shortest unique
commit-ID prefix) plus a custom oneline payload, separated by `\x1f`
sentinels.
The picker is a hand-rolled TUI rendered via direct ANSI to `/dev/tty`
(no `ratatui`, no `ansi-to-tui`). Two-column layout when the terminal
is at least 80 cols wide: picker on the left, `jj show --summary` of
the cursor row on the right (cached per revision). On Enter, the
resolved `jj <sub> [args] -r '<id>'` is either exec'd directly or
printed to stdout (in `--emit` mode, used by the shell wrappers).
The `--ignore-working-copy` flag avoids snapshotting during the picker
phase, which prevents races with prompt/editor integrations that
auto-snapshot (e.g. starship's `jj` module).
## Dependencies
Runtime crates: `crossterm`, `nucleo-matcher`, `anyhow`, `libc`. Total
transitive dep count is around 50; release binary is ~700 KB stripped.
## Acknowledgments
The idea for `jjf` came from a [bash + fzf script of the same
concept](https://oppi.li/posts/jjj/) (also discussed on
[lobste.rs](https://lobste.rs/s/exlogg/jjj)) by the author at oppi.li.
`jjf` is an independent Rust implementation — **none** of the original
script's code was viewed or used by the author — but the core insight (wrap any jj subcommand
with an fzf-style picker over `jj log`) is theirs and worth crediting.
## License
MIT — see [LICENSE](LICENSE).
+14 -7
View File
@@ -1,3 +1,9 @@
_jjf_drop_last_history() {
local _off
_off=$(history 1 2>/dev/null | awk '{print $1}')
[ -n "$_off" ] && history -d "$_off" 2>/dev/null || true
}
jjf() { jjf() {
# No args → default to `show` so `jjf` alone is a rev browser. # No args → default to `show` so `jjf` alone is a rev browser.
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
@@ -9,14 +15,15 @@ jjf() {
return $? return $?
fi fi
local _jjf_cmd local _jjf_cmd
_jjf_cmd="$(command jjf --emit "$@")" || return $? _jjf_cmd="$(command jjf --emit "$@")"
[ -n "$_jjf_cmd" ] || return 130 local _jjf_rc=$?
# Replace the original `jjf …` history entry with the resolved jj command. # Always drop the `jjf …` entry from history; on cancel/error there's
local _jjf_last_offset # nothing to replace it with, on success we add the resolved command below.
_jjf_last_offset=$(history 1 2>/dev/null | awk '{print $1}') _jjf_drop_last_history
if [ -n "$_jjf_last_offset" ]; then if [ $_jjf_rc -ne 0 ] || [ -z "$_jjf_cmd" ]; then
history -d "$_jjf_last_offset" 2>/dev/null || true return $_jjf_rc
fi fi
history -s -- "$_jjf_cmd" history -s -- "$_jjf_cmd"
printf '\033[2m$\033[0m %s\n' "$_jjf_cmd"
sh -c "$_jjf_cmd" sh -c "$_jjf_cmd"
} }
+5 -3
View File
@@ -9,6 +9,10 @@ function jjf
command jjf $argv command jjf $argv
return $status return $status
end end
# Arm the postexec hook *before* invoking the picker so the original
# `jjf …` entry is removed from history on any exit path (cancel, error,
# or success). Fish records function invocations *after* return.
set -g _jjf_pending_swap 1
set -l _jjf_cmd (command jjf --emit $argv) set -l _jjf_cmd (command jjf --emit $argv)
set -l _jjf_status $status set -l _jjf_status $status
if test $_jjf_status -ne 0 if test $_jjf_status -ne 0
@@ -18,9 +22,7 @@ function jjf
return 130 return 130
end end
builtin history append -- $_jjf_cmd builtin history append -- $_jjf_cmd
# Signal the postexec hook to remove the literal `jjf …` entry that fish printf '\033[2m$\033[0m %s\n' $_jjf_cmd
# is about to record (fish records function invocations *after* return).
set -g _jjf_pending_swap 1
sh -c $_jjf_cmd sh -c $_jjf_cmd
end end
+1
View File
@@ -12,5 +12,6 @@ def --env jjf [...args] {
let cmd = ($result.stdout | str trim) let cmd = ($result.stdout | str trim)
if ($cmd | is-empty) { exit 130 } if ($cmd | is-empty) { exit 130 }
[{command: $cmd, start_timestamp: (date now | format date "%+")}] | history import [{command: $cmd, start_timestamp: (date now | format date "%+")}] | history import
print $"(ansi grey)$(ansi reset) ($cmd)"
^sh -c $cmd ^sh -c $cmd
} }
+1
View File
@@ -12,6 +12,7 @@ jjf() {
_jjf_cmd="$(command jjf --emit "$@")" || return $? _jjf_cmd="$(command jjf --emit "$@")" || return $?
[ -n "$_jjf_cmd" ] || return 130 [ -n "$_jjf_cmd" ] || return 130
print -s -- "$_jjf_cmd" print -s -- "$_jjf_cmd"
printf '\033[2m$\033[0m %s\n' "$_jjf_cmd"
sh -c "$_jjf_cmd" sh -c "$_jjf_cmd"
} }
+874 -34
View File
@@ -4,15 +4,41 @@ use anyhow::{Context, Result};
const SEP: u8 = 0x1f; const SEP: u8 = 0x1f;
// 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!( const TEMPLATE: &str = concat!(
"\"\\x1f\" ++ ", "\"\\x1f\" ++ ",
"change_id.short() ++ \"\\x1f\" ++ ", "change_id.short() ++ \"\\x1f\" ++ ",
"change_id.shortest().prefix() ++ \"\\x1f\" ++ ", "change_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"commit_id.short() ++ \"\\x1f\" ++ ", "commit_id.short() ++ \"\\x1f\" ++ ",
"commit_id.shortest().prefix() ++ \"\\x1f\" ++ ", "commit_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"builtin_log_oneline", "(separate(\" \", \
format_short_change_id(self.change_id()), \
bookmarks, \
if(conflict, label(\"conflict\", \"conflict\")), \
if(empty, label(\"empty\", \"(empty)\")), \
description.first_line() \
))",
); );
/// Either a commit row (selectable) or a connector row (decoration only —
/// `├─┬─╮`, `│ │`, `~`, …). Connectors have empty `change_id_short`.
pub struct Row { pub struct Row {
pub change_id_short: String, pub change_id_short: String,
pub change_id_prefix: String, pub change_id_prefix: String,
@@ -22,10 +48,22 @@ pub struct Row {
pub styled: Vec<u8>, 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>> { pub fn capture_log() -> Result<Vec<Row>> {
let output = Command::new("jj") let output = Command::new("jj")
.args([ .args([
"--ignore-working-copy", "--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", "log",
"--color=always", "--color=always",
"-r", "-r",
@@ -48,14 +86,15 @@ pub fn capture_log() -> Result<Vec<Row>> {
if raw.is_empty() { if raw.is_empty() {
continue; continue;
} }
if let Some(row) = parse_row(raw) { rows.push(parse_row(raw));
rows.push(row);
}
} }
Ok(rows) 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 parts: Vec<&[u8]> = Vec::with_capacity(6);
let mut start = 0; let mut start = 0;
for (i, &b) in bytes.iter().enumerate() { for (i, &b) in bytes.iter().enumerate() {
@@ -68,7 +107,17 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
} }
} }
if parts.len() < 5 { 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..]); parts.push(&bytes[start..]);
@@ -79,24 +128,19 @@ fn parse_row(bytes: &[u8]) -> Option<Row> {
let commit_id_prefix = strip_ansi(parts[4]); let commit_id_prefix = strip_ansi(parts[4]);
let payload = parts[5]; let payload = parts[5];
if change_id_short.is_empty() {
return None;
}
let mut styled = Vec::with_capacity(graph.len() + payload.len()); let mut styled = Vec::with_capacity(graph.len() + payload.len());
styled.extend_from_slice(graph); styled.extend_from_slice(graph);
styled.extend_from_slice(payload); styled.extend_from_slice(payload);
let plain = strip_ansi(&styled); let plain = strip_ansi(&styled);
Some(Row { Row {
change_id_short, change_id_short,
change_id_prefix, change_id_prefix,
commit_id_short, commit_id_short,
commit_id_prefix, commit_id_prefix,
plain, plain,
styled, styled,
}) }
} }
/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice. /// Strip CSI escape sequences (ESC `[` … letter) from a byte slice.
@@ -122,42 +166,558 @@ pub fn strip_ansi(bytes: &[u8]) -> String {
String::from_utf8_lossy(&out).into_owned() String::from_utf8_lossy(&out).into_owned()
} }
pub fn supports_revisions(subcommand: &str) -> Result<bool> { /// Description and file-summary parts of `jj show --summary`, returned
/// separately so the renderer can fit them dynamically into the available
/// vertical space.
pub struct PreviewParts {
/// Description lines (still 4-space-indented as jj show emits them),
/// joined by `\n`. No trailing blank.
pub description: Vec<u8>,
/// File list (`M path`, `A path`, …) joined by `\n`. No leading blank.
pub files: Vec<u8>,
}
/// Run `jj show --summary` for a single revision and split the result into
/// description and file-list parts (stripping the `Commit ID:` / `Change ID:`
/// / `Author:` / `Committer:` header).
pub fn show_summary(change_id: &str) -> PreviewParts {
let out = Command::new("jj")
.args([
"--ignore-working-copy",
"show",
"--color=always",
"--summary",
"-r",
change_id,
])
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => parse_show_summary(&o.stdout),
_ => PreviewParts {
description: b"(preview unavailable)".to_vec(),
files: Vec::new(),
},
}
}
fn parse_show_summary(bytes: &[u8]) -> PreviewParts {
// Skip the metadata header — everything up to and including the first
// `\n\n` (blank line) is `Commit ID:` / `Change ID:` / `Author:` /
// `Committer:` and we don't want any of it.
let body: &[u8] = if let Some(pos) = bytes.windows(2).position(|w| w == b"\n\n") {
&bytes[pos + 2..]
} else {
bytes
};
let lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();
// Description block: leading lines that are either 4-space-indented or
// blank. Stops at the first non-indented non-blank line, which is the
// first file entry (e.g. `M src/main.rs`).
let mut desc_end = 0;
for (i, line) in lines.iter().enumerate() {
if line.is_empty() || line.starts_with(b" ") {
desc_end = i + 1;
} else {
break;
}
}
// Trim trailing blank lines from the description block.
while desc_end > 0 && lines[desc_end - 1].is_empty() {
desc_end -= 1;
}
// Skip the first description line — it's the title, already shown on
// the left-pane oneline as `description.first_line()`. Also skip any
// blank lines immediately after it so the body starts cleanly.
let mut desc_start = if desc_end > 0 { 1 } else { 0 };
while desc_start < desc_end && lines[desc_start].is_empty() {
desc_start += 1;
}
let mut description = Vec::new();
for (i, line) in lines[desc_start..desc_end].iter().enumerate() {
if i > 0 {
description.push(b'\n');
}
// jj show indents description lines with 4 spaces — strip them so
// the preview pane doesn't waste a 4-char left margin (and so our
// wrapper has the full width to wrap into).
let stripped: &[u8] = line.strip_prefix(b" ").unwrap_or(line);
description.extend_from_slice(stripped);
}
let mut files = Vec::new();
let mut first = true;
for line in lines.iter().skip(desc_end) {
if line.is_empty() {
continue;
}
if !first {
files.push(b'\n');
}
files.extend_from_slice(line);
first = false;
}
PreviewParts { description, files }
}
/// 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") let output = Command::new("jj")
.arg(subcommand) .args(path)
.arg("--help") .arg("--help")
.stderr(Stdio::null())
.output() .output()
.context("failed to spawn jj for preflight")?; .context("failed to spawn jj for preflight")?;
if !output.status.success() { if !output.status.success() {
// unknown subcommand or other error — let the main flow surface jj's error return Ok(None);
return Ok(true);
} }
let help = String::from_utf8_lossy(&output.stdout); Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
Ok(help.contains("REVSET"))
} }
pub fn exec(subcommand: &str, passthrough: &[String], ids: &[String]) -> Result<ExitStatus> { /// Parse the `Commands:` block of a jj help output. Returns the set of
let revset = ids.join("|"); /// subcommand names AND their `[aliases: …]` annotations, so a user typing
let status = Command::new("jj") /// `jjf tag s` (where `s` is the alias for `set`) walks correctly.
.arg(subcommand) fn parse_commands_section(help: &str) -> std::collections::HashSet<String> {
.args(passthrough) let mut set = std::collections::HashSet::new();
.arg("-r") let mut in_commands = false;
.arg(&revset) for line in help.lines() {
.status() if line == "Commands:" {
.context("failed to spawn jj")?; 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) Ok(status)
} }
pub fn command_line(subcommand: &str, passthrough: &[String], ids: &[String]) -> String { pub fn command_line(target: &RevsetTarget, ids: &[String]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(4 + passthrough.len()); let mut parts: Vec<String> =
Vec::with_capacity(2 + target.leaf.len() + target.passthrough.len() + 2);
parts.push("jj".into()); parts.push("jj".into());
parts.push(subcommand.into()); for w in &target.leaf {
for a in passthrough { parts.push(w.clone());
}
for a in &target.passthrough {
parts.push(shell_quote(a)); parts.push(shell_quote(a));
} }
if !ids.is_empty() { if !ids.is_empty() {
parts.push("-r".into()); if let Some(flag) = target.flag {
parts.push(shell_quote(&ids.join("|"))); parts.push(flag.as_str().into());
parts.push(shell_quote(&ids.join("|")));
}
} }
parts.join(" ") parts.join(" ")
} }
@@ -183,3 +743,283 @@ pub fn shell_quote(s: &str) -> String {
out.push('\''); out.push('\'');
out 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() { match first.as_str() {
"init" => run_init(args), "init" => run_init(args),
"--emit" => match args.next() { "--emit" => {
Some(sub) => { let rest: Vec<String> = args.collect();
let passthrough: Vec<String> = args.collect(); if rest.is_empty() {
run_pick(&sub, &passthrough, /* emit = */ true)
}
None => {
eprintln!("usage: jjf --emit <jj-subcommand> [args...]"); eprintln!("usage: jjf --emit <jj-subcommand> [args...]");
Ok(2) return Ok(2);
} }
}, run_pick(&rest, /* emit = */ true)
sub => { }
let passthrough: Vec<String> = args.collect(); _ => {
run_pick(sub, &passthrough, /* emit = */ false) 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> { fn run_pick(args: &[String], emit: bool) -> Result<u8> {
if !jj::supports_revisions(subcommand)? { 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!( 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); return Ok(2);
} }
@@ -89,7 +109,7 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
None None
}; };
let tui_result = tui::run(rows); let tui_result = tui::run(rows, &target);
if let Some(saved) = saved_stdout { if let Some(saved) = saved_stdout {
restore_stdout(saved)?; restore_stdout(saved)?;
@@ -103,12 +123,12 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
} }
if emit { if emit {
let cmd = jj::command_line(subcommand, passthrough, &ids); let cmd = jj::command_line(&target, &ids);
println!("{cmd}"); println!("{cmd}");
Ok(0) Ok(0)
} else { } else {
echo_command(subcommand, passthrough, &ids); echo_command(&target, &ids);
let status = jj::exec(subcommand, passthrough, &ids)?; let status = jj::exec(&target, &ids)?;
Ok(status.code().unwrap_or(1) as u8) Ok(status.code().unwrap_or(1) as u8)
} }
} }
@@ -142,9 +162,9 @@ fn restore_stdout(saved: libc::c_int) -> Result<()> {
Ok(()) Ok(())
} }
fn echo_command(subcommand: &str, passthrough: &[String], ids: &[String]) { fn echo_command(target: &jj::RevsetTarget, ids: &[String]) {
use std::io::IsTerminal; 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() { if std::io::stdout().is_terminal() {
println!("\x1b[2m$\x1b[0m {cmd}"); println!("\x1b[2m$\x1b[0m {cmd}");
} else { } else {
+536 -100
View File
@@ -1,25 +1,56 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use nucleo_matcher::{Config, Matcher, Utf32Str}; use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::jj::Row; use crate::jj::{self, PreviewParts, Row};
const PREVIEW_MIN_COLS: u16 = 80;
// SGR constants // SGR constants
const SGR_RESET: &[u8] = b"\x1b[0m"; const SGR_RESET: &[u8] = b"\x1b[0m";
const SGR_RESET_SHORT: &[u8] = b"\x1b[m"; const SGR_RESET_SHORT: &[u8] = b"\x1b[m";
const SGR_DIM: &[u8] = b"\x1b[2m"; const SGR_DIM: &[u8] = b"\x1b[2m";
const SGR_NORMAL_WEIGHT: &[u8] = b"\x1b[22m";
const SGR_BOLD: &[u8] = b"\x1b[1m"; const SGR_BOLD: &[u8] = b"\x1b[1m";
const SGR_FG_CYAN: &[u8] = b"\x1b[36m"; const SGR_FG_CYAN: &[u8] = b"\x1b[36m";
const SGR_FG_YELLOW: &[u8] = b"\x1b[33m"; const SGR_FG_YELLOW: &[u8] = b"\x1b[33m";
const SGR_BG_CURSOR: &[u8] = b"\x1b[48;5;237m";
const ERASE_TO_EOL: &[u8] = b"\x1b[K"; const ERASE_TO_EOL: &[u8] = b"\x1b[K";
const ERASE_TO_END: &[u8] = b"\x1b[J";
const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
// Synchronized output (DEC Mode 2026): Kitty / WezTerm / foot / recent iTerm
// honor these brackets and present the buffered redraw atomically; other
// terminals ignore them. Eliminates tearing on supported terminals.
const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
const SYNC_END: &[u8] = b"\x1b[?2026l";
/// The cursor-row "highlight" SGR. Dark gray bg on dark themes, reverse
/// video on light themes (since indexed-color 237 disappears on light).
fn cursor_bg() -> &'static [u8] {
static CACHE: OnceLock<&'static [u8]> = OnceLock::new();
CACHE.get_or_init(|| {
if is_light_terminal() {
b"\x1b[7m"
} else {
b"\x1b[48;5;237m"
}
})
}
fn is_light_terminal() -> bool {
if let Ok(v) = std::env::var("COLORFGBG")
&& let Some(bg_str) = v.split(';').next_back()
&& let Ok(bg) = bg_str.parse::<u8>()
{
return bg == 7 || bg == 15;
}
false
}
const HIDE_CURSOR: &[u8] = b"\x1b[?25l"; const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
struct App { struct App {
@@ -31,12 +62,13 @@ struct App {
filtered: Vec<usize>, filtered: Vec<usize>,
matcher: Matcher, matcher: Matcher,
last_height: usize, last_height: usize,
preview_cache: HashMap<usize, PreviewParts>,
} }
impl App { impl App {
fn new(rows: Vec<Row>) -> Self { fn new(rows: Vec<Row>) -> Self {
let n = rows.len(); let n = rows.len();
Self { let mut app = Self {
rows, rows,
filter: String::new(), filter: String::new(),
cursor: 0, cursor: 0,
@@ -45,13 +77,59 @@ impl App {
filtered: (0..n).collect(), filtered: (0..n).collect(),
matcher: Matcher::new(Config::DEFAULT), matcher: Matcher::new(Config::DEFAULT),
last_height: 0, 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 {
if !self.preview_cache.contains_key(&row_idx) {
let cid = self.rows[row_idx].change_id_short.clone();
self.preview_cache.insert(row_idx, jj::show_summary(&cid));
}
self.preview_cache.get(&row_idx).unwrap()
} }
fn refilter(&mut self) { fn refilter(&mut self) {
if self.filter.is_empty() { 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(); self.filtered = (0..self.rows.len()).collect();
} else { } 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 { let App {
rows, rows,
matcher, matcher,
@@ -64,6 +142,7 @@ impl App {
let mut scored: Vec<(usize, u32)> = rows let mut scored: Vec<(usize, u32)> = rows
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, row)| !row.is_connector())
.filter_map(|(i, row)| { .filter_map(|(i, row)| {
score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s)) score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s))
}) })
@@ -71,7 +150,7 @@ impl App {
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); 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.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; self.view_offset = 0;
} }
@@ -80,28 +159,34 @@ impl App {
if len == 0 { if len == 0 {
return; return;
} }
let mut c = self.cursor as isize + delta;
if c < 0 {
c = 0;
}
let max = len as isize - 1; let max = len as isize - 1;
if c > max { let target_pos = (self.cursor as isize + delta).clamp(0, max) as usize;
c = max; // 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) { fn jump_top(&mut self) {
self.cursor = 0; self.cursor = self.next_selectable(0, 1).unwrap_or(0);
self.adjust_view(); self.adjust_view();
} }
fn jump_bottom(&mut self) { fn jump_bottom(&mut self) {
if !self.filtered.is_empty() { if self.filtered.is_empty() {
self.cursor = self.filtered.len() - 1; return;
self.adjust_view();
} }
let last = self.filtered.len() - 1;
self.cursor = self.next_selectable(last, -1).unwrap_or(last);
self.adjust_view();
} }
fn adjust_view(&mut self) { fn adjust_view(&mut self) {
@@ -117,6 +202,7 @@ impl App {
fn toggle_select(&mut self) { fn toggle_select(&mut self) {
if let Some(&row_idx) = self.filtered.get(self.cursor) if let Some(&row_idx) = self.filtered.get(self.cursor)
&& !self.row_is_connector(row_idx)
&& !self.selected.insert(row_idx) && !self.selected.insert(row_idx)
{ {
self.selected.remove(&row_idx); self.selected.remove(&row_idx);
@@ -190,7 +276,10 @@ fn score_row(
if hit { Some(best) } else { None } if hit { Some(best) } else { None }
} }
pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> { pub fn run(
rows: Vec<Row>,
target: &jj::RevsetTarget,
) -> Result<Option<Vec<String>>> {
install_panic_hook(); install_panic_hook();
let mut tty = OpenOptions::new() let mut tty = OpenOptions::new()
.read(true) .read(true)
@@ -198,53 +287,29 @@ pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
.open("/dev/tty") .open("/dev/tty")
.context("open /dev/tty")?; .context("open /dev/tty")?;
crossterm::terminal::enable_raw_mode().context("enable raw mode")?; crossterm::terminal::enable_raw_mode().context("enable raw mode")?;
// Full-screen alt-screen mode: saves the user's screen, gives us the
// whole terminal as a clean canvas, restores on exit. Resize behavior
// becomes trivial because we own the entire display.
tty.write_all(b"\x1b[?1049h").context("enter alt screen")?;
tty.write_all(HIDE_CURSOR).context("hide cursor")?;
tty.flush().ok();
let mut viewport_y: u16 = 0; let result = run_loop(&mut tty, rows, target);
let result = (|| {
let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((80, 24));
let max_height = (term_rows / 2).clamp(8, 20);
let needed = (rows.len() as u16).saturating_add(2);
let viewport_height = needed.min(max_height).max(4);
viewport_y = allocate_viewport(&mut tty, viewport_height, term_rows)?; let _ = tty.write_all(SHOW_CURSOR);
tty.write_all(HIDE_CURSOR)?; let _ = tty.write_all(b"\x1b[?1049l");
tty.flush()?; let _ = tty.flush();
run_loop(&mut tty, rows, viewport_y, viewport_height, term_cols)
})();
let _ = cleanup_at(&mut tty, viewport_y);
let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::terminal::disable_raw_mode();
result result
} }
fn allocate_viewport(tty: &mut File, viewport_height: u16, term_rows: u16) -> Result<u16> {
let (_, mut cursor_row) = crossterm::cursor::position().context("query cursor position")?;
let bottom_excl = cursor_row + viewport_height;
if bottom_excl > term_rows {
let scroll = bottom_excl - term_rows;
for _ in 0..scroll {
tty.write_all(b"\n")?;
}
tty.flush()?;
cursor_row = cursor_row.saturating_sub(scroll);
}
Ok(cursor_row)
}
fn cleanup_at(tty: &mut File, viewport_y: u16) -> std::io::Result<()> {
write!(tty, "\x1b[{};1H", viewport_y + 1)?;
tty.write_all(ERASE_TO_END)?;
tty.write_all(SHOW_CURSOR)?;
tty.flush()
}
fn install_panic_hook() { fn install_panic_hook() {
let original = std::panic::take_hook(); let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| { std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::terminal::disable_raw_mode();
if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") { if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") {
let _ = tty.write_all(SHOW_CURSOR); let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
let _ = tty.flush(); let _ = tty.flush();
} }
original(info); original(info);
@@ -254,14 +319,14 @@ fn install_panic_hook() {
fn run_loop( fn run_loop(
tty: &mut File, tty: &mut File,
rows: Vec<Row>, rows: Vec<Row>,
viewport_y: u16, target: &jj::RevsetTarget,
viewport_height: u16,
term_cols: u16,
) -> Result<Option<Vec<String>>> { ) -> Result<Option<Vec<String>>> {
let mut app = App::new(rows); let mut app = App::new(rows);
app.last_height = (viewport_height as usize).saturating_sub(2); // input + hint 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 { loop {
render(tty, &app, viewport_y, viewport_height, term_cols)?; render(tty, &mut app, term_rows, term_cols, target)?;
if !event::poll(Duration::from_millis(250)).context("event poll failed")? { if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
continue; continue;
} }
@@ -271,8 +336,13 @@ fn run_loop(
return Ok(action); return Ok(action);
} }
} }
Event::Resize(_, _) => { Event::Resize(new_cols, new_rows) => {
// Just redraw on next loop iteration; viewport stays the same. // Alt-screen mode: trivial. Just update dims and redraw.
term_cols = new_cols;
term_rows = new_rows;
app.last_height = (term_rows as usize).saturating_sub(3);
let _ = write!(tty, "\x1b[2J");
let _ = tty.flush();
} }
_ => {} _ => {}
} }
@@ -379,30 +449,90 @@ fn delete_word(s: &mut String) {
fn render( fn render(
tty: &mut File, tty: &mut File,
app: &App, app: &mut App,
viewport_y: u16, term_rows: u16,
viewport_height: u16,
term_cols: u16, term_cols: u16,
target: &jj::RevsetTarget,
) -> Result<()> { ) -> Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024); let viewport_y: u16 = 0;
let viewport_height = term_rows;
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
buf.extend_from_slice(SYNC_BEGIN);
let log_height = app.last_height;
// Move to viewport top-left. let preview_enabled = term_cols >= PREVIEW_MIN_COLS;
// Layout (1-indexed columns), with a 2-col separator between picker and box:
// cols 1..=left_w picker pane (text + padding for non-cursor;
// text only, no bg padding, for cursor)
// cols left_w+1 .. left_w+2 2-col separator
// col left_w+3 box left edge `│` (non-cursor)
// cols left_w+4 .. left_w+5 2-col gutter inside box
// cols left_w+6 .. term_cols preview content
//
// On the cursor row the picker text is NOT padded; instead a dynamic
// 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)
// cols Y+3 .. left_w+3 `─` (variable-length body)
// 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 {
let lw = (term_cols / 2).saturating_sub(3) as usize;
let rx = (lw + 2) as u16; // col where `│` lives (1-indexed): lw+3 = rx+1
let rw = (term_cols as usize).saturating_sub(lw + 2);
(lw, rx, rw)
} else {
(term_cols as usize, term_cols, 0)
};
// 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
// selection into preview content.
let preview_gutter_w = 3;
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
let preview_rows = log_height; // top border is on input row, not log area
let preview_lines: Vec<Vec<u8>> = if preview_enabled {
if let Some(&row_idx) = app.filtered.get(app.cursor) {
let parts = app.preview_for(row_idx);
build_preview_lines(parts, preview_rows, preview_content_w)
} else {
Vec::new()
}
} else {
Vec::new()
};
// Input row (left only).
write!(buf, "\x1b[{};1H", viewport_y + 1)?; write!(buf, "\x1b[{};1H", viewport_y + 1)?;
// Input row.
buf.extend_from_slice(SGR_BOLD); buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_CYAN); 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.extend_from_slice(SGR_RESET);
buf.extend_from_slice(app.filter.as_bytes()); buf.extend_from_slice(app.filter.as_bytes());
buf.extend_from_slice(SGR_FG_CYAN); // Reverse-video caret on a single space — matches fzf/lazygit/telescope
buf.extend_from_slice("█".as_bytes()); // convention; survives any colorscheme without competing with the cyan ❯.
buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(b"\x1b[7m \x1b[27m");
buf.extend_from_slice(ERASE_TO_EOL); buf.extend_from_slice(ERASE_TO_EOL);
buf.extend_from_slice(b"\r\n");
// Top border for the preview pane — drawn at the input row's vertical
// position so the box "starts" alongside the search field. Lives at
// col left_w+3 (= right_x + 1), past the 2-col separator from the picker.
if preview_enabled {
write!(buf, "\x1b[{};{}H", viewport_y + 1, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("┌".as_bytes());
for _ in 1..right_w {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
}
// Log rows. // Log rows.
let log_height = app.last_height;
let visible: Vec<(usize, usize)> = app let visible: Vec<(usize, usize)> = app
.filtered .filtered
.iter() .iter()
@@ -413,18 +543,82 @@ fn render(
.collect(); .collect();
for i in 0..log_height { for i in 0..log_height {
if let Some(&(filt_idx, row_idx)) = visible.get(i) { let row_y = viewport_y + 2 + i as u16; // input row + offset
write!(buf, "\x1b[{};1H", row_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let on_cursor = visible
.get(i)
.is_some_and(|&(filt_idx, _)| filt_idx == app.cursor);
let visible_used: usize = if let Some(&(filt_idx, row_idx)) = visible.get(i) {
let row = &app.rows[row_idx]; let row = &app.rows[row_idx];
let is_cursor = filt_idx == app.cursor; let is_cursor = filt_idx == app.cursor;
let is_selected = app.selected.contains(&row_idx); let is_selected = app.selected.contains(&row_idx);
render_row(&mut buf, row, is_cursor, is_selected, term_cols); render_log_row(&mut buf, row, is_cursor, is_selected, left_w)
} else { } else {
buf.extend_from_slice(ERASE_TO_EOL); 0
};
if preview_enabled {
if on_cursor {
// Dynamic arrow inline, starting at col visible_used+1:
// ` ` ● ─×N ▶ ` ` then preview content at col left_w+6.
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
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(SGR_RESET);
buf.push(b' ');
} else {
// Position past the picker pane: 2-col sep, then `│ `, then content.
write!(buf, "\x1b[{};{}H", row_y, left_w as u16 + 1)?;
buf.push(b' ');
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("│ ".as_bytes());
buf.extend_from_slice(SGR_RESET);
}
if let Some(line) = preview_lines.get(i) {
buf.extend_from_slice(line);
buf.extend_from_slice(SGR_RESET);
}
} }
buf.extend_from_slice(b"\r\n");
} }
// Hint row. // Command preview row — `▶ jj describe -m 'foo' -r 'vx'` in bright green.
// Tells the user exactly what Enter will run, with the resolved `-r '<id>'`
// updated live as the cursor moves or selections toggle.
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(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
} else if cmd_max == 0 {
String::new()
} else {
let mut s: String = cmd.chars().take(cmd_max.saturating_sub(1)).collect();
s.push('…');
s
};
buf.extend_from_slice(b"\x1b[1;32m"); // bold green
buf.extend_from_slice("▶ ".as_bytes());
buf.extend_from_slice(cmd_visible.as_bytes());
buf.extend_from_slice(SGR_RESET);
// 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() { let counts = if app.selected.is_empty() {
format!("[{}/{}] ", app.filtered.len(), app.rows.len()) format!("[{}/{}] ", app.filtered.len(), app.rows.len())
} else { } else {
@@ -435,27 +629,199 @@ fn render(
app.selected.len() 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(SGR_DIM);
buf.extend_from_slice(counts.as_bytes()); buf.extend_from_slice(counts.as_bytes());
buf.extend_from_slice( buf.extend_from_slice(b"type filter");
"type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit".as_bytes(), 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(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL); buf.extend_from_slice(ERASE_TO_EOL);
// Park cursor at end of input line so terminal doesn't blink in the log. // Park cursor at end of input line.
// Compute input column = column after "❯ " + filter chars + cursor block.
let input_col = 2 + app.filter.chars().count() as u16 + 1; let input_col = 2 + app.filter.chars().count() as u16 + 1;
write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?; write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?;
let _ = viewport_height; // currently unused; reserved for future use let _ = viewport_height;
let _ = right_x;
buf.extend_from_slice(SYNC_END);
tty.write_all(&buf)?; tty.write_all(&buf)?;
tty.flush()?; tty.flush()?;
Ok(()) Ok(())
} }
fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool, width: u16) { fn write_hint_pair(buf: &mut Vec<u8>, key: &[u8], label: &[u8]) {
// Selected marker (always rendered at column 0, two cells) buf.extend_from_slice(" · ".as_bytes());
buf.extend_from_slice(SGR_NORMAL_WEIGHT);
buf.extend_from_slice(key);
buf.extend_from_slice(SGR_DIM);
buf.push(b' ');
buf.extend_from_slice(label);
}
/// Pack description + files into `available_rows`. The file list always wins —
/// description is clipped (with a dim ellipsis appended to its last kept line)
/// to whatever room is left after the files (and a blank separator) are placed.
/// Long description/file lines are word-wrapped to `width` columns.
fn build_preview_lines(parts: &PreviewParts, available_rows: usize, width: usize) -> Vec<Vec<u8>> {
let mut desc_lines: Vec<Vec<u8>> = Vec::new();
if !parts.description.is_empty() {
for line in split_lines(&parts.description) {
desc_lines.extend(wrap_ansi(&line, width));
}
}
let mut file_lines: Vec<Vec<u8>> = Vec::new();
if !parts.files.is_empty() {
for line in split_lines(&parts.files) {
file_lines.extend(wrap_ansi(&line, width));
}
}
let blank_sep = if !desc_lines.is_empty() && !file_lines.is_empty() {
1
} else {
0
};
let max_desc = available_rows.saturating_sub(file_lines.len() + blank_sep);
let kept_desc = desc_lines.len().min(max_desc);
let clipped = desc_lines.len() > kept_desc;
let mut out: Vec<Vec<u8>> = Vec::with_capacity(kept_desc + blank_sep + file_lines.len());
for (i, line) in desc_lines.iter().take(kept_desc).enumerate() {
if clipped && i + 1 == kept_desc {
let mut last = line.clone();
last.extend_from_slice(b" \x1b[2m\xe2\x80\xa6\x1b[0m");
out.push(last);
} else {
out.push(line.clone());
}
}
if blank_sep == 1 && kept_desc > 0 {
out.push(Vec::new());
}
for line in file_lines {
out.push(line);
}
out
}
/// Word-wrap a line containing ANSI escapes to `width` visible columns.
/// Splits on spaces; a word longer than `width` overflows on its own line
/// rather than mid-word breaking. ANSI escape sequences carry through and
/// don't count toward visible width.
fn wrap_ansi(line: &[u8], width: usize) -> Vec<Vec<u8>> {
if width == 0 || visible_width(line) <= width {
return vec![line.to_vec()];
}
let mut out: Vec<Vec<u8>> = Vec::new();
let mut current: Vec<u8> = Vec::new();
let mut current_visible = 0usize;
let mut word: Vec<u8> = Vec::new();
let mut word_visible = 0usize;
let mut i = 0;
while i < line.len() {
if line[i] == 0x1b && line.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < line.len() && !line[i].is_ascii_alphabetic() {
i += 1;
}
if i < line.len() {
i += 1;
}
// Append ANSI to the in-progress word so styling travels with it.
word.extend_from_slice(&line[start..i]);
} else if line[i] == b' ' {
// End-of-word: try to fit `word` into `current`.
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
current_visible += 1;
}
current.extend_from_slice(&word);
current_visible += word_visible;
} else {
// Doesn't fit: flush current, start new line with the word.
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
current_visible = word_visible;
}
word.clear();
word_visible = 0;
i += 1;
} else {
let lead = line[i];
let len = utf8_char_len(lead);
let end = (i + len).min(line.len());
word.extend_from_slice(&line[i..end]);
word_visible += 1;
i = end;
}
}
// Final word
if word_visible > 0 {
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
}
current.extend_from_slice(&word);
} else {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
}
}
if !current.is_empty() {
out.push(current);
}
out
}
/// Render the picker portion of one log row. Returns the 1-indexed column
/// after the rendered content (i.e. the col where the arrow's leading space
/// would be drawn for the cursor row).
///
/// Non-cursor rows are padded with normal spaces to fill `width`. Cursor rows
/// have bg highlight on actual text only and are NOT padded — the dynamic
/// arrow fills the rest.
fn render_log_row(
buf: &mut Vec<u8>,
row: &Row,
is_cursor: bool,
is_selected: bool,
width: usize,
) -> usize {
let gutter_w = 2;
let content_w = width.saturating_sub(gutter_w);
// Gutter: yellow `▎ ` if selected, else two spaces.
if is_selected { if is_selected {
buf.extend_from_slice(SGR_BOLD); buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_YELLOW); buf.extend_from_slice(SGR_FG_YELLOW);
@@ -465,24 +831,94 @@ fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool,
buf.extend_from_slice(b" "); buf.extend_from_slice(b" ");
} }
let truncated = truncate_ansi(&row.styled, content_w);
let used = visible_width(&truncated);
if is_cursor { if is_cursor {
buf.extend_from_slice(SGR_BG_CURSOR); let bg = cursor_bg();
// Inject bg-restore after every embedded reset so the row's bg persists. buf.extend_from_slice(bg);
inject_bg_into(buf, &row.styled, SGR_BG_CURSOR); inject_bg_into(buf, &truncated, bg);
// Pad to terminal width with bg.
let used_chars = 2 + crate::jj::strip_ansi(&row.styled).chars().count();
let width_us = width as usize;
if used_chars < width_us {
for _ in 0..(width_us - used_chars) {
buf.push(b' ');
}
}
buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL); gutter_w + used
} else { } else {
buf.extend_from_slice(&row.styled); buf.extend_from_slice(&truncated);
buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL); let pad = content_w.saturating_sub(used);
for _ in 0..pad {
buf.push(b' ');
}
width
}
}
fn split_lines(bytes: &[u8]) -> Vec<Vec<u8>> {
bytes.split(|&b| b == b'\n').map(|l| l.to_vec()).collect()
}
/// Count visible chars (post-CSI-strip) in a UTF-8 byte slice.
fn visible_width(bytes: &[u8]) -> usize {
let mut w = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
let lead = bytes[i];
let len = utf8_char_len(lead);
i = (i + len).min(bytes.len());
w += 1;
}
}
w
}
/// Truncate `bytes` so visible (non-CSI, non-continuation) chars fit in `max`.
/// All CSI sequences are preserved; only printable chars are counted.
fn truncate_ansi(bytes: &[u8], max: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut visible = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
out.extend_from_slice(&bytes[start..i]);
} else {
if visible >= max {
break;
}
let lead = bytes[i];
let len = utf8_char_len(lead);
let end = (i + len).min(bytes.len());
out.extend_from_slice(&bytes[i..end]);
visible += 1;
i = end;
}
}
out
}
fn utf8_char_len(lead: u8) -> usize {
if lead < 0x80 || (0x80..0xc0).contains(&lead) {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
} }
} }
+10
View File
@@ -0,0 +1,10 @@
❯ ┌──────────────────────────────────────────────────────────────
○ tovs chore(release): prepare 0.1.0 for crates.io │ Mirrors the original bash+fzf script's behavior — `jjf`
@ xnkx feat(ux): more refinements │ alone becomes
○ ssnu feat(ux): batch UX improvements │ a quick rev browser. Implemented in shell wrappers (so the
○ lyxo fix(history): drop jjf entry on cancel/error paths t │ --emit
○ tqvr feat: two-column layout with jj show preview pane │ history-rewrite path still applies); the binary itself still
○ yksz feat: default to 'show' subcommand when jjf is invok ├─► prints
○ lsqz feat: broaden picker revset to all() to expose every │ usage on bare invocation, for explicit invocations.
○ nzwx feat: initial implementation of jjf │
◆ zzzz (empty) │ M src/init/bash.sh