feat(ux): more refinements

- Custom oneline template (drops timestamp + commit-id hash from the
  left pane); keeps change-id with shortest-unique-prefix highlighting,
  bookmarks, conflict/empty labels, and the description first-line.
  More room for the actually-useful columns.
- Drop the dim '── <change_id> ────' header above the preview; the
  cursor row tee (├) now carries the 'this is what you're looking at'
  signal.
- Box-char frame for the preview pane: top border (┌────) and left
  border (│), with ├ replacing │ on the cursor row to draw the eye
  from picker selection into preview content.
- Dynamic description clipping: the description fills whatever rows
  are left after the file list (ellipsis on overflow). Replaces the
  hard 3-line cap, which under-used vertical space for short file
  lists and was buggy (didn't count blank lines within description).
- Bump VIEWPORT_MIN from 15 to 20 so the preview pane has enough
  vertical room to be useful by default.
This commit is contained in:
swaits committed 2026-04-29 18:12:40 -06:00
1 parent 8605528960
commit d2740008e9
4 files changed
+383 -145

No files matched your search

+82 -43
View File
@@ -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,9 +132,21 @@ pub fn strip_ansi(bytes: &[u8]) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// Run `jj show --summary` for a single revision and return the body
/// (description + diff summary) with the metadata header stripped.
pub fn show_summary(change_id: &str) -> Vec<u8> {
/// 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",
@@ -137,59 +159,76 @@ pub fn show_summary(change_id: &str) -> Vec<u8> {
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => clip_description(&strip_show_header(&o.stdout), 3),
_ => b"(preview unavailable)".to_vec(),
Ok(o) if o.status.success() => parse_show_summary(&o.stdout),
_ => PreviewParts {
description: b"(preview unavailable)".to_vec(),
files: Vec::new(),
},
}
}
/// `jj show` output starts with `Commit ID:` / `Change ID:` / `Author:` /
/// `Committer:` lines, then a blank line, then the description, then file
/// list. Skip everything up to and including the first blank line.
fn strip_show_header(bytes: &[u8]) -> Vec<u8> {
if let Some(pos) = bytes.windows(2).position(|w| w == b"\n\n") {
bytes[pos + 2..].to_vec()
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.to_vec()
}
}
bytes
};
/// Truncate the (4-space-indented) description block to `max_desc` lines,
/// appending a dim ellipsis to the last kept line if anything was clipped.
/// Lines after the description (blank separator + `M`/`A`/`D` file list)
/// pass through unchanged so the file summary remains visible.
fn clip_description(bytes: &[u8], max_desc: usize) -> Vec<u8> {
let lines: Vec<&[u8]> = bytes.split(|&b| b == b'\n').collect();
// Description lines are the leading lines that start with 4 spaces.
let mut desc_total = 0;
for line in &lines {
if line.starts_with(b" ") {
desc_total += 1;
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;
}
let kept = desc_total.min(max_desc);
let mut out = Vec::with_capacity(bytes.len());
// 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().take(kept) {
if !first {
out.push(b'\n');
for line in lines.iter().skip(desc_end) {
if line.is_empty() {
continue;
}
out.extend_from_slice(line);
if !first {
files.push(b'\n');
}
files.extend_from_slice(line);
first = false;
}
if desc_total > max_desc {
out.extend_from_slice(b" \x1b[2m\xe2\x80\xa6\x1b[0m");
}
for line in lines.iter().skip(desc_total) {
if !first {
out.push(b'\n');
}
out.extend_from_slice(line);
first = false;
}
out
PreviewParts { description, files }
}
pub fn supports_revisions(subcommand: &str) -> Result<bool> {
+1 -1
View File
@@ -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)?;
+290 -101
View File
@@ -8,10 +8,9 @@ use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::jj::{self, Row};
use crate::jj::{self, PreviewParts, Row};
const PREVIEW_MIN_COLS: u16 = 80;
const VIEWPORT_MIN: u16 = 15;
// SGR constants
const SGR_RESET: &[u8] = b"\x1b[0m";
@@ -22,7 +21,6 @@ const SGR_BOLD: &[u8] = b"\x1b[1m";
const SGR_FG_CYAN: &[u8] = b"\x1b[36m";
const SGR_FG_YELLOW: &[u8] = b"\x1b[33m";
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
@@ -64,7 +62,7 @@ struct App {
filtered: Vec<usize>,
matcher: Matcher,
last_height: usize,
preview_cache: HashMap<usize, Vec<u8>>,
preview_cache: HashMap<usize, PreviewParts>,
}
impl App {
@@ -83,11 +81,10 @@ impl App {
}
}
fn preview_for(&mut self, row_idx: usize) -> &[u8] {
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();
let bytes = jj::show_summary(&cid);
self.preview_cache.insert(row_idx, bytes);
self.preview_cache.insert(row_idx, jj::show_summary(&cid));
}
self.preview_cache.get(&row_idx).unwrap()
}
@@ -234,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)
@@ -242,52 +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 needed = (rows.len() as u16).saturating_add(2);
let viewport_height = needed.max(VIEWPORT_MIN).min(term_rows.max(1));
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);
@@ -297,14 +275,16 @@ 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, &mut 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;
}
@@ -314,8 +294,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();
}
_ => {}
}
@@ -423,45 +408,61 @@ fn delete_word(s: &mut String) {
fn render(
tty: &mut File,
app: &mut App,
viewport_y: u16,
viewport_height: u16,
term_rows: u16,
term_cols: u16,
subcommand: &str,
passthrough: &[String],
) -> Result<()> {
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;
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(1) as usize;
let rx = term_cols / 2 + 1;
let rw = (term_cols as usize).saturating_sub(rx as usize);
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)
};
// Pre-compute preview lines (cached) for the cursor row, before borrowing app immutably.
// The first preview row is a dim header anchoring the pane to the cursor's change ID.
let (preview_header, preview_lines): (Option<Vec<u8>>, Vec<Vec<u8>>) = if preview_enabled {
// 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 cid = if app.rows[row_idx].change_id_prefix.is_empty() {
app.rows[row_idx].change_id_short.clone()
} else {
app.rows[row_idx].change_id_prefix.clone()
};
let label = format!("── {cid} ");
let dashes = right_w.saturating_sub(label.chars().count());
let header = format!(
"\x1b[2m{label}{}\x1b[0m",
"─".repeat(dashes)
);
(Some(header.into_bytes()), split_lines(app.preview_for(row_idx)))
let parts = app.preview_for(row_idx);
build_preview_lines(parts, preview_rows, preview_content_w)
} else {
(None, Vec::new())
Vec::new()
}
} else {
(None, Vec::new())
Vec::new()
};
// Input row (left only).
@@ -476,6 +477,20 @@ fn render(
buf.extend_from_slice(b"\x1b[7m \x1b[27m");
buf.extend_from_slice(ERASE_TO_EOL);
// 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 visible: Vec<(usize, usize)> = app
.filtered
@@ -491,35 +506,75 @@ fn render(
write!(buf, "\x1b[{};1H", row_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
if let Some(&(filt_idx, row_idx)) = visible.get(i) {
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_log_row(&mut buf, row, is_cursor, is_selected, left_w);
}
render_log_row(&mut buf, row, is_cursor, is_selected, left_w)
} else {
0
};
if preview_enabled {
write!(buf, "\x1b[{};{}H", row_y, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
if i == 0 {
if let Some(h) = &preview_header {
buf.extend_from_slice(h);
}
} else {
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());
buf.extend_from_slice(SGR_RESET);
if let Some(line) = preview_lines.get(i - 1) {
let clipped = truncate_ansi(line, right_w.saturating_sub(2));
buf.extend_from_slice(&clipped);
buf.extend_from_slice(SGR_RESET);
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);
}
}
}
// Hint row (left only).
let hint_y = viewport_y + 1 + log_height as u16 + 1;
// 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())
@@ -531,8 +586,6 @@ fn render(
app.selected.len()
)
};
// Two-tone hint: keys at normal weight, labels dim. Gives the eye an anchor
// to scan by without removing any information.
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice(counts.as_bytes());
buf.extend_from_slice(b"type filter");
@@ -544,16 +597,12 @@ fn render(
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
if preview_enabled {
write!(buf, "\x1b[{};{}H", hint_y, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
}
// 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;
let _ = right_x;
buf.extend_from_slice(SYNC_END);
tty.write_all(&buf)?;
tty.flush()?;
@@ -569,7 +618,145 @@ fn write_hint_pair(buf: &mut Vec<u8>, key: &[u8], label: &[u8]) {
buf.extend_from_slice(label);
}
fn render_log_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool, width: usize) {
/// 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);
@@ -590,14 +777,16 @@ fn render_log_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bo
let bg = cursor_bg();
buf.extend_from_slice(bg);
inject_bg_into(buf, &truncated, bg);
buf.extend_from_slice(SGR_RESET);
gutter_w + used
} else {
buf.extend_from_slice(&truncated);
buf.extend_from_slice(SGR_RESET);
let pad = content_w.saturating_sub(used);
for _ in 0..pad {
buf.push(b' ');
}
buf.extend_from_slice(SGR_RESET);
} else {
buf.extend_from_slice(&truncated);
buf.extend_from_slice(SGR_RESET);
width
}
}
+10
View File
@@ -0,0 +1,10 @@
❯ ┌──────────────────────────────────────────────────────────────
○ tovs chore(release): prepare 0.1.0 for crates.io │ Mirrors the original bash+fzf script's behavior — `jjf`
@ xnkx feat(ux): more refinements │ alone becomes
○ ssnu feat(ux): batch UX improvements │ a quick rev browser. Implemented in shell wrappers (so the
○ lyxo fix(history): drop jjf entry on cancel/error paths t │ --emit
○ tqvr feat: two-column layout with jj show preview pane │ history-rewrite path still applies); the binary itself still
○ yksz feat: default to 'show' subcommand when jjf is invok ├─► prints
○ lsqz feat: broaden picker revset to all() to expose every │ usage on bare invocation, for explicit invocations.
○ nzwx feat: initial implementation of jjf │
◆ zzzz (empty) │ M src/init/bash.sh