feat: two-column layout with jj show preview pane

When term_cols >= 80, right side shows jj show --summary of the
currently highlighted revision (header stripped — just description and
the M/A/D file list, with description capped at 3 lines). Per-row
preview is cached, so navigating back to previously-viewed revs is
instant. Bumps the viewport floor to 15 rows (max = full terminal) so
the preview has room to be useful.
This commit is contained in:
swaits committed 2026-04-29 16:20:57 -06:00
1 parent 7dec6770cc
commit 6479867e61
2 files changed
+224 -33

No files matched your search

+154 -33
View File
@@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::time::Duration;
@@ -7,7 +7,10 @@ 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, Row};
const PREVIEW_MIN_COLS: u16 = 80;
const VIEWPORT_MIN: u16 = 15;
// SGR constants
const SGR_RESET: &[u8] = b"\x1b[0m";
@@ -31,6 +34,7 @@ struct App {
filtered: Vec<usize>,
matcher: Matcher,
last_height: usize,
preview_cache: HashMap<usize, Vec<u8>>,
}
impl App {
@@ -45,9 +49,19 @@ 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) -> &[u8] {
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.get(&row_idx).unwrap()
}
fn refilter(&mut self) {
if self.filter.is_empty() {
self.filtered = (0..self.rows.len()).collect();
@@ -202,9 +216,8 @@ pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
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 viewport_height = needed.max(VIEWPORT_MIN).min(term_rows.max(1));
viewport_y = allocate_viewport(&mut tty, viewport_height, term_rows)?;
tty.write_all(HIDE_CURSOR)?;
@@ -261,7 +274,7 @@ fn run_loop(
let mut app = App::new(rows);
app.last_height = (viewport_height as usize).saturating_sub(2); // input + hint
loop {
render(tty, &app, viewport_y, viewport_height, term_cols)?;
render(tty, &mut app, viewport_y, viewport_height, term_cols)?;
if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
continue;
}
@@ -379,17 +392,37 @@ fn delete_word(s: &mut String) {
fn render(
tty: &mut File,
app: &App,
app: &mut App,
viewport_y: u16,
viewport_height: u16,
term_cols: u16,
) -> Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
let log_height = app.last_height;
// Move to viewport top-left.
let preview_enabled = term_cols >= PREVIEW_MIN_COLS;
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);
(lw, rx, rw)
} else {
(term_cols as usize, term_cols, 0)
};
// Pre-compute preview lines (cached) for the cursor row, before borrowing app immutably.
let preview_lines: Vec<Vec<u8>> = if preview_enabled {
if let Some(&row_idx) = app.filtered.get(app.cursor) {
split_lines(app.preview_for(row_idx))
} 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());
@@ -399,10 +432,8 @@ fn render(
buf.extend_from_slice("█".as_bytes());
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
buf.extend_from_slice(b"\r\n");
// Log rows.
let log_height = app.last_height;
let visible: Vec<(usize, usize)> = app
.filtered
.iter()
@@ -413,18 +444,34 @@ fn render(
.collect();
for i in 0..log_height {
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);
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);
} else {
render_log_row(&mut buf, row, is_cursor, is_selected, left_w);
}
if preview_enabled {
write!(buf, "\x1b[{};{}H", row_y, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
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) {
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(b"\r\n");
}
// Hint row.
// Hint row (left only).
let hint_y = viewport_y + 1 + log_height as u16 + 1;
write!(buf, "\x1b[{};1H", hint_y)?;
let counts = if app.selected.is_empty() {
format!("[{}/{}] ", app.filtered.len(), app.rows.len())
} else {
@@ -443,19 +490,26 @@ fn render(
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.
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; // currently unused; reserved for future use
let _ = viewport_height;
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 render_log_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool, width: 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 +519,91 @@ 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' ');
}
inject_bg_into(buf, &truncated, SGR_BG_CURSOR);
let pad = content_w.saturating_sub(used);
for _ in 0..pad {
buf.push(b' ');
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
} 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);
}
}
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
}
}