diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bceb38..d6c93ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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.2.0] — 2026-04-29 + +Picker now renders jj's real graph (merge connectors and all), with the +cursor/filter aware of decorative rows. + +### Changed + +- Graph rendering now uses jj's native `--graph` output instead of a + custom one-row-per-commit reconstruction. Merge connectors + (`├─┬─╮`, `├─╯`, `╰─╯`) appear exactly as `jj log` draws them, so the + picker is a 1:1 visual match for the real log. +- Cursor row arrow glyphs changed from `├──►` to `●──▶` so the + selection arrow doesn't visually clash with the graph chrome. +- Hint row at the bottom of the picker drops trailing key/label pairs + (`esc quit`, `^U clear`, …) until it fits on one row. Previously a + narrow terminal would wrap the hint and push the search bar off the + top of the screen. + +### Added + +- Connector / decorative graph rows (`├─╮`, `╰─╯`, `~`, …) are kept in + the picker for visual fidelity with `jj log`. Cursor navigation + (arrows, Page{Up,Down}, Home, End, Ctrl-N/P/J/K, Ctrl-A/E) skips over + them; selection is silently ignored on connector rows. +- Filtering (any non-empty query) excludes connector rows, since they + can't match a query and a stray `├─╮` between filtered hits would + carry no useful information. + +### Fixed + +- Long descriptions no longer split across rows when the user has + `ui.log-word-wrap = true` configured globally — the internal + `jj log` invocation now forces `ui.log-word-wrap=false` so a single + commit always renders on a single picker row. + +[0.2.0]: https://github.com/swaits/jjf/releases/tag/v0.2.0 + ## [0.1.0] — 2026-04-29 Initial release. diff --git a/Cargo.lock b/Cargo.lock index ad6a6c3..620c447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "jjf" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 582261e..89613cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jjf" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.85" authors = ["Stephen Waits "] diff --git a/README.md b/README.md index 7c4b541..3ae9c45 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 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… + ○ 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 diff --git a/src/jj.rs b/src/jj.rs index efa43ec..8bfc339 100644 --- a/src/jj.rs +++ b/src/jj.rs @@ -4,10 +4,24 @@ 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. +// Custom oneline template emitting field-separated values. We run jj with +// its native `--graph` so the merge connectors (`├─┬─╮`, `╰─╯`) render +// exactly as they do in `jj log` — the picker treats those connector-only +// rows as decoration and keeps the cursor on commit rows. +// +// The template MUST emit a single line per commit (no embedded `\n`, +// `ui.log-word-wrap=false`), or jj's graph drawer would inject continuation +// rows into our one-line invariant. +// +// Fields (\x1f-separated, leading `\x1f` separates jj's graph chrome from +// our fields): +// 0 graph chrome (everything jj prepended before the first \x1f) +// 1 change_id.short() — full 12-char change id +// 2 change_id.shortest().prefix() — shortest unique prefix +// 3 commit_id.short() — full 12-char commit id +// 4 commit_id.shortest().prefix() — shortest unique prefix +// 5 payload — change-id-prefix-highlighted, bookmarks, conflict/empty, +// description.first_line() const TEMPLATE: &str = concat!( "\"\\x1f\" ++ ", "change_id.short() ++ \"\\x1f\" ++ ", @@ -23,6 +37,8 @@ const TEMPLATE: &str = concat!( ))", ); +/// Either a commit row (selectable) or a connector row (decoration only — +/// `├─┬─╮`, `│ │`, `~`, …). Connectors have empty `change_id_short`. pub struct Row { pub change_id_short: String, pub change_id_prefix: String, @@ -32,10 +48,22 @@ pub struct Row { pub styled: Vec, } +impl Row { + pub fn is_connector(&self) -> bool { + self.change_id_short.is_empty() + } +} + pub fn capture_log() -> Result> { let output = Command::new("jj") .args([ "--ignore-working-copy", + // Force off any user `ui.log-word-wrap = true` — wrapping would + // split a description into a continuation row that looks + // syntactically identical to a graph connector row, breaking the + // one-row-per-commit invariant the picker relies on. + "--config", + "ui.log-word-wrap=false", "log", "--color=always", "-r", @@ -58,14 +86,15 @@ pub fn capture_log() -> Result> { if raw.is_empty() { continue; } - if let Some(row) = parse_row(raw) { - rows.push(row); - } + rows.push(parse_row(raw)); } Ok(rows) } -fn parse_row(bytes: &[u8]) -> Option { +/// Parse one line of `jj log --graph` output. A line with our 5 `\x1f` +/// separators is a commit row; anything else is a connector / `~` / +/// continuation row that we keep as decoration but render unselectable. +fn parse_row(bytes: &[u8]) -> Row { let mut parts: Vec<&[u8]> = Vec::with_capacity(6); let mut start = 0; for (i, &b) in bytes.iter().enumerate() { @@ -78,7 +107,17 @@ fn parse_row(bytes: &[u8]) -> Option { } } if parts.len() < 5 { - return None; + // Connector / `~` / pure-graph row — keep as decoration only. + let styled = bytes.to_vec(); + let plain = strip_ansi(&styled); + return Row { + change_id_short: String::new(), + change_id_prefix: String::new(), + commit_id_short: String::new(), + commit_id_prefix: String::new(), + plain, + styled, + }; } parts.push(&bytes[start..]); @@ -89,24 +128,19 @@ fn parse_row(bytes: &[u8]) -> Option { let commit_id_prefix = strip_ansi(parts[4]); let payload = parts[5]; - if change_id_short.is_empty() { - return None; - } - let mut styled = Vec::with_capacity(graph.len() + payload.len()); styled.extend_from_slice(graph); styled.extend_from_slice(payload); - let plain = strip_ansi(&styled); - Some(Row { + Row { change_id_short, change_id_prefix, commit_id_short, commit_id_prefix, plain, styled, - }) + } } /// Strip CSI escape sequences (ESC `[` … letter) from a byte slice. diff --git a/src/tui.rs b/src/tui.rs index 07f129e..364b2b7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -68,7 +68,7 @@ struct App { impl App { fn new(rows: Vec) -> Self { let n = rows.len(); - Self { + let mut app = Self { rows, filter: String::new(), cursor: 0, @@ -78,7 +78,39 @@ impl App { matcher: Matcher::new(Config::DEFAULT), last_height: 0, preview_cache: HashMap::new(), + }; + // Land the initial cursor on the first commit (connectors are + // unselectable, so a commit log starting with a `~` placeholder + // shouldn't park the cursor on it). + app.cursor = app.next_selectable(0, 1).unwrap_or(0); + app + } + + /// Returns whether row `i` (in `self.rows`) is a connector / decorative + /// graph row that the cursor should skip over. + fn row_is_connector(&self, row_idx: usize) -> bool { + self.rows + .get(row_idx) + .is_some_and(|r| r.is_connector()) + } + + /// Find the nearest selectable position in `self.filtered` starting at + /// `from`, scanning by `step` (`1` = down, `-1` = up). Returns `None` if + /// every entry in the search direction is a connector. + fn next_selectable(&self, from: usize, step: isize) -> Option { + let len = self.filtered.len(); + if len == 0 { + return None; } + let mut idx = from as isize; + while (0..len as isize).contains(&idx) { + let row_idx = self.filtered[idx as usize]; + if !self.row_is_connector(row_idx) { + return Some(idx as usize); + } + idx += step; + } + None } fn preview_for(&mut self, row_idx: usize) -> &PreviewParts { @@ -91,8 +123,13 @@ impl App { fn refilter(&mut self) { if self.filter.is_empty() { + // Empty query: show every row including connectors so the user + // sees the full graph chrome. self.filtered = (0..self.rows.len()).collect(); } else { + // Non-empty query: only commits can match. Connectors carry no + // searchable content, so dropping them here also keeps the + // result list compact and free of orphaned graph fragments. let App { rows, matcher, @@ -105,6 +142,7 @@ impl App { let mut scored: Vec<(usize, u32)> = rows .iter() .enumerate() + .filter(|(_, row)| !row.is_connector()) .filter_map(|(i, row)| { score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s)) }) @@ -112,7 +150,7 @@ impl App { scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); self.filtered = scored.into_iter().map(|(i, _)| i).collect(); } - self.cursor = 0; + self.cursor = self.next_selectable(0, 1).unwrap_or(0); self.view_offset = 0; } @@ -121,28 +159,34 @@ impl App { if len == 0 { return; } - let mut c = self.cursor as isize + delta; - if c < 0 { - c = 0; - } let max = len as isize - 1; - if c > max { - c = max; + let target_pos = (self.cursor as isize + delta).clamp(0, max) as usize; + // Scan past connector rows in the direction of motion. If we hit a + // wall (e.g. PageDown near the end with only connectors below), fall + // back to scanning the opposite direction so we always land on a + // commit. + let primary_step: isize = if delta >= 0 { 1 } else { -1 }; + if let Some(t) = self + .next_selectable(target_pos, primary_step) + .or_else(|| self.next_selectable(target_pos, -primary_step)) + { + self.cursor = t; + self.adjust_view(); } - self.cursor = c as usize; - self.adjust_view(); } fn jump_top(&mut self) { - self.cursor = 0; + self.cursor = self.next_selectable(0, 1).unwrap_or(0); self.adjust_view(); } fn jump_bottom(&mut self) { - if !self.filtered.is_empty() { - self.cursor = self.filtered.len() - 1; - self.adjust_view(); + if self.filtered.is_empty() { + return; } + let last = self.filtered.len() - 1; + self.cursor = self.next_selectable(last, -1).unwrap_or(last); + self.adjust_view(); } fn adjust_view(&mut self) { @@ -158,6 +202,7 @@ impl App { fn toggle_select(&mut self) { if let Some(&row_idx) = self.filtered.get(self.cursor) + && !self.row_is_connector(row_idx) && !self.selected.insert(row_idx) { self.selected.remove(&row_idx); @@ -431,9 +476,9 @@ fn render( // 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) + // 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+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 { @@ -448,7 +493,7 @@ fn render( // 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 `│ ` (3-char gutter) or - // `├─►` on the cursor's row, drawing the eye from picker + // `●─▶` 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); @@ -521,17 +566,17 @@ fn render( if preview_enabled { if on_cursor { // Dynamic arrow inline, starting at col visible_used+1: - // ` ` ├ ─×N ► ` ` then preview content at col left_w+6. + // ` ` ● ─×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("●".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("▶".as_bytes()); buf.extend_from_slice(SGR_RESET); buf.push(b' '); } else { @@ -572,7 +617,9 @@ fn render( buf.extend_from_slice(cmd_visible.as_bytes()); buf.extend_from_slice(SGR_RESET); - // Hint row (full width, below cmd preview). + // Hint row — must fit on a single line. If it wrapped, the wrapped + // tail would push the input row off the top of the terminal. So drop + // pairs from the right until the row fits in `term_cols`. let hint_y = cmd_y + 1; write!(buf, "\x1b[{};1H", hint_y)?; let counts = if app.selected.is_empty() { @@ -585,14 +632,32 @@ fn render( app.selected.len() ) }; + let pairs: &[(&str, &str)] = &[ + ("↑↓/^N^P", "nav"), + ("tab", "select"), + ("enter", "run"), + ("^U", "clear"), + ("esc", "quit"), + ]; + let base_w = counts.chars().count() + "type filter".chars().count(); + let max_w = term_cols as usize; + let mut used = base_w; + let mut keep = 0usize; + for (key, label) in pairs { + // " · " (3) + key + " " (1) + label + let w = 3 + key.chars().count() + 1 + label.chars().count(); + if used + w > max_w { + break; + } + used += w; + keep += 1; + } buf.extend_from_slice(SGR_DIM); buf.extend_from_slice(counts.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"); + for (key, label) in pairs.iter().take(keep) { + write_hint_pair(&mut buf, key.as_bytes(), label.as_bytes()); + } buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(ERASE_TO_EOL);