Compare commits
12
Commits
7dec6770cc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13025f2d3b
|
||
|
|
49bcd4f501
|
||
|
|
cbc54ba685
|
||
|
|
7390b92a40
|
||
|
|
40a83b388a
|
||
|
|
6ce9cb2b17
|
||
|
|
1c4ae09646
|
||
|
|
3b63d67737
|
||
|
|
d2740008e9
|
||
|
|
8605528960
|
||
|
|
5650a14d55
|
||
|
|
6479867e61
|
No files matched your search
@@ -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
|
||||||
@@ -1 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
|
/Cargo.lock.bak
|
||||||
|
ux.txt
|
||||||
+144
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user