Compare commits
5
Commits
7dec6770cc
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
/Cargo.lock.bak
|
||||
ux.txt
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.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://github.com/swaits/jjf/releases/tag/v0.1.0
|
||||
+14
@@ -2,6 +2,20 @@
|
||||
name = "jjf"
|
||||
version = "0.1.0"
|
||||
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://github.com/swaits/jjf"
|
||||
homepage = "https://github.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]
|
||||
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() {
|
||||
# No args → default to `show` so `jjf` alone is a rev browser.
|
||||
if [ $# -eq 0 ]; then
|
||||
@@ -9,14 +15,15 @@ jjf() {
|
||||
return $?
|
||||
fi
|
||||
local _jjf_cmd
|
||||
_jjf_cmd="$(command jjf --emit "$@")" || return $?
|
||||
[ -n "$_jjf_cmd" ] || return 130
|
||||
# Replace the original `jjf …` history entry with the resolved jj command.
|
||||
local _jjf_last_offset
|
||||
_jjf_last_offset=$(history 1 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$_jjf_last_offset" ]; then
|
||||
history -d "$_jjf_last_offset" 2>/dev/null || true
|
||||
_jjf_cmd="$(command jjf --emit "$@")"
|
||||
local _jjf_rc=$?
|
||||
# Always drop the `jjf …` entry from history; on cancel/error there's
|
||||
# nothing to replace it with, on success we add the resolved command below.
|
||||
_jjf_drop_last_history
|
||||
if [ $_jjf_rc -ne 0 ] || [ -z "$_jjf_cmd" ]; then
|
||||
return $_jjf_rc
|
||||
fi
|
||||
history -s -- "$_jjf_cmd"
|
||||
printf '\033[2m$\033[0m %s\n' "$_jjf_cmd"
|
||||
sh -c "$_jjf_cmd"
|
||||
}
|
||||
+5
-3
@@ -9,6 +9,10 @@ function jjf
|
||||
command jjf $argv
|
||||
return $status
|
||||
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_status $status
|
||||
if test $_jjf_status -ne 0
|
||||
@@ -18,9 +22,7 @@ function jjf
|
||||
return 130
|
||||
end
|
||||
builtin history append -- $_jjf_cmd
|
||||
# Signal the postexec hook to remove the literal `jjf …` entry that fish
|
||||
# is about to record (fish records function invocations *after* return).
|
||||
set -g _jjf_pending_swap 1
|
||||
printf '\033[2m$\033[0m %s\n' $_jjf_cmd
|
||||
sh -c $_jjf_cmd
|
||||
end
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ def --env jjf [...args] {
|
||||
let cmd = ($result.stdout | str trim)
|
||||
if ($cmd | is-empty) { exit 130 }
|
||||
[{command: $cmd, start_timestamp: (date now | format date "%+")}] | history import
|
||||
print $"(ansi grey)$(ansi reset) ($cmd)"
|
||||
^sh -c $cmd
|
||||
}
|
||||
@@ -12,6 +12,7 @@ jjf() {
|
||||
_jjf_cmd="$(command jjf --emit "$@")" || return $?
|
||||
[ -n "$_jjf_cmd" ] || return 130
|
||||
print -s -- "$_jjf_cmd"
|
||||
printf '\033[2m$\033[0m %s\n' "$_jjf_cmd"
|
||||
sh -c "$_jjf_cmd"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,23 @@ use anyhow::{Context, Result};
|
||||
|
||||
const SEP: u8 = 0x1f;
|
||||
|
||||
// Custom oneline template: drop the timestamp and commit id (rarely useful in
|
||||
// the picker, and the commit-id hex steals real estate), keep change-id with
|
||||
// shortest-unique-prefix highlighting, bookmarks, conflict/empty labels, and
|
||||
// the first line of the description.
|
||||
const TEMPLATE: &str = concat!(
|
||||
"\"\\x1f\" ++ ",
|
||||
"change_id.short() ++ \"\\x1f\" ++ ",
|
||||
"change_id.shortest().prefix() ++ \"\\x1f\" ++ ",
|
||||
"commit_id.short() ++ \"\\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() \
|
||||
))",
|
||||
);
|
||||
|
||||
pub struct Row {
|
||||
@@ -122,6 +132,105 @@ pub fn strip_ansi(bytes: &[u8]) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
pub fn supports_revisions(subcommand: &str) -> Result<bool> {
|
||||
let output = Command::new("jj")
|
||||
.arg(subcommand)
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
|
||||
None
|
||||
};
|
||||
|
||||
let tui_result = tui::run(rows);
|
||||
let tui_result = tui::run(rows, subcommand, passthrough);
|
||||
|
||||
if let Some(saved) = saved_stdout {
|
||||
restore_stdout(saved)?;
|
||||
|
||||
+460
-86
@@ -1,25 +1,56 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use nucleo_matcher::{Config, Matcher, Utf32Str};
|
||||
|
||||
use crate::jj::Row;
|
||||
use crate::jj::{self, PreviewParts, Row};
|
||||
|
||||
const PREVIEW_MIN_COLS: u16 = 80;
|
||||
|
||||
// SGR constants
|
||||
const SGR_RESET: &[u8] = b"\x1b[0m";
|
||||
const SGR_RESET_SHORT: &[u8] = b"\x1b[m";
|
||||
const SGR_DIM: &[u8] = b"\x1b[2m";
|
||||
const SGR_NORMAL_WEIGHT: &[u8] = b"\x1b[22m";
|
||||
const SGR_BOLD: &[u8] = b"\x1b[1m";
|
||||
const SGR_FG_CYAN: &[u8] = b"\x1b[36m";
|
||||
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_END: &[u8] = b"\x1b[J";
|
||||
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";
|
||||
|
||||
struct App {
|
||||
@@ -31,6 +62,7 @@ struct App {
|
||||
filtered: Vec<usize>,
|
||||
matcher: Matcher,
|
||||
last_height: usize,
|
||||
preview_cache: HashMap<usize, PreviewParts>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -45,9 +77,18 @@ impl App {
|
||||
filtered: (0..n).collect(),
|
||||
matcher: Matcher::new(Config::DEFAULT),
|
||||
last_height: 0,
|
||||
preview_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if self.filter.is_empty() {
|
||||
self.filtered = (0..self.rows.len()).collect();
|
||||
@@ -190,7 +231,11 @@ fn score_row(
|
||||
if hit { Some(best) } else { None }
|
||||
}
|
||||
|
||||
pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
|
||||
pub fn run(
|
||||
rows: Vec<Row>,
|
||||
subcommand: &str,
|
||||
passthrough: &[String],
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
install_panic_hook();
|
||||
let mut tty = OpenOptions::new()
|
||||
.read(true)
|
||||
@@ -198,53 +243,29 @@ pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
|
||||
.open("/dev/tty")
|
||||
.context("open /dev/tty")?;
|
||||
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 = (|| {
|
||||
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);
|
||||
let result = run_loop(&mut tty, rows, subcommand, passthrough);
|
||||
|
||||
viewport_y = allocate_viewport(&mut tty, viewport_height, term_rows)?;
|
||||
tty.write_all(HIDE_CURSOR)?;
|
||||
tty.flush()?;
|
||||
|
||||
run_loop(&mut tty, rows, viewport_y, viewport_height, term_cols)
|
||||
})();
|
||||
|
||||
let _ = cleanup_at(&mut tty, viewport_y);
|
||||
let _ = tty.write_all(SHOW_CURSOR);
|
||||
let _ = tty.write_all(b"\x1b[?1049l");
|
||||
let _ = tty.flush();
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
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() {
|
||||
let original = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") {
|
||||
let _ = tty.write_all(SHOW_CURSOR);
|
||||
let _ = tty.write_all(b"\x1b[?1049l");
|
||||
let _ = tty.flush();
|
||||
}
|
||||
original(info);
|
||||
@@ -254,14 +275,15 @@ fn install_panic_hook() {
|
||||
fn run_loop(
|
||||
tty: &mut File,
|
||||
rows: Vec<Row>,
|
||||
viewport_y: u16,
|
||||
viewport_height: u16,
|
||||
term_cols: u16,
|
||||
subcommand: &str,
|
||||
passthrough: &[String],
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
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 {
|
||||
render(tty, &app, viewport_y, viewport_height, term_cols)?;
|
||||
render(tty, &mut app, term_rows, term_cols, subcommand, passthrough)?;
|
||||
if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
|
||||
continue;
|
||||
}
|
||||
@@ -271,8 +293,13 @@ fn run_loop(
|
||||
return Ok(action);
|
||||
}
|
||||
}
|
||||
Event::Resize(_, _) => {
|
||||
// Just redraw on next loop iteration; viewport stays the same.
|
||||
Event::Resize(new_cols, new_rows) => {
|
||||
// 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 +406,91 @@ fn delete_word(s: &mut String) {
|
||||
|
||||
fn render(
|
||||
tty: &mut File,
|
||||
app: &App,
|
||||
viewport_y: u16,
|
||||
viewport_height: u16,
|
||||
app: &mut App,
|
||||
term_rows: u16,
|
||||
term_cols: u16,
|
||||
subcommand: &str,
|
||||
passthrough: &[String],
|
||||
) -> 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)?;
|
||||
|
||||
// Input row.
|
||||
buf.extend_from_slice(SGR_BOLD);
|
||||
buf.extend_from_slice(SGR_FG_CYAN);
|
||||
buf.extend_from_slice("❯ ".as_bytes());
|
||||
buf.extend_from_slice(SGR_RESET);
|
||||
buf.extend_from_slice(app.filter.as_bytes());
|
||||
buf.extend_from_slice(SGR_FG_CYAN);
|
||||
buf.extend_from_slice("█".as_bytes());
|
||||
buf.extend_from_slice(SGR_RESET);
|
||||
// Reverse-video caret on a single space — matches fzf/lazygit/telescope
|
||||
// convention; survives any colorscheme without competing with the cyan ❯.
|
||||
buf.extend_from_slice(b"\x1b[7m \x1b[27m");
|
||||
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.
|
||||
let log_height = app.last_height;
|
||||
let visible: Vec<(usize, usize)> = app
|
||||
.filtered
|
||||
.iter()
|
||||
@@ -413,18 +501,80 @@ fn render(
|
||||
.collect();
|
||||
|
||||
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 is_cursor = filt_idx == app.cursor;
|
||||
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 {
|
||||
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(subcommand, passthrough, &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 (full width, below cmd preview).
|
||||
let hint_y = cmd_y + 1;
|
||||
write!(buf, "\x1b[{};1H", hint_y)?;
|
||||
let counts = if app.selected.is_empty() {
|
||||
format!("[{}/{}] ", app.filtered.len(), app.rows.len())
|
||||
} else {
|
||||
@@ -437,25 +587,179 @@ fn render(
|
||||
};
|
||||
buf.extend_from_slice(SGR_DIM);
|
||||
buf.extend_from_slice(counts.as_bytes());
|
||||
buf.extend_from_slice(
|
||||
"type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit".as_bytes(),
|
||||
);
|
||||
buf.extend_from_slice(b"type filter");
|
||||
write_hint_pair(&mut buf, "↑↓/^N^P".as_bytes(), b"nav");
|
||||
write_hint_pair(&mut buf, b"tab", b"select");
|
||||
write_hint_pair(&mut buf, b"enter", b"run");
|
||||
write_hint_pair(&mut buf, b"^U", b"clear");
|
||||
write_hint_pair(&mut buf, b"esc", b"quit");
|
||||
buf.extend_from_slice(SGR_RESET);
|
||||
buf.extend_from_slice(ERASE_TO_EOL);
|
||||
|
||||
// Park cursor at end of input line so terminal doesn't blink in the log.
|
||||
// Compute input column = column after "❯ " + filter chars + cursor block.
|
||||
// Park cursor at end of input line.
|
||||
let input_col = 2 + app.filter.chars().count() as u16 + 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.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool, width: u16) {
|
||||
// Selected marker (always rendered at column 0, two cells)
|
||||
fn write_hint_pair(buf: &mut Vec<u8>, key: &[u8], label: &[u8]) {
|
||||
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 {
|
||||
buf.extend_from_slice(SGR_BOLD);
|
||||
buf.extend_from_slice(SGR_FG_YELLOW);
|
||||
@@ -465,24 +769,94 @@ fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool,
|
||||
buf.extend_from_slice(b" ");
|
||||
}
|
||||
|
||||
let truncated = truncate_ansi(&row.styled, content_w);
|
||||
let used = visible_width(&truncated);
|
||||
|
||||
if is_cursor {
|
||||
buf.extend_from_slice(SGR_BG_CURSOR);
|
||||
// Inject bg-restore after every embedded reset so the row's bg persists.
|
||||
inject_bg_into(buf, &row.styled, SGR_BG_CURSOR);
|
||||
// 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' ');
|
||||
}
|
||||
}
|
||||
let bg = cursor_bg();
|
||||
buf.extend_from_slice(bg);
|
||||
inject_bg_into(buf, &truncated, bg);
|
||||
buf.extend_from_slice(SGR_RESET);
|
||||
buf.extend_from_slice(ERASE_TO_EOL);
|
||||
gutter_w + used
|
||||
} else {
|
||||
buf.extend_from_slice(&row.styled);
|
||||
buf.extend_from_slice(&truncated);
|
||||
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