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:
2 files changed
+224
-33
No files matched your search
@@ -122,6 +122,76 @@ 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> {
|
||||
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() => clip_description(&strip_show_header(&o.stdout), 3),
|
||||
_ => b"(preview unavailable)".to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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()
|
||||
} else {
|
||||
bytes.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let kept = desc_total.min(max_desc);
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut first = true;
|
||||
for line in lines.iter().take(kept) {
|
||||
if !first {
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.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
|
||||
}
|
||||
|
||||
pub fn supports_revisions(subcommand: &str) -> Result<bool> {
|
||||
let output = Command::new("jj")
|
||||
.arg(subcommand)
|
||||
|
||||
+154
-33
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user