From 47eef7f71006ea1660f4a47dd98363e1011cbf16 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 14:58:15 -0400 Subject: [PATCH 01/19] feat(review): devicons-backed icon table with brand colors --- Cargo.lock | 10 ++ Cargo.toml | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/icons.rs | 177 +++++++++++++++++++++++--------- git-workon-review/src/render.rs | 30 ++++-- 5 files changed, 162 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7cb53d85..0f6b108c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "devicons" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e47e2f330cf4fdd5a958dcef921b9523ffc21ab6713aa5e77ba2cce03904b" +dependencies = [ + "lazy_static", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -965,6 +974,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "devicons", "dirs", "expectrl", "git-workon-fixture", diff --git a/Cargo.toml b/Cargo.toml index f68fe8c4..92a63124 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ clap-verbosity-flag = "3.0.4" clap_complete = { version = "4.6.5", features = ["unstable-dynamic"] } clap_mangen = "0.3.0" crossterm = "0.29.0" +devicons = "0.6" dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } dirs = "6.0" env_logger = "0.11.10" diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index 32bc7e9c..d9e9a408 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -35,6 +35,7 @@ vendored = ["git-workon-lib/vendored", "git2/vendored-libgit2", "git2/vendored-o clap.workspace = true clap_complete.workspace = true crossterm.workspace = true +devicons.workspace = true dirs.workspace = true git-workon-lib.workspace = true git2.workspace = true diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index cd3ba7df..dc8f969a 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -6,6 +6,21 @@ //! icons are strictly opt-in via `workon.review.outline.icons = nerd` (see `config.rs`'s schema //! doc block and `App::apply_view_config`). With the config left at its default (`none`), //! nothing in this module is ever called from `render.rs`. +//! +//! **Icon table (CS1 polish pass):** per-file glyphs and brand colors are looked up via the +//! [`devicons`] crate (Apache-2.0, `alexpasmantier/devicons`) rather than a hand-rolled table — +//! 597 filename+extension entries with the same filename-before-extension precedence +//! [`icon_for_path`] already followed. devicons ships separate Dark/Light color maps; the caller +//! picks one from the active [`crate::theme::Palette`] (see [`icon_for_path`]'s doc comment). +//! **Nerd-font v3 requirement:** devicons' glyphs are drawn from nerd-font v3's private-use +//! codepoints, roughly a fifth of which sit in a Unicode supplementary plane (outside the BMP). +//! The crate's own `OutlineIcons::Nerd` glyphs picked in CS3 (status/header markers) stay +//! BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 +//! nerd-font — this is the same "no auto-detection" opt-in tradeoff as the rest of this module. +//! devicons does not cover directories (it is a per-file mapper), so [`DIR_ICON`] is still ours. + +use devicons::{icon_for_file, FileIcon, Theme as DeviconsTheme}; +use ratatui::style::Color; /// Which of the outline's icon strategies is active — `workon.review.outline.icons` /// (`nerd`/`none`), read once at startup by `App::apply_view_config` (CS5 mirrors CS3's @@ -22,40 +37,59 @@ pub enum OutlineIcons { } /// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every -/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. +/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. devicons is a +/// per-file mapper (it has no directory entries), so this stays our own constant. pub const DIR_ICON: char = '\u{f07b}'; // nf-fa-folder -/// The fallback file icon (nerd-font `nf-fa-file`, U+F15B) for any extension not in -/// [`icon_for_path`]'s table (including extensionless files). +/// The fallback file icon (nerd-font `nf-fa-file`, U+F15B) — devicons itself falls back to a +/// generic glyph for unrecognized extensions, but [`icon_for_path`] surfaces this constant +/// instead so callers (and this module's own tests) have a stable, documented "unknown" glyph. pub const DEFAULT_ICON: char = '\u{f15b}'; // nf-fa-file -/// Look up the nerd-font glyph for `path`'s extension — small, deliberately-curated table -/// covering the languages this crate's own `highlight.rs` already bundles grammars for -/// (`lang_key_for_ext`), plus a couple of common project files. Every codepoint below is in the -/// nerd-font private-use area (`seti`/`devicons`/`fa` icon sets); unrecognized extensions and -/// extensionless files fall back to [`DEFAULT_ICON`]. -pub fn icon_for_path(path: &str) -> char { - // `Cargo.lock`/other `*.lock` files: match on the file NAME first, since "lock" isn't a - // meaningful extension-based language distinction the way the rest of the table is. - let name = path.rsplit('/').next().unwrap_or(path); - if name.ends_with(".lock") { - return '\u{f023}'; // nf-fa-lock +/// Look up the nerd-font glyph and brand color for `path`, via [`devicons::icon_for_file`]. +/// `light_background` selects devicons' Light vs Dark color map — pass +/// `crate::theme::is_light_background(palette.background)` so the icon color suits the active +/// [`crate::theme::Palette`], not necessarily the terminal's real background. +/// +/// Returns `(glyph, color)`, where `color` is `None` if devicons' hex string didn't parse (never +/// observed in practice, but handled without panicking rather than trusting an external crate's +/// string format unconditionally) — callers should fall back to their own plain foreground. +pub fn icon_for_path(path: &str, light_background: bool) -> (char, Option) { + let devicons_theme = Some(if light_background { + DeviconsTheme::Light + } else { + DeviconsTheme::Dark + }); + let mut resolved = icon_for_file(path, &devicons_theme); + if resolved.icon == FileIcon::default().icon { + // devicons' filename match is exact-case (only its EXTENSION fallback lowercases), so + // "Makefile" misses its lowercase-only "makefile" key. Mirror its extension strategy: + // exact first (some keys, e.g. "PKGBUILD"/".Xresources", exist ONLY in exact case), then + // retry with the lowercased basename. `FileIcon::default().icon` is devicons' unknown-file + // sentinel — no real table entry uses it (verified against 0.6.12's tables). + let name = path.rsplit('/').next().unwrap_or(path).to_lowercase(); + resolved = icon_for_file(name.as_str(), &devicons_theme); + } + if resolved.icon == FileIcon::default().icon { + // Still unknown: surface OUR stable fallback glyph (see `DEFAULT_ICON`) instead of + // devicons' bare `*`, which reads as a typo next to real icon glyphs. + return (DEFAULT_ICON, None); } - let ext = match name.rsplit_once('.') { - Some((_, ext)) => ext, - None => return DEFAULT_ICON, - }; - match ext { - "rs" => '\u{e7a8}', // seti-rust - "lua" => '\u{e620}', // seti-lua - "js" | "mjs" | "cjs" => '\u{e74e}', // seti-javascript - "jsx" | "tsx" => '\u{e7ba}', // seti-react - "ts" | "mts" | "cts" => '\u{e628}', // seti-typescript - "json" => '\u{e60b}', // seti-json - "toml" => '\u{e6b2}', // seti-config (toml has no dedicated seti glyph) - "md" | "markdown" => '\u{e73e}', // seti-markdown - _ => DEFAULT_ICON, + (resolved.icon, parse_hex_color(resolved.color)) +} + +/// Parse a `"#rrggbb"` hex string (devicons' [`FileIcon::color`] format) into a +/// [`ratatui::style::Color::Rgb`]. Returns `None` on anything malformed rather than panicking — +/// this is data from an external crate, not a value this codebase controls. +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#')?; + if hex.len() != 6 { + return None; } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) } #[cfg(test)] @@ -63,29 +97,80 @@ mod tests { use super::*; #[test] - fn known_extensions_map_to_their_glyphs() { - assert_eq!(icon_for_path("src/main.rs"), '\u{e7a8}'); - assert_eq!(icon_for_path("scripts/init.lua"), '\u{e620}'); - assert_eq!(icon_for_path("index.js"), '\u{e74e}'); - assert_eq!(icon_for_path("app.mjs"), '\u{e74e}'); - assert_eq!(icon_for_path("component.tsx"), '\u{e7ba}'); - assert_eq!(icon_for_path("component.jsx"), '\u{e7ba}'); - assert_eq!(icon_for_path("types.ts"), '\u{e628}'); - assert_eq!(icon_for_path("package.json"), '\u{e60b}'); - assert_eq!(icon_for_path("Cargo.toml"), '\u{e6b2}'); - assert_eq!(icon_for_path("README.md"), '\u{e73e}'); + fn known_extensions_map_to_devicons_glyphs() { + // Pinned against devicons 0.6.12's actual table — these WILL need updating if the crate + // revises its glyph picks; that's an intentional pin, not a bug. + assert_eq!(icon_for_path("src/main.rs", false).0, '\u{e68b}'); + assert_eq!(icon_for_path("index.js", false).0, '\u{e60c}'); + assert_eq!(icon_for_path("component.tsx", false).0, '\u{e7ba}'); + assert_eq!(icon_for_path("types.ts", false).0, '\u{e628}'); + assert_eq!(icon_for_path("package.json", false).0, '\u{e71e}'); + assert_eq!(icon_for_path("Cargo.toml", false).0, '\u{e6b2}'); + assert_eq!(icon_for_path("README.md", false).0, '\u{f48a}'); + } + + #[test] + fn filename_precedence_matches_our_previous_lock_file_handling() { + // devicons matches on filename before extension, same shape as the old hand-rolled table. + assert_eq!(icon_for_path("Cargo.lock", false).0, '\u{e672}'); + assert_eq!(icon_for_path("nested/dir/yarn.lock", false).0, '\u{e672}'); + } + + #[test] + fn newly_covered_project_files_resolve_to_devicons_glyphs() { + // Names our old 8-entry table didn't cover — devicons' broader table does. + assert_eq!(icon_for_path("Makefile", false).0, '\u{e779}'); + assert_eq!(icon_for_path("Dockerfile", false).0, '\u{f0868}'); + assert_eq!(icon_for_path(".gitignore", false).0, '\u{e702}'); + } + + #[test] + fn filename_match_retries_lowercased_when_the_exact_case_misses() { + // devicons' own filename lookup is exact-case; its table has "makefile" but no + // "Makefile", so without our lowercased retry the standard spelling would fall through + // to the unknown-file fallback. + assert_eq!( + icon_for_path("Makefile", false), + icon_for_path("makefile", false) + ); + assert_eq!(icon_for_path("nested/dir/LICENSE", false).0, '\u{e60a}'); + } + + #[test] + fn unknown_files_fall_back_to_our_default_icon_not_devicons_asterisk() { + // devicons returns a literal '*' for unknown files; `icon_for_path` surfaces our stable + // DEFAULT_ICON (with no color) instead — see `DEFAULT_ICON`'s doc comment. + assert_eq!( + icon_for_path("file.zzznotreal", false), + (DEFAULT_ICON, None) + ); + assert_eq!(icon_for_path("noextension", false), (DEFAULT_ICON, None)); + } + + #[test] + fn colors_differ_between_dark_and_light_themes_for_a_branded_extension() { + let (_, dark_color) = icon_for_path("src/main.rs", false); + let (_, light_color) = icon_for_path("src/main.rs", true); + assert!(dark_color.is_some()); + assert!(light_color.is_some()); } #[test] - fn lock_files_match_on_name_not_extension() { - assert_eq!(icon_for_path("Cargo.lock"), '\u{f023}'); - assert_eq!(icon_for_path("nested/dir/yarn.lock"), '\u{f023}'); + fn parse_hex_color_accepts_well_formed_rrggbb() { + assert_eq!( + parse_hex_color("#a074c4"), + Some(Color::Rgb(0xa0, 0x74, 0xc4)) + ); + assert_eq!(parse_hex_color("#000000"), Some(Color::Rgb(0, 0, 0))); + assert_eq!(parse_hex_color("#ffffff"), Some(Color::Rgb(255, 255, 255))); } #[test] - fn unknown_and_extensionless_paths_fall_back_to_the_default_icon() { - assert_eq!(icon_for_path("Makefile"), DEFAULT_ICON); - assert_eq!(icon_for_path("script.sh"), DEFAULT_ICON); - assert_eq!(icon_for_path("noextension"), DEFAULT_ICON); + fn parse_hex_color_rejects_malformed_input_without_panicking() { + assert_eq!(parse_hex_color("a074c4"), None); // missing '#' + assert_eq!(parse_hex_color("#a074c"), None); // too short + assert_eq!(parse_hex_color("#a074c400"), None); // too long + assert_eq!(parse_hex_color("#zzzzzz"), None); // not hex digits + assert_eq!(parse_hex_color(""), None); } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index d536cd7e..aade2ceb 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -613,11 +613,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) } else { tree_prefix(guides) }; - let icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::icon_for_path(path)), - OutlineIcons::None => String::new(), - }; - Line::from(vec![ + let mut spans = vec![ TSpan::styled( format!("{prefix}{glyph}"), Style::default().fg(theme.foreground), @@ -626,11 +622,23 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) letter.to_string(), Style::default().fg(change_letter_color(*change, theme)), ), - TSpan::styled( - format!(" {icon}{path}"), - Style::default().fg(theme.foreground), - ), - ]) + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + ]; + if icons == OutlineIcons::Nerd { + let (icon, color) = crate::icons::icon_for_path( + path, + crate::theme::is_light_background(theme.background), + ); + spans.push(TSpan::styled( + format!("{icon} "), + Style::default().fg(color.unwrap_or(theme.foreground)), + )); + } + spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )); + Line::from(spans) } } } @@ -2908,7 +2916,7 @@ mod tests { .find(|r| r.contains("main.rs")) .expect("main.rs file row present"); assert!( - file_row.contains(crate::icons::icon_for_path("main.rs")), + file_row.contains(crate::icons::icon_for_path("main.rs", false).0), "expected the rust file icon before main.rs, got: {file_row:?}" ); } From 14bb3e724ed2c46b0d442cc7dd60be11c7257a98 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:18:26 -0400 Subject: [PATCH 02/19] feat(review): promote semantic fg colors to palette knobs --- docs/adr/035-review-theming-base16-hybrid.md | 12 ++++ git-workon-review/src/render.rs | 64 ++++++++--------- git-workon-review/src/theme.rs | 73 ++++++++++++++++++-- 3 files changed, 114 insertions(+), 35 deletions(-) diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index 4a93a1f9..fd0cf817 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -132,6 +132,18 @@ read left untested (see `terminal_query.rs`). to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render tests that assert concrete colors must resolve through a fixed test `Palette`. +## Revised (CS2, visual-polish pass) + +The "chrome that is never a theme knob (error/warn/current-marker) stays ANSI/const in +`render.rs`" clause above is superseded. Those three colors are now `Palette` fields +(`error_fg`/`warn_fg`/`current_fg`, mapped to base08/base0A/base0B) rather than module +consts — the user explicitly approved revisiting this boundary during the icons/semantic-fg +polish pass. `dark()` keeps the shipped RGB values verbatim (the same pixel-identity +precedent the diff/cursor tints follow); `light()` takes `ONE_LIGHT`'s base08/base0A/base0B; +`from_terminal()` takes the probed scheme's base08/base0A/base0B directly, same reasoning as +the syntax slots (matching the terminal, not curated-tint-borrowing). No other part of the +hybrid boundary changes: this only moves three named colors from `const` to palette fields. + ## References - [ADR-034](034-review-git-native-config-schema.md) — `workon.review.theme` config key diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index aade2ceb..97e7f1d2 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -30,20 +30,11 @@ use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax // foreground) come from a [`Palette`] threaded through render (ADR-035). The canvas background and // default/dim/gutter chrome foreground ALSO now come from the palette (`theme.background`/ -// `theme.foreground`/`theme.dim`/`theme.gutter`) — see the theme module's revised hybrid-boundary -// doc comment — so a curated theme fully controls the look. Only semantic chrome that is never a -// theme knob (error/warn/current-marker) stays ANSI-named / const below. - -/// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on -/// both light and dark terminal themes. -const FG_ERROR: Color = Color::Rgb(220, 60, 60); -/// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct -/// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. -const FG_WARN: Color = Color::Rgb(214, 158, 46); -/// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked decision -/// #9's outline half) — a green, distinct from every other marker color in this module so -/// "current" reads unambiguously at a glance. -const FG_CURRENT: Color = Color::Rgb(96, 200, 128); +// `theme.foreground`/`theme.dim`/`theme.gutter`), as does the semantic chrome that used to be +// const here — error/warn/current-marker are now `theme.error_fg`/`theme.warn_fg`/ +// `theme.current_fg` (CS2, revising ADR-035's hybrid boundary) — see the theme module's revised +// hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this +// module hardcodes a semantic color anymore. /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -470,7 +461,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// Render the outline side pane's rows into `area`: [`OutlineItem::Header`]s (Stack mode only) /// carry the changeset's position marker (green ● for `cs.current`) and needs-restack glyph -/// (amber ⚠, [`FG_WARN`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an +/// (amber ⚠, [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -563,7 +554,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) let marker = if *current { "\u{25CF} " } else { " " }; let mut spans = vec![TSpan::styled( marker.to_string(), - Style::default().fg(FG_CURRENT), + Style::default().fg(theme.current_fg), )]; spans.push(TSpan::styled( label.clone(), @@ -572,12 +563,18 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) .add_modifier(Modifier::BOLD), )); if *needs_restack { - spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + spans.push(TSpan::styled( + " \u{26A0}", + Style::default().fg(theme.warn_fg), + )); } // ADR-037: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { - spans.push(TSpan::styled(" \u{2717}", Style::default().fg(FG_ERROR))); + spans.push(TSpan::styled( + " \u{2717}", + Style::default().fg(theme.error_fg), + )); } else if *loading { spans.push(TSpan::styled(" \u{2026}", Style::default().fg(theme.dim))); } @@ -705,7 +702,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { if cs.needs_restack { spans.push(TSpan::styled( " ⚠ needs restack", - Style::default().fg(FG_WARN).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.warn_fg) + .add_modifier(Modifier::BOLD), )); } let fidx = app.current + 1; @@ -726,7 +725,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( - Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), + Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(theme.error_fg)), area, ); return; @@ -734,7 +733,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them match &app.notice { Some(Notice { text, severity }) => { let fg = match severity { - Severity::Error => FG_ERROR, + Severity::Error => theme.error_fg, Severity::Info => theme.foreground, }; frame.render_widget( @@ -918,7 +917,7 @@ fn changeset_summary_lines( let mut title_spans = vec![TSpan::styled( if summary.current { "\u{25CF} " } else { " " }, - Style::default().fg(FG_CURRENT), + Style::default().fg(theme.current_fg), )]; title_spans.push(TSpan::styled( summary.label.clone(), @@ -927,7 +926,10 @@ fn changeset_summary_lines( .add_modifier(Modifier::BOLD), )); if summary.needs_restack { - title_spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); + title_spans.push(TSpan::styled( + " \u{26A0}", + Style::default().fg(theme.warn_fg), + )); } lines.push(Line::from(title_spans)); @@ -938,7 +940,7 @@ fn changeset_summary_lines( .unwrap_or("(no error message)"); lines.push(Line::from(TSpan::styled( format!("\u{2717} {msg}"), - Style::default().fg(FG_ERROR), + Style::default().fg(theme.error_fg), ))); return lines; } @@ -1012,7 +1014,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { if let Some(message) = app.current_failure() { let msg = format!("Failed to load this changeset: {message}"); frame.render_widget( - Paragraph::new(msg).style(Style::default().fg(FG_ERROR)), + Paragraph::new(msg).style(Style::default().fg(theme.error_fg)), area, ); return; @@ -2298,7 +2300,7 @@ mod tests { ); assert_eq!( buf.cell((0, footer_y)).unwrap().style().fg, - Some(super::FG_ERROR), + Some(Palette::dark().error_fg), "expected the error notice to render in the error fg color" ); } @@ -2438,7 +2440,7 @@ mod tests { let marker_x = header.find('⚠').expect("restack glyph present") as u16; assert_eq!( buf.cell((marker_x, 0)).unwrap().style().fg, - Some(super::FG_WARN), + Some(Palette::dark().warn_fg), "expected the restack glyph to carry the warning color, not the plain header color" ); } @@ -2650,8 +2652,8 @@ mod tests { let marker_x = content[row].find('\u{25CF}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, - Some(super::FG_CURRENT), - "expected the outline's current marker to carry FG_CURRENT" + Some(Palette::dark().current_fg), + "expected the outline's current marker to carry Palette::dark().current_fg" ); } @@ -2672,8 +2674,8 @@ mod tests { let marker_x = content[row].find('\u{26A0}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, - Some(super::FG_WARN), - "expected the outline's restack glyph to carry FG_WARN" + Some(Palette::dark().warn_fg), + "expected the outline's restack glyph to carry Palette::dark().warn_fg" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index a9b63ce2..a4b0d4a0 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -6,7 +6,7 @@ //! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! -//! ## Hybrid boundary (ADR-035, revised) +//! ## Hybrid boundary (ADR-035, twice-revised) //! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the //! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live //! here, as before. The canvas background and chrome FOREGROUND (default text, dim labels, the @@ -15,9 +15,17 @@ //! look instead of bleeding the terminal's own bg/fg through. `auto` ([`Palette::from_terminal`]) //! still derives these four from the probed terminal colors — so it matches the terminal exactly — //! and leaves [`Palette::paint_canvas`] `false` so a transparent/backgrounded terminal isn't -//! painted over; the curated schemes and the probe's curated fallback set it `true`. Semantic -//! chrome that is never on a tint and never a theme knob — error/warn/current-marker colors — stays -//! ANSI/const in [`crate::render`] (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), unaffected by this boundary. +//! painted over; the curated schemes and the probe's curated fallback set it `true`. +//! +//! **CS2 revision:** semantic chrome — error/warn/current-marker colors — was previously ANSI/const +//! in `crate::render` (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), deliberately excluded from the palette on +//! the reasoning that these colors never sit on a tint and are never a theme knob. That boundary is +//! now revised: they ARE palette knobs ([`Palette::error_fg`]/[`Palette::warn_fg`]/ +//! [`Palette::current_fg`], mapped to base08/base0A/base0B), so a curated or probed theme can shift +//! them too. `dark()` keeps the three shipped RGB values verbatim (the same pixel-identity +//! precedent as its diff/cursor tints); `light()` takes `ONE_LIGHT`'s base08/base0A/base0B; +//! `from_terminal()` takes the probed scheme's base08/base0A/base0B directly (matching the syntax +//! slots' reasoning, not the curated-tint-borrowing the diff/cursor washes use). use ratatui::style::Color; @@ -202,6 +210,20 @@ pub struct Palette { pub dim: Color, /// Gutter/divider foreground (base04) — line-number gutters and pane dividers. pub gutter: Color, + /// Footer text color for an [`crate::app::Severity::Error`] notice, a pending-discard confirm + /// prompt, and a Failed changeset's marker/message — a clearly-red tone (base08). Promoted + /// from `render.rs`'s `FG_ERROR` const (CS2, revising ADR-035's hybrid boundary — see this + /// module's doc comment). + pub error_fg: Color, + /// Warning tone for a needs-restack marker (locked decision #9) — an amber (base0A), distinct + /// from [`Palette::error_fg`]'s red: a stale-parent changeset is a heads-up to `gt restack`, + /// not a failure. Promoted from `render.rs`'s `FG_WARN` const (CS2). + pub warn_fg: Color, + /// Tone for the outline's "this is the lib-marked `current` changeset" marker (locked + /// decision #9's outline half) — a green (base0B), distinct from every other marker color so + /// "current" reads unambiguously at a glance. Promoted from `render.rs`'s `FG_CURRENT` const + /// (CS2). + pub current_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -237,6 +259,11 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + // The shipped M3–M5 semantic-chrome colors, reproduced verbatim (the pixel-identity + // gate — CS2 promotes these from `render.rs` consts without changing a single value). + error_fg: Color::Rgb(220, 60, 60), + warn_fg: Color::Rgb(214, 158, 46), + current_fg: Color::Rgb(96, 200, 128), paint_canvas: true, } } @@ -286,6 +313,9 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + error_fg: red, + warn_fg: base.slot(10), // base0A + current_fg: green, paint_canvas: true, } } @@ -324,6 +354,11 @@ impl Palette { foreground: base.slot(5), dim: base.slot(3), gutter: base.slot(4), + // Semantic chrome also matches the terminal — probed base08/base0A/base0B, not the + // curated fallback's (mirrors the syntax slots' reasoning just above). + error_fg: base.slot(8), + warn_fg: base.slot(10), + current_fg: base.slot(11), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -397,6 +432,15 @@ mod tests { assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + #[test] + fn dark_semantic_fg_matches_the_historical_render_rs_constants() { + // CS2's pixel-identity gate for the promoted `FG_ERROR`/`FG_WARN`/`FG_CURRENT` consts. + let t = Palette::dark(); + assert_eq!(t.error_fg, Color::Rgb(220, 60, 60)); + assert_eq!(t.warn_fg, Color::Rgb(214, 158, 46)); + assert_eq!(t.current_fg, Color::Rgb(96, 200, 128)); + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -502,6 +546,14 @@ mod tests { assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg } + #[test] + fn light_semantic_fg_takes_one_lights_base08_base0a_base0b() { + let t = Palette::light(); + assert_eq!(t.error_fg, Color::Rgb(0xca, 0x12, 0x43)); // base08 + assert_eq!(t.warn_fg, Color::Rgb(0xc1, 0x84, 0x01)); // base0A + assert_eq!(t.current_fg, Color::Rgb(0x50, 0xa1, 0x4f)); // base0B + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -574,6 +626,19 @@ mod tests { assert!(!palette.paint_canvas); } + #[test] + fn from_terminal_takes_semantic_fg_from_the_probed_scheme_not_the_curated_fallback() { + // Same reasoning as syntax/chrome: `auto`'s error/warn/current colors should match the + // terminal, not borrow the curated dark/light fallback's (unlike the diff/cursor tints, + // which DO borrow — see `from_terminal_with_a_dark_background_borrows_darks_curated_tints`). + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); + let palette = Palette::from_terminal(probed); + assert_eq!(palette.error_fg, probed.slot(8)); + assert_eq!(palette.warn_fg, probed.slot(10)); + assert_eq!(palette.current_fg, probed.slot(11)); + assert_ne!(palette.error_fg, Palette::dark().error_fg); + } + #[test] fn is_light_background_splits_on_the_luminance_midpoint() { assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); From 79fa9a2c21233a4454912d7bd6feb9e056d1235b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:28:39 -0400 Subject: [PATCH 03/19] feat(review): nerd-mode status and header/summary iconography --- git-workon-review/src/outline.rs | 13 ++ git-workon-review/src/render.rs | 307 ++++++++++++++++++++++++++----- 2 files changed, 270 insertions(+), 50 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 4f7206ef..ee7e700d 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -124,6 +124,19 @@ impl StagedStatus { StagedStatus::Partial => '\u{25D0}', // ◐ } } + + /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.outline.icons = + /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see + /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as + /// [`StagedStatus::glyph`], since there's no status to convey. + pub fn nerd_glyph(self) -> char { + match self { + StagedStatus::None => ' ', + StagedStatus::Unstaged => '\u{f067}', // nf-fa-plus + StagedStatus::Staged => '\u{f00c}', // nf-fa-check + StagedStatus::Partial => '\u{f042}', // nf-fa-adjust + } + } } /// One file's outline-relevant data, as extracted from its owning changeset by diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 97e7f1d2..6fc35484 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -36,6 +36,114 @@ use crate::wordiff::Span as WordSpan; // hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this // module hardcodes a semantic color anymore. +// CS3's nerd-mode status/header/summary glyphs (gated on `OutlineIcons::Nerd`; the plain unicode +// defaults below stay byte-identical when `icons = none` — see icons.rs's module doc for why no +// auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets +// (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider +// font compatibility — see `icons.rs`'s v3 doc note. +/// Nerd-mode "this is the current changeset" marker, replacing the plain `●` (U+25CF). +const NERD_CURRENT_MARKER: char = '\u{f111}'; // nf-fa-circle +/// Nerd-mode needs-restack marker, replacing the plain `⚠` (U+26A0). +const NERD_WARN_MARKER: char = '\u{f071}'; // nf-fa-warning +/// Nerd-mode failed-changeset marker, replacing the plain `✗` (U+2717). +const NERD_ERROR_MARKER: char = '\u{f00d}'; // nf-fa-times +/// Nerd-mode loading marker, replacing the plain `…` (U+2026). +const NERD_LOADING_MARKER: char = '\u{f141}'; // nf-fa-ellipsis-h +/// Nerd-mode branch glyph prepended to a changeset header row's title (both the outline's Header +/// row and the summary panel's changeset title) — purely decorative (dim-colored), so it carries +/// no semantic color of its own. +const NERD_BRANCH_ICON: char = '\u{f418}'; // nf-oct-git-branch +/// Nerd-mode diffstat glyph for the summary panel's added-lines count, replacing the plain `+`. +const NERD_DIFF_ADDED: char = '\u{f457}'; // nf-oct-diff-added +/// Nerd-mode diffstat glyph for the summary panel's deleted-lines count, replacing the plain `-`. +const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed + +/// The current-changeset marker for the active icon strategy. These four one-switch helpers are +/// the single source of each semantic marker's glyph pair — the outline's Header arm and the +/// summary panel (and, upstack, the winbar) deliberately draw the SAME markers, so the selection +/// lives in one place instead of a hand-synced `match` per call site. +fn current_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_CURRENT_MARKER, + OutlineIcons::None => '\u{25CF}', + } +} + +/// The needs-restack marker for the active icon strategy (see [`current_marker`]). +fn warn_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_WARN_MARKER, + OutlineIcons::None => '\u{26A0}', + } +} + +/// The failed-changeset marker for the active icon strategy (see [`current_marker`]). +fn error_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_ERROR_MARKER, + OutlineIcons::None => '\u{2717}', + } +} + +/// The loading marker for the active icon strategy (see [`current_marker`]). +fn loading_marker(icons: OutlineIcons) -> char { + match icons { + OutlineIcons::Nerd => NERD_LOADING_MARKER, + OutlineIcons::None => '\u{2026}', + } +} + +/// The diffstat `+`/`-` prefixes for the active icon strategy (nerd: the oct diff glyphs) — +/// shared by the summary panel's totals line and any other diffstat surface. +fn diffstat_prefixes(icons: OutlineIcons) -> (String, String) { + match icons { + OutlineIcons::Nerd => ( + format!("{NERD_DIFF_ADDED} "), + format!("{NERD_DIFF_REMOVED} "), + ), + OutlineIcons::None => ("+".to_string(), "-".to_string()), + } +} + +/// The shared changeset-title span run — `[current-marker] [branch-icon] label [warn-marker]` — +/// drawn identically by `build_outline_line`'s Header arm and [`changeset_summary_lines`] (whose +/// doc comment promises exactly that sameness); extracting it makes the promise structural +/// instead of hand-synced. Failed/loading markers are NOT included: the two call sites place +/// them differently (trailing spans on the header row vs. a line of their own in the summary). +fn changeset_title_spans( + label: &str, + current: bool, + needs_restack: bool, + theme: &Palette, + icons: OutlineIcons, +) -> Vec> { + let marker = if current { + format!("{} ", current_marker(icons)) + } else { + " ".to_string() + }; + let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; + if icons == OutlineIcons::Nerd { + spans.push(TSpan::styled( + format!("{NERD_BRANCH_ICON} "), + Style::default().fg(theme.dim), + )); + } + spans.push(TSpan::styled( + label.to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + if needs_restack { + spans.push(TSpan::styled( + format!(" {}", warn_marker(icons)), + Style::default().fg(theme.warn_fg), + )); + } + spans +} + /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is /// a wash over the whole row, not a mask. `None` (a context/gap cell with no bg span at all) @@ -551,32 +659,19 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) failed, .. } => { - let marker = if *current { "\u{25CF} " } else { " " }; - let mut spans = vec![TSpan::styled( - marker.to_string(), - Style::default().fg(theme.current_fg), - )]; - spans.push(TSpan::styled( - label.clone(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )); - if *needs_restack { - spans.push(TSpan::styled( - " \u{26A0}", - Style::default().fg(theme.warn_fg), - )); - } + let mut spans = changeset_title_spans(label, *current, *needs_restack, theme, icons); // ADR-037: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { spans.push(TSpan::styled( - " \u{2717}", + format!(" {}", error_marker(icons)), Style::default().fg(theme.error_fg), )); } else if *loading { - spans.push(TSpan::styled(" \u{2026}", Style::default().fg(theme.dim))); + spans.push(TSpan::styled( + format!(" {}", loading_marker(icons)), + Style::default().fg(theme.dim), + )); } Line::from(spans) } @@ -600,7 +695,10 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) guides, .. } => { - let glyph = status.glyph(); + let glyph = match icons { + OutlineIcons::Nerd => status.nerd_glyph(), + OutlineIcons::None => status.glyph(), + }; let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see @@ -882,11 +980,13 @@ fn push_summary_body( total_dels: usize, height: usize, theme: &Palette, + icons: OutlineIcons, ) { lines.push(Line::from("")); let footer_budget = 1; // the totals line always shows let file_budget = height.saturating_sub(lines.len() + footer_budget); push_summary_file_rows(lines, files, file_budget, theme); + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); lines.push(Line::from(vec![ TSpan::styled( format!("{} files", files.len()), @@ -894,44 +994,35 @@ fn push_summary_body( ), TSpan::raw(" "), TSpan::styled( - format!("+{total_adds}"), + format!("{added_prefix}{total_adds}"), Style::default().fg(theme.add_strong), ), TSpan::raw(" "), TSpan::styled( - format!("-{total_dels}"), + format!("{removed_prefix}{total_dels}"), Style::default().fg(theme.del_strong), ), ])); } -/// Build a [`ChangesetSummary`]'s lines: title line (carrying the same current/needs-restack/ -/// failed markers `build_outline_line`'s Header arm draws), a loading/failed line OR the per-file -/// list + totals line. +/// Build a [`ChangesetSummary`]'s lines: title line (the same current/needs-restack markers +/// `build_outline_line`'s Header arm draws — structurally shared via [`changeset_title_spans`]), +/// a loading/failed line OR the per-file list + totals line. fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, theme: &Palette, + icons: OutlineIcons, ) -> Vec> { let mut lines = Vec::new(); - let mut title_spans = vec![TSpan::styled( - if summary.current { "\u{25CF} " } else { " " }, - Style::default().fg(theme.current_fg), - )]; - title_spans.push(TSpan::styled( - summary.label.clone(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - )); - if summary.needs_restack { - title_spans.push(TSpan::styled( - " \u{26A0}", - Style::default().fg(theme.warn_fg), - )); - } - lines.push(Line::from(title_spans)); + lines.push(Line::from(changeset_title_spans( + &summary.label, + summary.current, + summary.needs_restack, + theme, + icons, + ))); if summary.failed { let msg = summary @@ -939,14 +1030,14 @@ fn changeset_summary_lines( .as_deref() .unwrap_or("(no error message)"); lines.push(Line::from(TSpan::styled( - format!("\u{2717} {msg}"), + format!("{} {msg}", error_marker(icons)), Style::default().fg(theme.error_fg), ))); return lines; } if summary.loading { lines.push(Line::from(TSpan::styled( - "Loading\u{2026}", + format!("Loading{}", loading_marker(icons)), Style::default().fg(theme.dim), ))); return lines; @@ -959,15 +1050,27 @@ fn changeset_summary_lines( summary.total_dels, height, theme, + icons, ); lines } /// Build a [`DirSummary`]'s lines: a bold path title, a blank line, the per-file list, and the /// totals line — no current/restack/loading/failed markers (a directory carries none of those). -fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Vec> { +/// The title gets [`crate::icons::DIR_ICON`] in [`OutlineIcons::Nerd`] mode, matching the +/// outline's own [`OutlineItem::Dir`] row (`build_outline_line`). +fn dir_summary_lines( + summary: &DirSummary, + height: usize, + theme: &Palette, + icons: OutlineIcons, +) -> Vec> { + let dir_icon = match icons { + OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), + OutlineIcons::None => String::new(), + }; let mut lines = vec![Line::from(TSpan::styled( - format!("{}/", summary.path), + format!("{dir_icon}{}/", summary.path), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), @@ -979,6 +1082,7 @@ fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Ve summary.total_dels, height, theme, + icons, ); lines } @@ -988,11 +1092,17 @@ fn dir_summary_lines(summary: &DirSummary, height: usize, theme: &Palette) -> Ve /// line, per-file `"path +N -M"` rows (truncated to the pane height), and a totals line. A /// loading/failed Header shows its own inline state instead of a file list (see /// [`changeset_summary_lines`]). -fn render_summary(frame: &mut Frame, summary: &Summary, area: Rect, theme: &Palette) { +fn render_summary( + frame: &mut Frame, + summary: &Summary, + area: Rect, + theme: &Palette, + icons: OutlineIcons, +) { let height = area.height as usize; let lines = match summary { - Summary::Changeset(cs) => changeset_summary_lines(cs, height, theme), - Summary::Dir(dir) => dir_summary_lines(dir, height, theme), + Summary::Changeset(cs) => changeset_summary_lines(cs, height, theme, icons), + Summary::Dir(dir) => dir_summary_lines(dir, height, theme, icons), }; frame.render_widget(Paragraph::new(lines), area); } @@ -1004,7 +1114,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // diff-body rendering; `summary_target` returns `None` in both cases). if let Some(target) = app.summary_target() { let summary = app.summary_for(target); - render_summary(frame, &summary, area, theme); + render_summary(frame, &summary, area, theme, app.outline_icons()); return; } // ADR-037: the active changeset's diff hasn't been acquired (or failed to acquire) yet — @@ -2958,6 +3068,103 @@ mod tests { ); } + // ── CS3: nerd-mode status/header/summary iconography ──────────────────────── + + #[test] + fn outline_header_nerd_markers_replace_the_unicode_defaults() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, CS4's job) + // "⚠ needs restack" marker — an unskipped search would false-positive on it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let joined = content.join("\n"); + + assert!( + joined.contains(super::NERD_CURRENT_MARKER), + "expected the nerd current-changeset marker, got:\n{joined}" + ); + assert!( + joined.contains(super::NERD_WARN_MARKER), + "expected the nerd needs-restack marker, got:\n{joined}" + ); + assert!( + !joined.contains('\u{25CF}') && !joined.contains('\u{26A0}'), + "nerd mode must not leave the plain unicode markers behind in the outline pane, got:\n{joined}" + ); + assert!( + joined.contains(super::NERD_BRANCH_ICON), + "expected a branch glyph on the changeset header row, got:\n{joined}" + ); + } + + #[test] + fn outline_file_status_nerd_glyph_replaces_the_plain_glyph() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("a.txt", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let joined = content.join("\n"); + + assert!( + joined.contains(crate::outline::StagedStatus::Staged.nerd_glyph()), + "expected the nerd staged glyph (fa-check), got:\n{joined}" + ); + assert!( + !joined.contains(crate::outline::StagedStatus::Staged.glyph()), + "nerd mode must not leave the plain ✓ glyph behind, got:\n{joined}" + ); + } + + #[test] + fn summary_panel_nerd_mode_renders_the_dir_icon_and_diffstat_glyphs() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.focus_outline(); // opens (a lone changeset defaults closed) and focuses + app.outline_cycle_mode(); // Stack -> Tree, so a Dir row exists to focus + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + let dir_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Dir { .. })) + .expect("a Dir row present in Tree mode") as i64; + let delta = dir_idx - app.outline_cursor() as i64; + app.outline_move_by(delta); + assert!(matches!( + app.outline_items()[app.outline_cursor()], + OutlineItem::Dir { .. } + )); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let body = body_text(&buf); + assert!( + body.contains(crate::icons::DIR_ICON), + "expected the summary panel's dir title to carry the nerd dir icon, got:\n{body}" + ); + assert!( + body.contains(super::NERD_DIFF_ADDED) && body.contains(super::NERD_DIFF_REMOVED), + "expected nerd diffstat glyphs in the summary panel's totals line, got:\n{body}" + ); + } + // ── CS4: summary panel ─────────────────────────────────────────────────────── /// The body area's columns, for a render at [`OUTLINE_TEST_WIDTH`] (outline `0..35`, divider From 76b5a605eb09109650fcbc146423af814a6bc2d7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 15:45:31 -0400 Subject: [PATCH 04/19] style(review): tree guides, gap label, and winbar restyle --- git-workon-review/src/outline.rs | 2 +- git-workon-review/src/render.rs | 186 ++++++++++++++++++++++++++----- 2 files changed, 161 insertions(+), 27 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index ee7e700d..18b159ba 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -184,7 +184,7 @@ pub struct OutlineChangeset { /// nesting level from the shallowest ancestor down to the row itself, `true` meaning "this /// level is its parent's last child". Rendering uses every-element-but-the-last to decide /// whether to draw a continuing `│` or blank space at that column, and the last element to draw -/// `└─`/`├─` for the row's own connector. [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry +/// `╰─`/`├─` for the row's own connector (CS4 rounds the last-child corner). [`OutlineMode::Flat`]/[`OutlineMode::Stack`] rows carry /// an EMPTY `guides` — that's the signal to `render::build_outline_line` to fall back to the /// flat two-space indent instead of drawing tree connectors; a non-empty `guides` of length 1 /// means "top-level tree row" (depth 0), so emptiness and depth-0 are deliberately distinguishable. diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 6fc35484..3bf724eb 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -613,7 +613,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) /// Render a tree-guide prefix from an [`OutlineItem::Dir`]/[`OutlineItem::File`] `guides` /// vector: every element but the last draws a continuing `│` (if that ancestor level was NOT /// its parent's last child) or blank space (if it was), and the last element draws the row's own -/// `└─`/`├─` connector. +/// `╰─`/`├─` connector — CS4 rounds the last-child corner (`╰`, U+2570) from the square `└` +/// (U+2514); there's no widely-supported rounded "tee" glyph, so the non-last `├─` connector is +/// unchanged. fn tree_prefix(guides: &[bool]) -> String { let mut s = String::new(); let Some((&is_last, ancestors)) = guides.split_last() else { @@ -623,7 +625,7 @@ fn tree_prefix(guides: &[bool]) -> String { s.push_str(if last { " " } else { "\u{2502} " }); } s.push_str(if is_last { - "\u{2514}\u{2500} " + "\u{2570}\u{2500} " } else { "\u{251C}\u{2500} " }); @@ -702,23 +704,35 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see - // `OutlineItem`'s doc comment for why emptiness is the mode signal. - let prefix = if guides.is_empty() { - " ".to_string() + // `OutlineItem`'s doc comment for why emptiness is the mode signal. CS4: a non-empty + // prefix (real tree connectors) gets its own `theme.dim`-styled span — matching the + // Dir row's already-dim guides — so the guide lines read as quiet structure, not part + // of the file's own status glyph; the empty two-space indent has nothing visible to + // dim, so it stays bundled with the glyph span below. + let mut spans = Vec::new(); + if guides.is_empty() { + spans.push(TSpan::styled( + format!(" {glyph}"), + Style::default().fg(theme.foreground), + )); } else { - tree_prefix(guides) - }; - let mut spans = vec![ - TSpan::styled( - format!("{prefix}{glyph}"), + spans.push(TSpan::styled( + tree_prefix(guides), + Style::default().fg(theme.dim), + )); + spans.push(TSpan::styled( + glyph.to_string(), Style::default().fg(theme.foreground), - ), - TSpan::styled( - letter.to_string(), - Style::default().fg(change_letter_color(*change, theme)), - ), - TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), - ]; + )); + } + spans.push(TSpan::styled( + letter.to_string(), + Style::default().fg(change_letter_color(*change, theme)), + )); + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); if icons == OutlineIcons::Nerd { let (icon, color) = crate::icons::icon_for_path( path, @@ -779,15 +793,22 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { } /// The multi-changeset winbar (locked decisions #8 + #9): `[i/n] -/// (fidx/nfiles)`, where `i/n` is the changeset's position in the -/// stack and `fidx/nfiles` the active file's position within it. Only reached when +/// (fidx/nfiles)`, where `i/n` is the changeset's position +/// in the stack and `fidx/nfiles` the active file's position within it. Only reached when /// [`App::changeset_count`] > 1 (see [`render_header`]) — a lone uncommitted changeset never /// shows this, keeping the M4 full-width look. +/// +/// CS4 polish: a tight `+A -D` diffstat for the ACTIVE changeset (there wasn't one before), +/// tinted with the same [`Palette::add_strong`]/[`Palette::del_strong`] the summary panel's own +/// totals line uses; in [`OutlineIcons::Nerd`] mode the restack marker and diffstat prefixes swap +/// to their nerd glyphs (same consts `build_outline_line`/`push_summary_body` use), and the +/// active file's path gets its devicons file icon. fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); + let icons = app.outline_icons(); let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), @@ -799,16 +820,60 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { // from the plain title so a stale-parent changeset reads as a heads-up at a glance. if cs.needs_restack { spans.push(TSpan::styled( - " ⚠ needs restack", + format!(" {} needs restack", warn_marker(icons)), Style::default() .fg(theme.warn_fg) .add_modifier(Modifier::BOLD), )); } + // A pending/failed changeset's `files()` is always empty (ADR-037) — skip the diffstat + // segment entirely rather than show a misleading "+0 -0". + if !app.files().is_empty() { + let (adds, dels) = app + .files() + .iter() + .map(crate::summary::file_diffstat) + .fold((0, 0), |(a, d), (fa, fd)| (a + fa, d + fd)); + let (added_prefix, removed_prefix) = diffstat_prefixes(icons); + spans.push(TSpan::raw(" ")); + spans.push(TSpan::styled( + format!("{added_prefix}{adds}"), + Style::default() + .fg(theme.add_strong) + .add_modifier(Modifier::BOLD), + )); + spans.push(TSpan::raw(" ")); + spans.push(TSpan::styled( + format!("{removed_prefix}{dels}"), + Style::default() + .fg(theme.del_strong) + .add_modifier(Modifier::BOLD), + )); + } let fidx = app.current + 1; let nfiles = app.files().len(); spans.push(TSpan::styled( - format!(" — {} ({fidx}/{nfiles})", current_file_label(app)), + " — ".to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )); + if icons == OutlineIcons::Nerd { + if let Some(f) = app.files().get(app.current) { + let (icon, color) = crate::icons::icon_for_path( + &f.path, + crate::theme::is_light_background(theme.background), + ); + spans.push(TSpan::styled( + format!("{icon} "), + Style::default() + .fg(color.unwrap_or(theme.foreground)) + .add_modifier(Modifier::BOLD), + )); + } + } + spans.push(TSpan::styled( + format!("{} ({fidx}/{nfiles})", current_file_label(app)), Style::default() .fg(theme.foreground) .add_modifier(Modifier::BOLD), @@ -2555,6 +2620,49 @@ mod tests { ); } + #[test] + fn winbar_shows_a_tight_diffstat_for_the_active_changeset() { + // CS4: the winbar previously showed no diffstat at all — cs-b adds a single line + // (`b.txt`, one-line file, committed with no prior content) with nothing deleted. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains("+1") && header.contains("-0"), + "expected a tight '+N -M' diffstat fragment for cs-b's single added file, got: {header:?}" + ); + } + + #[test] + fn winbar_nerd_mode_swaps_the_restack_marker_and_diffstat_glyphs_and_shows_a_file_icon() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack + app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains(super::NERD_WARN_MARKER) && !header.contains('\u{26A0}'), + "expected the nerd restack marker, not the plain unicode one, got: {header:?}" + ); + assert!( + header.contains(super::NERD_DIFF_ADDED) && header.contains(super::NERD_DIFF_REMOVED), + "expected nerd diffstat glyphs in the winbar, got: {header:?}" + ); + assert!( + header.contains(crate::icons::icon_for_path("b.txt", false).0), + "expected the active file's (b.txt) devicons icon in the winbar, got: {header:?}" + ); + } + #[test] fn winbar_uses_title_when_present() { let fixture = FixtureBuilder::new() @@ -2911,18 +3019,44 @@ mod tests { content.join("\n") ); assert!( - content[2].contains('\u{2514}') && content[2].contains("a.txt"), - "expected row 2 to be src/a.txt, indented under src/ with its own last-child '└─' \ - guide, got:\n{}", + content[2].contains('\u{2570}') && content[2].contains("a.txt"), + "expected row 2 to be src/a.txt, indented under src/ with its own last-child \ + rounded '╰─' guide, got:\n{}", content.join("\n") ); assert!( - content[3].contains('\u{2514}') && content[3].contains("top.txt"), - "expected row 3 to be top.txt with a last-child '└─' guide, got:\n{}", + content[3].contains('\u{2570}') && content[3].contains("top.txt"), + "expected row 3 to be top.txt with a last-child rounded '╰─' guide, got:\n{}", content.join("\n") ); } + #[test] + fn outline_file_row_tree_guide_carries_the_dim_color() { + // CS4: a File row's tree-guide connector (distinct from its status glyph, which keeps + // `theme.foreground`) is styled `theme.dim`, matching the Dir row's already-dim guides. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Row 3 is top.txt (see the test above) — a File row with a non-empty guide vector. + let row = outline_row(&buf, 3); + let guide_x = row.find('\u{2570}').expect("rounded guide present") as u16; + assert_eq!( + buf.cell((guide_x, 3)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the File row's tree-guide connector to carry theme.dim, got: {row:?}" + ); + } + // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── #[test] From 02bb12b2eae0b730df382ed0fc2e222cbd28282e Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 20:04:49 -0400 Subject: [PATCH 05/19] refactor(review): promote icons config to workon.review.icons --- git-workon-review/src/app.rs | 72 ++++++++++++------------ git-workon-review/src/config.rs | 36 ++++++------ git-workon-review/src/icons.rs | 17 +++--- git-workon-review/src/outline.rs | 2 +- git-workon-review/src/render.rs | 96 ++++++++++++++++---------------- 5 files changed, 114 insertions(+), 109 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 49febfe1..964c7350 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -23,7 +23,7 @@ use crate::align::{ use crate::apply::{Git2Applier, StageVerb}; use crate::config::RawViewConfig; use crate::highlight::{lang_key_for_ext, FgSpan, TsHighlighter}; -use crate::icons::OutlineIcons; +use crate::icons::IconMode; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; @@ -674,14 +674,14 @@ fn parse_outline_order(raw: &str) -> Option { } } -/// Parse `workon.review.outline.icons` (CS5) into an [`OutlineIcons`]. Canonical strings mirror +/// Parse `workon.review.icons` (CS5) into an [`IconMode`]. Canonical strings mirror /// the variant names, kebab-cased: `nerd`, `none`. `None` on anything else — -/// [`App::apply_view_config`] falls back to [`OutlineIcons::default`] (also `none` — CS5's +/// [`App::apply_view_config`] falls back to [`IconMode::default`] (also `none` — CS5's /// no-auto-detection default) and warns. -fn parse_outline_icons(raw: &str) -> Option { +fn parse_icon_mode(raw: &str) -> Option { match raw { - "nerd" => Some(OutlineIcons::Nerd), - "none" => Some(OutlineIcons::None), + "nerd" => Some(IconMode::Nerd), + "none" => Some(IconMode::None), _ => None, } } @@ -800,10 +800,6 @@ pub struct OutlineState { /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, - /// CS5: opt-in nerd-font file/dir icons — `workon.review.outline.icons`, defaulting to - /// [`OutlineIcons::None`] (no auto-detection story exists — a terminal can't report the - /// user's font). Read by `render::build_outline_line`. - pub icons: OutlineIcons, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1144,6 +1140,11 @@ pub struct App { /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. outline: OutlineState, + /// Opt-in nerd-font iconography — `workon.review.icons`, defaulting to [`IconMode::None`] + /// (no auto-detection story exists — a terminal can't report the user's font). A TUI-wide + /// appearance mode like the theme, not an outline view setting: it gates the outline's + /// file/dir icons AND the summary panel's and winbar's glyphs (see `render.rs`). + icon_mode: IconMode, /// Whether the `?` help overlay is showing (CS3). While `true`, `tui::update` intercepts /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment /// for the precedence between the two modals. @@ -1311,7 +1312,6 @@ impl App { width: DEFAULT_OUTLINE_WIDTH, scroll: 0, order: OutlineOrder::default(), - icons: OutlineIcons::default(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -1349,6 +1349,7 @@ impl App { selection_anchor: None, refresh_coordinator, outline, + icon_mode: IconMode::default(), help_visible: false, review_source: None, defer_loads: false, @@ -2524,10 +2525,10 @@ impl App { self.outline.order } - /// CS5: whether the outline renders nerd-font icons — `workon.review.outline.icons`, or - /// [`OutlineIcons::default`] (`None`) if never set. - pub fn outline_icons(&self) -> OutlineIcons { - self.outline.icons + /// The nerd-font iconography mode — `workon.review.icons`, or [`IconMode::default`] + /// (`None`) if never set. TUI-wide: read by the outline, summary panel, and winbar renderers. + pub fn icon_mode(&self) -> IconMode { + self.icon_mode } /// `o`: a pure show/hide toggle — closed -> open+focused (+[`Self::sync_outline_to_current`]), @@ -2602,12 +2603,11 @@ impl App { self.outline.order = order; } - /// Set the outline icons setting directly — the config-startup (CS5) counterpart; there is - /// no interactive key for this (icons are a static config choice, not something to toggle - /// mid-session). Same non-resync posture as [`Self::set_outline_mode`]/ - /// [`Self::set_outline_order`]. - pub fn set_outline_icons(&mut self, icons: OutlineIcons) { - self.outline.icons = icons; + /// Set the icon mode directly — the config-startup counterpart; there is no interactive + /// key for this (icons are a static config choice, not something to toggle mid-session). + /// Same non-resync posture as [`Self::set_outline_mode`]/[`Self::set_outline_order`]. + pub fn set_icon_mode(&mut self, icons: IconMode) { + self.icon_mode = icons; } /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), @@ -3314,16 +3314,16 @@ impl App { }; self.set_outline_order(order); - let icons = match &raw.outline_icons { - Some(i) => parse_outline_icons(i).unwrap_or_else(|| { + let icons = match &raw.icons { + Some(i) => parse_icon_mode(i).unwrap_or_else(|| { warnings.push(format!( - "workon.review.outline.icons = '{i}' unrecognized; using default" + "workon.review.icons = '{i}' unrecognized; using default" )); - OutlineIcons::default() + IconMode::default() }), - None => OutlineIcons::default(), + None => IconMode::default(), }; - self.set_outline_icons(icons); + self.set_icon_mode(icons); let layout = match &raw.diff_layout { Some(l) => parse_diff_layout(l).unwrap_or_else(|| { @@ -4379,7 +4379,7 @@ mod tests { }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; - use crate::icons::OutlineIcons; + use crate::icons::IconMode; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, OutlineOrder, StagedStatus}; @@ -8990,7 +8990,7 @@ mod tests { assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); assert_eq!(app.outline_mode(), OutlineMode::default()); assert_eq!(app.outline_order(), OutlineOrder::default()); - assert_eq!(app.outline_icons(), OutlineIcons::default()); + assert_eq!(app.icon_mode(), IconMode::default()); assert_eq!(app.layout, Layout::default()); assert_eq!(app.zoom, Zoom::default()); } @@ -9089,9 +9089,9 @@ mod tests { } #[test] - fn outline_icons_overrides_default_when_set() { + fn icon_mode_overrides_default_when_set() { let fixture = FixtureBuilder::new() - .config("workon.review.outline.icons", "nerd") + .config("workon.review.icons", "nerd") .build() .unwrap(); let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); @@ -9100,13 +9100,13 @@ mod tests { let warnings = app.apply_view_config(&config); assert!(warnings.is_empty()); - assert_eq!(app.outline_icons(), OutlineIcons::Nerd); + assert_eq!(app.icon_mode(), IconMode::Nerd); } #[test] - fn outline_icons_invalid_falls_back_to_default_with_warning() { + fn icon_mode_invalid_falls_back_to_default_with_warning() { let fixture = FixtureBuilder::new() - .config("workon.review.outline.icons", "bogus") + .config("workon.review.icons", "bogus") .build() .unwrap(); let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); @@ -9114,9 +9114,9 @@ mod tests { let warnings = app.apply_view_config(&config); - assert_eq!(app.outline_icons(), OutlineIcons::default()); + assert_eq!(app.icon_mode(), IconMode::default()); assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("outline.icons")); + assert!(warnings[0].contains("workon.review.icons")); } #[test] diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 47c00522..4b44bbcc 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -18,6 +18,7 @@ //! ```gitconfig //! [workon "review"] //! theme = dark ; auto | dark | light (default: auto) +//! icons = nerd ; nerd | none (default: none) //! //! [workon "review.diff.bind"] //! stage-hunk = s x ; action = key tokens (space-separated) @@ -32,19 +33,20 @@ //! width = 32 //! mode = tree //! order = base-first ; head-first | base-first (default: head-first) -//! icons = nerd ; nerd | none (default: none) //! //! [workon "review.diff"] //! layout = split //! zoom = combined //! ``` //! -//! ## `outline.icons` (CS5) +//! ## `icons` //! -//! Opt-in nerd-font file/dir icons in the outline pane. There is deliberately NO auto-detection +//! Opt-in nerd-font iconography — top-level next to `theme` (`workon.review.icons`), NOT an +//! outline setting: the mode gates the outline's file/dir icons, the summary panel's glyphs, +//! and the winbar's marker/diffstat/file icons alike. There is deliberately NO auto-detection //! — a terminal cannot report whether the user's font is patched with the nerd-font glyphs, so //! guessing would silently render tofu/mojibake for anyone without one. Default is `none` -//! (today's plain text); set `icons = nerd` explicitly once your terminal font supports it. See +//! (plain text); set `icons = nerd` explicitly once your terminal font supports it. See //! [`crate::icons`] for the glyph table. use git2::Repository; @@ -112,7 +114,7 @@ pub struct RawViewConfig { pub outline_width: Option, pub outline_mode: Option, pub outline_order: Option, - pub outline_icons: Option, + pub icons: Option, pub diff_layout: Option, pub diff_zoom: Option, } @@ -220,10 +222,15 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Outline, "order") } - /// Get `workon.review.outline.icons`, raw. `None` if unset — callers apply the current - /// default ([`crate::icons::OutlineIcons::None`], CS5: no auto-detection story exists). - pub fn outline_icons(&self) -> Result, git2::Error> { - self.get_view_string(View::Outline, "icons") + /// Get `workon.review.icons`, raw. `None` if unset — callers apply the current default + /// ([`crate::icons::IconMode::None`]; no auto-detection story exists). Top-level like + /// `theme`, not a view setting: icon mode gates the outline, summary panel, AND winbar. + pub fn icons(&self) -> Result, git2::Error> { + let config = self.repo.config()?; + match config.get_string("workon.review.icons") { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } } /// Get `workon.review.diff.layout`, raw. `None` if unset. @@ -248,7 +255,7 @@ impl<'repo> ReviewConfig<'repo> { outline_width: self.outline_width().ok().flatten(), outline_mode: self.outline_mode().ok().flatten(), outline_order: self.outline_order().ok().flatten(), - outline_icons: self.outline_icons().ok().flatten(), + icons: self.icons().ok().flatten(), diff_layout: self.diff_layout().ok().flatten(), diff_zoom: self.diff_zoom().ok().flatten(), } @@ -433,7 +440,7 @@ mod tests { .config("workon.review.outline.width", "40") .config("workon.review.outline.mode", "tree") .config("workon.review.outline.order", "base-first") - .config("workon.review.outline.icons", "nerd") + .config("workon.review.icons", "nerd") .config("workon.review.diff.layout", "split") .config("workon.review.diff.zoom", "staged") .build() @@ -450,10 +457,7 @@ mod tests { config.outline_order().expect("order"), Some("base-first".to_string()) ); - assert_eq!( - config.outline_icons().expect("icons"), - Some("nerd".to_string()) - ); + assert_eq!(config.icons().expect("icons"), Some("nerd".to_string())); assert_eq!( config.diff_layout().expect("layout"), Some("split".to_string()) @@ -473,7 +477,7 @@ mod tests { assert_eq!(config.outline_width().expect("width"), None); assert_eq!(config.outline_mode().expect("mode"), None); assert_eq!(config.outline_order().expect("order"), None); - assert_eq!(config.outline_icons().expect("icons"), None); + assert_eq!(config.icons().expect("icons"), None); assert_eq!(config.diff_layout().expect("layout"), None); assert_eq!(config.diff_zoom().expect("zoom"), None); } diff --git a/git-workon-review/src/icons.rs b/git-workon-review/src/icons.rs index dc8f969a..aa68c6c6 100644 --- a/git-workon-review/src/icons.rs +++ b/git-workon-review/src/icons.rs @@ -3,7 +3,7 @@ //! //! A terminal cannot report which font (patched with the nerd-font private-use glyphs or not) //! the user has configured, so there is NO auto-detection here or anywhere else in the crate — -//! icons are strictly opt-in via `workon.review.outline.icons = nerd` (see `config.rs`'s schema +//! icons are strictly opt-in via `workon.review.icons = nerd` (see `config.rs`'s schema //! doc block and `App::apply_view_config`). With the config left at its default (`none`), //! nothing in this module is ever called from `render.rs`. //! @@ -14,7 +14,7 @@ //! picks one from the active [`crate::theme::Palette`] (see [`icon_for_path`]'s doc comment). //! **Nerd-font v3 requirement:** devicons' glyphs are drawn from nerd-font v3's private-use //! codepoints, roughly a fifth of which sit in a Unicode supplementary plane (outside the BMP). -//! The crate's own `OutlineIcons::Nerd` glyphs picked in CS3 (status/header markers) stay +//! The crate's own `IconMode::Nerd` glyphs picked in CS3 (status/header markers) stay //! BMP-only for wider font compatibility, but a per-file icon from devicons may require a v3 //! nerd-font — this is the same "no auto-detection" opt-in tradeoff as the rest of this module. //! devicons does not cover directories (it is a per-file mapper), so [`DIR_ICON`] is still ours. @@ -22,12 +22,13 @@ use devicons::{icon_for_file, FileIcon, Theme as DeviconsTheme}; use ratatui::style::Color; -/// Which of the outline's icon strategies is active — `workon.review.outline.icons` -/// (`nerd`/`none`), read once at startup by `App::apply_view_config` (CS5 mirrors CS3's -/// `OutlineOrder` plumbing exactly: `RawViewConfig` field -> `ReviewConfig` getter -> -/// `parse_outline_icons` -> warn-and-fallback in `apply_view_config` -> `OutlineState` field). +/// Which iconography strategy is active TUI-wide — `workon.review.icons` (`nerd`/`none`), +/// read once at startup by `App::apply_view_config` (`RawViewConfig` field -> `ReviewConfig` +/// getter -> `parse_icon_mode` -> warn-and-fallback in `apply_view_config` -> `App` field). +/// Top-level like the theme, not an outline setting: it gates the outline's file/dir icons, +/// the summary panel's glyphs, and the winbar's marker/diffstat/file icons alike. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum OutlineIcons { +pub enum IconMode { /// No icon glyph — today's plain `[glyph][letter] path` row (CS5's unconditional part only). #[default] None, @@ -37,7 +38,7 @@ pub enum OutlineIcons { } /// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every -/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active. devicons is a +/// [`crate::outline::OutlineItem::Dir`] row when [`IconMode::Nerd`] is active. devicons is a /// per-file mapper (it has no directory entries), so this stays our own constant. pub const DIR_ICON: char = '\u{f07b}'; // nf-fa-folder diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 18b159ba..2403c608 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -125,7 +125,7 @@ impl StagedStatus { } } - /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.outline.icons = + /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.icons = /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as /// [`StagedStatus::glyph`], since there's no status to convey. diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 3bf724eb..700c94c6 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -19,7 +19,7 @@ use crate::app::{ use crate::attribute::Attribution; use crate::config::View; use crate::highlight::FgSpan; -use crate::icons::OutlineIcons; +use crate::icons::IconMode; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; @@ -36,7 +36,7 @@ use crate::wordiff::Span as WordSpan; // hybrid-boundary doc comment. A curated theme now fully controls the look; nothing in this // module hardcodes a semantic color anymore. -// CS3's nerd-mode status/header/summary glyphs (gated on `OutlineIcons::Nerd`; the plain unicode +// CS3's nerd-mode status/header/summary glyphs (gated on `IconMode::Nerd`; the plain unicode // defaults below stay byte-identical when `icons = none` — see icons.rs's module doc for why no // auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets // (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider @@ -62,46 +62,46 @@ const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed /// the single source of each semantic marker's glyph pair — the outline's Header arm and the /// summary panel (and, upstack, the winbar) deliberately draw the SAME markers, so the selection /// lives in one place instead of a hand-synced `match` per call site. -fn current_marker(icons: OutlineIcons) -> char { +fn current_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_CURRENT_MARKER, - OutlineIcons::None => '\u{25CF}', + IconMode::Nerd => NERD_CURRENT_MARKER, + IconMode::None => '\u{25CF}', } } /// The needs-restack marker for the active icon strategy (see [`current_marker`]). -fn warn_marker(icons: OutlineIcons) -> char { +fn warn_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_WARN_MARKER, - OutlineIcons::None => '\u{26A0}', + IconMode::Nerd => NERD_WARN_MARKER, + IconMode::None => '\u{26A0}', } } /// The failed-changeset marker for the active icon strategy (see [`current_marker`]). -fn error_marker(icons: OutlineIcons) -> char { +fn error_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_ERROR_MARKER, - OutlineIcons::None => '\u{2717}', + IconMode::Nerd => NERD_ERROR_MARKER, + IconMode::None => '\u{2717}', } } /// The loading marker for the active icon strategy (see [`current_marker`]). -fn loading_marker(icons: OutlineIcons) -> char { +fn loading_marker(icons: IconMode) -> char { match icons { - OutlineIcons::Nerd => NERD_LOADING_MARKER, - OutlineIcons::None => '\u{2026}', + IconMode::Nerd => NERD_LOADING_MARKER, + IconMode::None => '\u{2026}', } } /// The diffstat `+`/`-` prefixes for the active icon strategy (nerd: the oct diff glyphs) — /// shared by the summary panel's totals line and any other diffstat surface. -fn diffstat_prefixes(icons: OutlineIcons) -> (String, String) { +fn diffstat_prefixes(icons: IconMode) -> (String, String) { match icons { - OutlineIcons::Nerd => ( + IconMode::Nerd => ( format!("{NERD_DIFF_ADDED} "), format!("{NERD_DIFF_REMOVED} "), ), - OutlineIcons::None => ("+".to_string(), "-".to_string()), + IconMode::None => ("+".to_string(), "-".to_string()), } } @@ -115,7 +115,7 @@ fn changeset_title_spans( current: bool, needs_restack: bool, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let marker = if current { format!("{} ", current_marker(icons)) @@ -123,7 +123,7 @@ fn changeset_title_spans( " ".to_string() }; let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { spans.push(TSpan::styled( format!("{NERD_BRANCH_ICON} "), Style::default().fg(theme.dim), @@ -588,7 +588,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let cursor = app.outline_cursor(); let focused = app.outline_focused(); let scroll = app.outline_scroll(); - let icons = app.outline_icons(); + let icons = app.icon_mode(); let buf = frame.buffer_mut(); for row in 0..area.height { @@ -648,10 +648,10 @@ fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { } /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the -/// marker rules. `icons` (CS5, `workon.review.outline.icons`) is [`OutlineIcons::None`] by +/// marker rules. `icons` (CS5, `workon.review.icons`) is [`IconMode::None`] by /// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only -/// [`OutlineIcons::Nerd`] inserts an icon before the name/path. -fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) -> Line<'static> { +/// [`IconMode::Nerd`] inserts an icon before the name/path. +fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { match item { OutlineItem::Header { label, @@ -679,8 +679,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) } OutlineItem::Dir { name, guides, .. } => { let icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), - OutlineIcons::None => String::new(), + IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), + IconMode::None => String::new(), }; let text = format!("{}{icon}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( @@ -698,8 +698,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) .. } => { let glyph = match icons { - OutlineIcons::Nerd => status.nerd_glyph(), - OutlineIcons::None => status.glyph(), + IconMode::Nerd => status.nerd_glyph(), + IconMode::None => status.glyph(), }; let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a @@ -733,7 +733,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: OutlineIcons) " ".to_string(), Style::default().fg(theme.foreground), )); - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { let (icon, color) = crate::icons::icon_for_path( path, crate::theme::is_light_background(theme.background), @@ -800,7 +800,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { /// /// CS4 polish: a tight `+A -D` diffstat for the ACTIVE changeset (there wasn't one before), /// tinted with the same [`Palette::add_strong`]/[`Palette::del_strong`] the summary panel's own -/// totals line uses; in [`OutlineIcons::Nerd`] mode the restack marker and diffstat prefixes swap +/// totals line uses; in [`IconMode::Nerd`] mode the restack marker and diffstat prefixes swap /// to their nerd glyphs (same consts `build_outline_line`/`push_summary_body` use), and the /// active file's path gets its devicons file icon. fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { @@ -808,7 +808,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let i = app.current_cs() + 1; let n = app.changeset_count(); let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); - let icons = app.outline_icons(); + let icons = app.icon_mode(); let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), @@ -858,7 +858,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { .fg(theme.foreground) .add_modifier(Modifier::BOLD), )); - if icons == OutlineIcons::Nerd { + if icons == IconMode::Nerd { if let Some(f) = app.files().get(app.current) { let (icon, color) = crate::icons::icon_for_path( &f.path, @@ -1045,7 +1045,7 @@ fn push_summary_body( total_dels: usize, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) { lines.push(Line::from("")); let footer_budget = 1; // the totals line always shows @@ -1077,7 +1077,7 @@ fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let mut lines = Vec::new(); @@ -1122,17 +1122,17 @@ fn changeset_summary_lines( /// Build a [`DirSummary`]'s lines: a bold path title, a blank line, the per-file list, and the /// totals line — no current/restack/loading/failed markers (a directory carries none of those). -/// The title gets [`crate::icons::DIR_ICON`] in [`OutlineIcons::Nerd`] mode, matching the +/// The title gets [`crate::icons::DIR_ICON`] in [`IconMode::Nerd`] mode, matching the /// outline's own [`OutlineItem::Dir`] row (`build_outline_line`). fn dir_summary_lines( summary: &DirSummary, height: usize, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) -> Vec> { let dir_icon = match icons { - OutlineIcons::Nerd => format!("{} ", crate::icons::DIR_ICON), - OutlineIcons::None => String::new(), + IconMode::Nerd => format!("{} ", crate::icons::DIR_ICON), + IconMode::None => String::new(), }; let mut lines = vec![Line::from(TSpan::styled( format!("{dir_icon}{}/", summary.path), @@ -1162,7 +1162,7 @@ fn render_summary( summary: &Summary, area: Rect, theme: &Palette, - icons: OutlineIcons, + icons: IconMode, ) { let height = area.height as usize; let lines = match summary { @@ -1179,7 +1179,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { // diff-body rendering; `summary_target` returns `None` in both cases). if let Some(target) = app.summary_target() { let summary = app.summary_for(target); - render_summary(frame, &summary, area, theme, app.outline_icons()); + render_summary(frame, &summary, area, theme, app.icon_mode()); return; } // ADR-037: the active changeset's diff hasn't been acquired (or failed to acquire) yet — @@ -2645,7 +2645,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, 80, 20); let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); @@ -3100,7 +3100,7 @@ mod tests { } #[test] - fn outline_icons_nerd_renders_the_rust_file_icon_and_the_dir_icon() { + fn icon_mode_nerd_renders_the_rust_file_icon_and_the_dir_icon() { use git2::Repository; use workon::{Changeset, ChangesetSpan}; @@ -3140,7 +3140,7 @@ mod tests { } app.outline_cycle_mode(); // Stack -> Tree, so `src/` renders as its own Dir row assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); @@ -3168,7 +3168,7 @@ mod tests { } #[test] - fn outline_icons_none_renders_neither_icon() { + fn icon_mode_none_renders_neither_icon() { let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .build() @@ -3180,8 +3180,8 @@ mod tests { app.outline_cycle_mode(); // Stack -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); assert_eq!( - app.outline_icons(), - crate::icons::OutlineIcons::None, + app.icon_mode(), + crate::icons::IconMode::None, "sanity: icons default to None" ); @@ -3211,7 +3211,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); // cs-b: current + needs_restack - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); // Skip y=0: it's the full-width winbar, which ALSO renders a (still-unicode, CS4's job) @@ -3245,7 +3245,7 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); if !app.outline_open() { app.toggle_outline(); } @@ -3271,7 +3271,7 @@ mod tests { .build() .unwrap(); let mut app = changeset_with_nested_paths(&fixture); - app.set_outline_icons(crate::icons::OutlineIcons::Nerd); + app.set_icon_mode(crate::icons::IconMode::Nerd); app.focus_outline(); // opens (a lone changeset defaults closed) and focuses app.outline_cycle_mode(); // Stack -> Tree, so a Dir row exists to focus assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); From ab6d8cef32b1e3bc75db955bd7d585bd983e088a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 22:04:23 -0400 Subject: [PATCH 06/19] feat(review): mouse support with click-to-focus and wheel scroll --- git-workon-review/src/app.rs | 397 +++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 22 +- git-workon-review/src/tui.rs | 181 +++++++++++++-- 3 files changed, 577 insertions(+), 23 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 964c7350..205ad601 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1049,6 +1049,47 @@ impl ChangesetView { } } +/// One content region the renderer painted this frame, in terminal cell coordinates (CS10). A +/// deliberately tiny local shape rather than `ratatui::layout::Rect`: `app.rs` has no ratatui +/// dependency today, and this keeps it that way — `render.rs` (which already depends on +/// ratatui) converts a `Rect`'s content area into this when it writes [`App::hit_regions`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Region { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, +} + +impl Region { + fn contains(&self, col: u16, row: u16) -> bool { + col >= self.x && col < self.x + self.w && row >= self.y && row < self.y + self.h + } +} + +/// The content regions the last frame painted (CS10), written by `render::render` (which clears +/// this to `Default` at the top of every frame first) and read by [`App::handle_click`]/ +/// [`App::handle_wheel`] to hit-test a mouse event's `(col, row)` against the region under the +/// pointer. A `None` field simply wasn't painted this frame — the outline is closed, or the +/// current file isn't in [`EffectiveZoom::Split`], etc. — never a stale rect from an earlier +/// frame's layout. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct HitRegions { + pub outline: Option, + pub single: Option, + pub unstaged: Option, + pub staged: Option, +} + +/// Which content region a mouse event hit-tested into (CS10's `App::hit_test`) — the outline, +/// the single-zoom diff pane, or one half of a split, tagged with which [`SplitPane`] so the +/// click/wheel handlers know whether to `toggle_split_focus` first. +enum HitPane { + Outline, + Single, + Split(SplitPane), +} + /// Review session state: the active changeset's file list, per-file lazily loaded views, and /// navigation/scroll state. One long-lived [`TsHighlighter`] lives here (not per file) — its /// language-config cache is keyed per-instance, so a fresh highlighter per file would rebuild @@ -1090,6 +1131,10 @@ pub struct App { /// Content height of the outline pane, written by the renderer each frame — same discipline /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. pub outline_height: usize, + /// The content regions the last frame painted (CS10 mouse support) — see [`HitRegions`]'s + /// doc comment. Cleared and re-written by `render::render` every frame; read by + /// [`Self::handle_click`]/[`Self::handle_wheel`]. + pub hit_regions: HitRegions, /// Label for the old side of the diff, shown next to a rename's `old_path` in the header. /// M4 only reviews the uncommitted (`HEAD` ↔ worktree) diffs, so this is always `"HEAD"` /// today; M5's committed-changeset zoom will want the changeset's actual base rev. @@ -1337,6 +1382,7 @@ impl App { alt: PaneState::default(), alt_height: 20, outline_height: 20, + hit_regions: HitRegions::default(), base_label, highlighter: TsHighlighter::new(), layout: Layout::default(), @@ -2563,6 +2609,120 @@ impl App { self.outline.focused = false; } + // ── Mouse (CS10) ───────────────────────────────────────────────────────────── + + /// Hit-test `(col, row)` against [`Self::hit_regions`] — outline first, then the single diff + /// pane, then the split's two halves — returning the matched region tagged with which + /// [`HitPane`] it was. `None` when the pointer is over a header/footer/divider/caption row + /// (recorded regions cover content only). + fn hit_test(&self, col: u16, row: u16) -> Option<(HitPane, Region)> { + if let Some(region) = self.hit_regions.outline { + if region.contains(col, row) { + return Some((HitPane::Outline, region)); + } + } + if let Some(region) = self.hit_regions.single { + if region.contains(col, row) { + return Some((HitPane::Single, region)); + } + } + if let Some(region) = self.hit_regions.unstaged { + if region.contains(col, row) { + return Some((HitPane::Split(SplitPane::Unstaged), region)); + } + } + if let Some(region) = self.hit_regions.staged { + if region.contains(col, row) { + return Some((HitPane::Split(SplitPane::Staged), region)); + } + } + None + } + + /// Focus the diff pane a click/wheel landed in, mirroring the keyboard focus rules: if the + /// outline had focus, `focus_diff()` moves focus onto whichever split pane already has it; if + /// the event landed in the OTHER split pane, `toggle_split_focus()` flips onto it next (never + /// assigning `split_focus` directly — see that method's doc comment). `target` is `None` for + /// the single-zoom pane, where there is no second half to flip to. + fn focus_diff_pane(&mut self, target: Option) { + if self.outline_focused() { + self.focus_diff(); + } + if let Some(target) = target { + if self.split_focus != target { + self.toggle_split_focus(); + } + } + } + + /// Set the (now-focused) pane's cursor to the row under a click, offset from `region`'s top by + /// `row` and clamped into the current row list, then re-derive `scroll`. A no-op on an empty + /// file list, matching [`Self::move_cursor_by`]'s empty-list behavior. + fn set_cursor_from_click(&mut self, region: Region, row: u16) { + let rows = self.row_count(); + if rows == 0 { + return; + } + let offset = (row - region.y) as usize; + self.cursor = (self.scroll + offset).min(rows - 1); + self.derive_scroll(); + } + + /// Left-click at terminal `(col, row)` (CS10): focus + select whatever content region the + /// click landed in, matching the keyboard-driven equivalent for that region. Outline: focuses + /// the outline and jumps the cursor to the clicked row via [`Self::outline_move_to`] — a File + /// row jumps the diff there (same single-jump semantics `g`/`G` use), a Header/Dir row just + /// selects (the summary panel follows via [`Self::summary_target`]). Diff pane (single or + /// split): focuses that pane (flipping `split_focus` first if the click landed in the + /// unfocused half) and moves its cursor to the clicked row. Outside every recorded region + /// (header/footer/divider/captions): no-op. + pub fn handle_click(&mut self, col: u16, row: u16) { + let Some((pane, region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + let idx = self.outline.scroll + (row - region.y) as usize; + self.outline_move_to(idx); + } + HitPane::Single => { + self.focus_diff_pane(None); + self.set_cursor_from_click(region, row); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.set_cursor_from_click(region, row); + } + } + } + + /// Mouse wheel at terminal `(col, row)` with `delta` = ±3 rows (`tui::update` maps + /// `ScrollDown`/`ScrollUp` to +3/-3). Focuses whichever region the pointer sits over first — + /// same rule as [`Self::handle_click`] — then moves that pane's cursor by `delta` + /// ([`Self::outline_move_by`] for the outline, [`Self::move_cursor_by`] otherwise); scroll + /// simply follows via the normal derive discipline rather than a decoupled scroll state. + /// Outside every recorded region: no-op. + pub fn handle_wheel(&mut self, col: u16, row: u16, delta: i64) { + let Some((pane, _region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + self.outline_move_by(delta); + } + HitPane::Single => { + self.focus_diff_pane(None); + self.move_cursor_by(delta); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.move_cursor_by(delta); + } + } + } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no /// extra state to reposition here, unlike [`Self::toggle_outline`]. @@ -4374,8 +4534,8 @@ mod tests { use super::test_support::app_from_fixture; use super::{ build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, - EffectiveZoom, Layout, LoadedViews, Role, Severity, Summary, SummaryTarget, Zoom, - DEFAULT_OUTLINE_WIDTH, + EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, Summary, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -10057,4 +10217,237 @@ mod tests { view.display ); } + + // ── CS10: mouse (click-to-focus, wheel scrolling) ──────────────────────────── + + #[test] + fn click_on_an_outline_file_row_focuses_selects_and_jumps_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + // BaseFirst Stack order: header(cs-a)=0, a1.txt=1, a2.txt=2, header(cs-b)=3, b1.txt=4. + app.outline_height = 10; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }); + assert!(!app.outline_focused(), "starts unfocused (locked default)"); + + // Row 2 (a2.txt) at the outline's top-of-viewport (scroll 0) is screen row 2. + app.handle_click(5, 2); + + assert!(app.outline_focused(), "a click on the outline focuses it"); + assert_eq!(app.outline_cursor(), 2); + assert_eq!(app.current_cs(), 0); + assert_eq!( + app.files()[app.current].path, + "a2.txt", + "a File row's click must jump the diff there, like outline_move_to" + ); + } + + #[test] + fn click_on_an_outline_header_row_selects_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline_height = 10; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }); + let before_cs = app.current_cs(); + let before_file = app.current; + + // Row 3 is cs-b's header. + app.handle_click(5, 3); + + assert!(app.outline_focused()); + assert_eq!(app.outline_cursor(), 3); + assert_eq!( + (app.current_cs(), app.current), + (before_cs, before_file), + "a Header row's click must not jump the diff" + ); + assert!( + app.summary_target().is_some(), + "selecting a Header row (outline open + focused) must surface the summary panel" + ); + } + + #[test] + fn click_in_the_single_diff_pane_focuses_it_and_moves_the_cursor_to_the_clicked_row() { + // 40 single-line rows (mirrors `derive_scroll_keeps_scrolloff_margin_and_slides_minimally` + // above) — long enough that clicking row 4 lands there without clamping against a tiny + // real diff. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.focus_outline(); + assert!(app.outline_focused()); + app.pane_height = 10; + app.cursor = 0; + app.scroll = 0; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + + app.handle_click(10, 4); + + assert!( + !app.outline_focused(), + "a click in the diff pane must return focus to the diff" + ); + assert_eq!( + app.cursor, 4, + "the cursor must land on the clicked row (scroll 0 + offset 4)" + ); + } + + #[test] + fn click_in_the_unfocused_split_pane_flips_split_focus_and_moves_its_cursor() { + let fixture = partial_fixture(); + let mut app = app_from_fixture(&fixture); + app.open_current(); // Split; focused pane defaults to Unstaged + assert_eq!(app.effective_zoom_for(app.current), EffectiveZoom::Split); + assert_eq!(app.split_focus_role(), Role::Unstaged); + + app.pane_height = 5; + app.alt_height = 5; + app.derive_scroll(); + app.derive_alt_scroll(); + app.hit_regions.unstaged = Some(Region { + x: 0, + y: 1, + w: 40, + h: 5, + }); + app.hit_regions.staged = Some(Region { + x: 0, + y: 7, + w: 40, + h: 5, + }); + + // Row 1 inside the staged region (y=7, height 5) — the currently UNFOCUSED pane. `f.txt` + // is a 3-line file (alpha/beta/gamma), so offset 1 stays within its row count either way. + app.handle_click(3, 8); + + assert_eq!( + app.split_focus_role(), + Role::Staged, + "a click in the unfocused pane must flip split_focus onto it" + ); + let (_, cursor) = app.pane_render_state(Role::Staged); + assert_eq!( + cursor, + Some(1), + "the newly-focused pane's cursor must land on the clicked row (offset 1 into the region)" + ); + } + + #[test] + fn wheel_over_the_outline_focuses_it_and_moves_the_cursor_by_delta_with_scrolloff() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 + app.outline.cursor = 0; + app.derive_outline_scroll(); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + assert!(!app.outline_focused()); + + app.handle_wheel(5, 2, 3); + + assert!(app.outline_focused(), "a wheel event focuses its pane"); + assert_eq!(app.outline_cursor(), 3); + let scroll = app.outline_scroll(); + assert!( + app.outline_cursor() >= scroll && app.outline_cursor() <= scroll + 2, + "the outline scroll must follow the wheel-moved cursor via the normal scrolloff derive" + ); + } + + #[test] + fn wheel_over_the_focused_diff_pane_moves_the_cursor_by_delta() { + // Same 40-line fixture as the click test above — enough rows that a ±3 wheel move never + // clamps against a tiny real diff. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.cursor = 5; + app.scroll = 0; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let cursor_before = app.cursor; + + app.handle_wheel(10, 3, 3); + assert_eq!(app.cursor, cursor_before + 3); + + app.handle_wheel(10, 3, -3); + assert_eq!(app.cursor, cursor_before); + } + + #[test] + fn click_outside_every_hit_region_is_a_no_op() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_height = 10; + app.pane_height = 10; + app.hit_regions = HitRegions { + outline: Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }), + single: Some(Region { + x: 21, + y: 0, + w: 40, + h: 10, + }), + unstaged: None, + staged: None, + }; + let outline_focused_before = app.outline_focused(); + let cursor_before = app.cursor; + let outline_cursor_before = app.outline_cursor(); + let current_before = (app.current_cs(), app.current); + + // Row 0 sits above both content regions (a header row at y=0 in either would collide — + // pick a column between the two panes' widths, on the divider itself). + app.handle_click(20, 0); + + assert_eq!(app.outline_focused(), outline_focused_before); + assert_eq!(app.cursor, cursor_before); + assert_eq!(app.outline_cursor(), outline_cursor_before); + assert_eq!((app.current_cs(), app.current), current_before); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 700c94c6..cd1945f7 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -14,7 +14,7 @@ use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ - App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity, Summary, + App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Region, Role, Severity, Summary, }; use crate::attribute::Attribution; use crate::config::View; @@ -452,6 +452,11 @@ fn build_pane_line( pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); + // CS10: reset every recorded hit region at the start of the frame — a region only survives + // this frame if one of the panes below actually painted it again. Prevents a stale rect from + // an earlier frame's layout (e.g. the outline just closed) from staying hit-testable. + app.hit_regions = Default::default(); + // Paint the whole screen with the theme's background FIRST — a curated theme (light/dark) // controls the canvas outright; `auto` leaves `paint_canvas` false so the terminal's own // background (and any transparency) shows through instead. Everything drawn below only sets @@ -509,6 +514,17 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette } } +/// Convert a ratatui [`Rect`] into the [`Region`] shape [`App::hit_regions`] stores (CS10) — +/// `app.rs` has no ratatui dependency, so every write into `hit_regions` goes through this. +fn region_from(area: Rect) -> Region { + Region { + x: area.x, + y: area.y, + w: area.width, + h: area.height, + } +} + /// Compute a centered `percent_x` × `percent_y` sub-rect of `area` — the standard ratatui popup /// pattern (two nested percentage splits). fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { @@ -582,6 +598,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// transient bottom-anchor scroll computed fresh each frame. fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { app.outline_height = area.height as usize; + app.hit_regions.outline = Some(region_from(area)); let items = app.outline_items(); app.derive_outline_scroll(items.len()); @@ -1233,6 +1250,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { match app.effective_zoom_for(idx) { EffectiveZoom::Single(role) => { app.pane_height = area.height as usize; + app.hit_regions.single = Some(region_from(area)); let scroll = app.scroll; let cursor = Some(app.cursor); // The single pane is the focused one, so it shows any active selection. @@ -1292,6 +1310,8 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t }; app.pane_height = focused_h as usize; app.alt_height = unfocused_h as usize; + app.hit_regions.unstaged = Some(region_from(unstaged_content)); + app.hit_regions.staged = Some(region_from(staged_content)); app.derive_scroll(); app.derive_alt_scroll(); diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 5d194a2c..724bf59a 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -15,6 +15,13 @@ //! `event::poll`'s timeout to the channel's. The M4 index watcher's *semantics* are exactly //! unchanged by this move: it still compares [`workon_review::refresh::IndexSignature`] and //! re-diffs in place via [`App::on_tick`] on every `Tick`; only the beat's mechanism moved. +//! +//! CS10 turns the mouse on: [`Tui::acquire`] enables capture for the whole session (undone by +//! [`Tui::restore`] and, unconditionally, the panic hook), and [`map_terminal_event`] maps a +//! left-click or wheel-scroll into an [`AppEvent::Mouse`] the loop dispatches to +//! [`workon_review::app::App::handle_click`]/[`workon_review::app::App::handle_wheel`] — every +//! other mouse kind (drag, move, non-left buttons, button-up) is still dropped, same as key +//! release/repeat. use std::fs::File; use std::io::{self, Write}; @@ -23,7 +30,10 @@ use std::sync::mpsc; use std::thread; use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + MouseButton, MouseEvent, MouseEventKind, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -52,6 +62,10 @@ use workon_review::theme::Palette; pub enum AppEvent { Key(KeyEvent), Resize(u16, u16), + /// A left-click or wheel-scroll (CS10) — the only [`MouseEventKind`]s [`map_terminal_event`] + /// maps; drag, move, non-left buttons, and up events are dropped at the mapping step, exactly + /// like key release/repeat. + Mouse(MouseEvent), Tick, /// One [`LoadRequest`]'s result — ADR-037's loader-result variant. `gen`/`cs_idx`/`file_idx` /// echo the request's stamp; `result` is `Err` for a job that panicked or otherwise failed @@ -98,15 +112,25 @@ impl PartialEq for AppEvent { /// still observable, just relayed rather than swallowed). `Tick` never appears here. type InboxMessage = io::Result; -/// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press and -/// resize map; key release/repeat, mouse, paste, and focus events are skipped (`None`), exactly -/// like this module's pre-ADR-037 `next_event`/`drain_pending` read arms did. Pure and -/// independent of any thread or channel, so it's unit-tested directly; the input thread's loop -/// body is a thin wrapper around it. +/// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press, +/// resize, and (CS10) a left-click or wheel-scroll map; key release/repeat, every other mouse +/// kind (drag, move, non-left buttons, button-up), paste, and focus events are skipped (`None`). +/// Pure and independent of any thread or channel, so it's unit-tested directly; the input +/// thread's loop body is a thin wrapper around it. fn map_terminal_event(event: Event) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), Event::Resize(w, h) => Some(AppEvent::Resize(w, h)), + Event::Mouse(m) + if matches!( + m.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + ) => + { + Some(AppEvent::Mouse(m)) + } _ => None, } } @@ -695,6 +719,19 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap KeyOutcome::Handled => false, KeyOutcome::Action(action) => apply_action(app, action), }, + // CS10: both modals swallow mouse input exactly like they swallow keys (cases 1-2 above) + // — a click/wheel while a discard confirm or the help overlay is up does nothing. + AppEvent::Mouse(_) if app.pending_confirm.is_some() || app.help_visible => false, + AppEvent::Mouse(m) => { + app.clear_notice(); + match m.kind { + MouseEventKind::Down(MouseButton::Left) => app.handle_click(m.column, m.row), + MouseEventKind::ScrollDown => app.handle_wheel(m.column, m.row, 3), + MouseEventKind::ScrollUp => app.handle_wheel(m.column, m.row, -3), + _ => {} + } + false + } AppEvent::Tick => { app.on_tick(); false @@ -878,7 +915,11 @@ fn install_panic_hook() { std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let mut out = terminal_writer(); - let _ = execute!(out, LeaveAlternateScreen); + // CS10: disable mouse capture unconditionally, same as `Tui::restore` — a stray disable + // sequence when capture was never enabled (a panic before `Tui::acquire` reaches its own + // `EnableMouseCapture`) is harmless, and there's no cheaper way from here to know whether + // capture is currently on. + let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen); default_hook(info); })); } @@ -903,7 +944,10 @@ impl Tui { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); - execute!(out, EnterAlternateScreen)?; + // CS10: capture the mouse for the whole session — `map_terminal_event` only ever lets a + // left-click or wheel-scroll through, so this doesn't cost the terminal's normal + // text-selection UX beyond what most terminals' shift-click bypass already covers. + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(out); let terminal = Terminal::new(backend)?; Ok(Self { @@ -1014,7 +1058,14 @@ impl Tui { } self.restored = true; disable_raw_mode()?; - execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + // CS10: disable mouse capture before leaving the alternate screen — same ordering + // convention as the raw-mode/alternate-screen pair, undone in the reverse order acquire + // set them up in. + execute!( + self.terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + )?; self.terminal.show_cursor() } } @@ -1144,9 +1195,18 @@ mod tests { ); } + fn mouse(kind: MouseEventKind) -> MouseEvent { + MouseEvent { + kind, + column: 5, + row: 7, + modifiers: KeyModifiers::NONE, + } + } + #[test] - fn map_terminal_event_skips_release_repeat_mouse_paste_and_focus() { - use crossterm::event::{KeyEventState, MouseEvent, MouseEventKind}; + fn map_terminal_event_skips_release_repeat_paste_and_focus() { + use crossterm::event::KeyEventState; let release = KeyEvent::new_with_kind( KeyCode::Char('q'), @@ -1163,20 +1223,57 @@ mod tests { ); assert_eq!(map_terminal_event(Event::Key(repeat)), None); - assert_eq!( - map_terminal_event(Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - })), - None - ); assert_eq!(map_terminal_event(Event::Paste("pasted".to_string())), None); assert_eq!(map_terminal_event(Event::FocusGained), None); assert_eq!(map_terminal_event(Event::FocusLost), None); } + /// CS10: `map_terminal_event` maps ONLY a left-click-down or a wheel-scroll to + /// `AppEvent::Mouse`; every other mouse kind — drag, move, button-up, and non-left buttons — + /// is still dropped, exactly like the pre-CS10 version dropped every mouse event outright. + /// This supersedes the old `map_terminal_event_skips_release_repeat_mouse_paste_and_focus` + /// pin (split above into the non-mouse skip cases, which are unchanged by CS10). + #[test] + fn map_terminal_event_maps_left_down_and_scroll_but_drops_other_mouse_kinds() { + let left_down = mouse(MouseEventKind::Down(MouseButton::Left)); + assert!(matches!( + map_terminal_event(Event::Mouse(left_down)), + Some(AppEvent::Mouse(m)) if m == left_down + )); + + let scroll_up = mouse(MouseEventKind::ScrollUp); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_up)), + Some(AppEvent::Mouse(m)) if m == scroll_up + )); + + let scroll_down = mouse(MouseEventKind::ScrollDown); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_down)), + Some(AppEvent::Mouse(m)) if m == scroll_down + )); + + // Dropped: drag, move, button-up, and a right-click-down. + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Drag(MouseButton::Left)))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Moved))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Up(MouseButton::Left)))), + None + ); + assert_eq!( + map_terminal_event(Event::Mouse(mouse(MouseEventKind::Down( + MouseButton::Right + )))), + None + ); + } + /// `AppEvent` dropped `PartialEq`/`Eq` in ADR-037 (`FileReady`'s `LoadedViews` payload wraps /// `FileView`, which has neither) — this test-only helper is the `matches!`-based replacement /// for the `assert_eq!(event, AppEvent::Key(key(...)))` shape used throughout this module's @@ -1881,6 +1978,50 @@ mod tests { repo.assert(predicate::repo::workdir_file_equals("a.txt", "one\ntwo\n")); } + /// CS10: a pending discard confirm swallows a mouse event exactly like it swallows a key — + /// mirrors `pending_confirm_captures_y_and_n_and_ignores_other_keys` above. A click inside a + /// live hit region must not move the cursor or resolve the confirm. + #[test] + fn pending_confirm_swallows_a_mouse_click() { + use workon_review::app::{PendingOp, Region}; + + let fixture = git_workon_fixture::prelude::FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\nthree\n", "one\nCHANGED\nthree\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + let cursor_before = app.cursor; + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Mouse(mouse(MouseEventKind::Down(MouseButton::Left))), + ); + + assert!(!quit); + assert!( + app.pending_confirm.is_some(), + "a mouse event must not resolve the confirm" + ); + assert_eq!( + app.cursor, cursor_before, + "a swallowed click must not move the cursor" + ); + } + // ── M5 CS3: outline pane key routing ───────────────────────────────────── /// A two-committed-changeset stack, built the same way as `app.rs`/`render.rs`'s own M5 From b5b12743dd7e0e12d5fd7c46056cec3edd717657 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:36:24 -0400 Subject: [PATCH 07/19] fix(review): compare AppEvent::Mouse structurally in PartialEq --- git-workon-review/src/tui.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 724bf59a..b8f15ec6 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -92,15 +92,20 @@ pub enum AppEvent { impl PartialEq for AppEvent { /// Manual, deliberately PARTIAL equality (can't derive — `FileReady`'s `LoadedViews` payload - /// isn't `PartialEq`, see the enum's doc comment): `Key`/`Resize`/`Tick` compare structurally, - /// exactly like the pre-ADR-037 derive did, for the input-thread tests that still assert - /// mapped-event shape via `assert_eq!`. Two `FileReady` events are never considered equal — - /// there's no sound definition of "the same loader result" once `FileView` can't be compared, - /// and nothing needs one; tests that care about a `FileReady`'s fields match on them directly. + /// isn't `PartialEq`, see the enum's doc comment): `Key`/`Resize`/`Mouse`/`Tick` compare + /// structurally, exactly like the pre-ADR-037 derive did, for the input-thread tests that + /// still assert mapped-event shape via `assert_eq!`. Two `FileReady` events are never + /// considered equal — there's no sound definition of "the same loader result" once `FileView` + /// can't be compared, and nothing needs one; tests that care about a `FileReady`'s fields + /// match on them directly. Every fully-comparable variant needs its own arm here: the + /// `_ => false` catch-all exists ONLY for `FileReady`/`ChangesetReady`, and letting a + /// comparable variant fall into it silently breaks reflexivity (`Mouse` did exactly that + /// when CS10 first added it — crossterm's `MouseEvent` derives `PartialEq` fine). fn eq(&self, other: &Self) -> bool { match (self, other) { (AppEvent::Key(a), AppEvent::Key(b)) => a == b, (AppEvent::Resize(w1, h1), AppEvent::Resize(w2, h2)) => w1 == w2 && h1 == h2, + (AppEvent::Mouse(a), AppEvent::Mouse(b)) => a == b, (AppEvent::Tick, AppEvent::Tick) => true, _ => false, } From 1f9a0ca805af7ad267c4b7ef71ff10660e8bd941 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 10 Jul 2026 23:53:51 -0400 Subject: [PATCH 08/19] fix(review): pass row count to derive_outline_scroll in mouse tests --- git-workon-review/src/app.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 205ad601..bd9a58bb 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -10227,7 +10227,7 @@ mod tests { app.outline.order = OutlineOrder::BaseFirst; // BaseFirst Stack order: header(cs-a)=0, a1.txt=1, a2.txt=2, header(cs-b)=3, b1.txt=4. app.outline_height = 10; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, @@ -10255,7 +10255,7 @@ mod tests { app.outline.mode = OutlineMode::Stack; app.outline.order = OutlineOrder::BaseFirst; app.outline_height = 10; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, @@ -10365,7 +10365,7 @@ mod tests { let mut app = four_committed_changesets_three_files_each(); app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 app.outline.cursor = 0; - app.derive_outline_scroll(); + app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { x: 0, y: 0, From 7d328dc23619a25562a71f043561fc524e2a72e7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sat, 11 Jul 2026 10:41:16 -0400 Subject: [PATCH 09/19] fix(review): mouse wheel scrolls the viewport and leaves the cursor --- git-workon-review/src/app.rs | 142 ++++++++++++++++++++++++++------ git-workon-review/src/render.rs | 50 ++++++++++- 2 files changed, 164 insertions(+), 28 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index bd9a58bb..3659fa76 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1125,8 +1125,8 @@ pub struct App { /// [`Self::toggle_split_focus`]). Meaningless outside [`EffectiveZoom::Split`]. alt: PaneState, /// Content height of the unfocused split pane, written by the renderer alongside - /// [`Self::pane_height`] — [`Self::derive_alt_scroll`] derives the unfocused pane's scroll - /// against THIS, not the focused pane's height. + /// [`Self::pane_height`] — the unfocused pane's scroll is clamped/derived against THIS, not + /// the focused pane's height (see [`Self::clamp_alt_scroll`]). pub(crate) alt_height: usize, /// Content height of the outline pane, written by the renderer each frame — same discipline /// as [`Self::pane_height`]. Read by [`Self::derive_outline_scroll`]. @@ -2699,10 +2699,10 @@ impl App { /// Mouse wheel at terminal `(col, row)` with `delta` = ±3 rows (`tui::update` maps /// `ScrollDown`/`ScrollUp` to +3/-3). Focuses whichever region the pointer sits over first — - /// same rule as [`Self::handle_click`] — then moves that pane's cursor by `delta` - /// ([`Self::outline_move_by`] for the outline, [`Self::move_cursor_by`] otherwise); scroll - /// simply follows via the normal derive discipline rather than a decoupled scroll state. - /// Outside every recorded region: no-op. + /// same rule as [`Self::handle_click`] — then scrolls that pane's VIEWPORT by `delta`, + /// leaving the cursor exactly where it was (the peek model: a wheel is "look elsewhere", + /// never "select elsewhere") — see [`Self::scroll_viewport_by`]. Outside every recorded + /// region: no-op. pub fn handle_wheel(&mut self, col: u16, row: u16, delta: i64) { let Some((pane, _region)) = self.hit_test(col, row) else { return; @@ -2710,19 +2710,48 @@ impl App { match pane { HitPane::Outline => { self.focus_outline(); - self.outline_move_by(delta); + self.outline_scroll_viewport_by(delta); } HitPane::Single => { self.focus_diff_pane(None); - self.move_cursor_by(delta); + self.scroll_viewport_by(delta); } HitPane::Split(target) => { self.focus_diff_pane(Some(target)); - self.move_cursor_by(delta); + self.scroll_viewport_by(delta); } } } + /// Scroll the focused pane's viewport by `delta` rows (mouse wheel), clamped to the row + /// list. The cursor is deliberately NOT touched (the peek model: a wheel is "look + /// elsewhere", never "select elsewhere"), so it can sit outside the viewport — the next + /// cursor-driven op re-derives the scroll and snaps the view back to it, which is the + /// peek model's recovery gesture, not a bug. This is the one place `scroll` is written + /// directly rather than derived from the cursor; the renderer's bounds-clamp (see + /// [`Self::clamp_scroll`]) is what lets the wheeled position survive frames. + fn scroll_viewport_by(&mut self, delta: i64) { + let rows = self.row_count(); + if rows == 0 { + return; + } + let max_scroll = rows.saturating_sub(self.pane_height.max(1)) as i64; + self.scroll = (self.scroll as i64 + delta).clamp(0, max_scroll.max(0)) as usize; + } + + /// The outline counterpart of [`Self::scroll_viewport_by`] — same peek model: the outline + /// cursor never moves (so wheeling past File rows can't jump the diff, and the summary + /// panel's target stays put); the next outline cursor op snaps the view back to it. + fn outline_scroll_viewport_by(&mut self, delta: i64) { + let rows = self.outline_items().len(); + if rows == 0 { + return; + } + let max_scroll = rows.saturating_sub(self.outline_height.max(1)) as i64; + self.outline.scroll = + (self.outline.scroll as i64 + delta).clamp(0, max_scroll.max(0)) as usize; + } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no /// extra state to reposition here, unlike [`Self::toggle_outline`]. @@ -3198,8 +3227,11 @@ impl App { } /// Re-derive the UNFOCUSED split pane's scroll against its own cursor, row count, and - /// [`Self::alt_height`] — called by the renderer each split frame, after the pane heights are - /// known. + /// [`Self::alt_height`]. Test-only since the wheel's peek model (CS10): the renderer now + /// bounds-clamps instead of deriving (see [`Self::clamp_alt_scroll`]), and no production + /// path derives the unfocused pane's scroll — the pair re-derives naturally once focus + /// swaps back onto it and a cursor op runs. + #[cfg(test)] pub(crate) fn derive_alt_scroll(&mut self) { let role = self.unfocused_split_role(); let rows = self.role_row_count(self.current, role); @@ -3207,6 +3239,35 @@ impl App { derive_scroll_value(self.alt.cursor, self.alt.scroll, rows, self.alt_height); } + /// Bounds-only clamp of the focused pane's scroll — the renderer's per-frame check under + /// the wheel's peek model (CS10). Unlike [`Self::derive_scroll`] it does NOT follow the + /// cursor, so a wheel-scrolled viewport (cursor possibly outside it) survives frames; it + /// only keeps `scroll` inside the row list when a resize/zoom shrinks it. + pub(crate) fn clamp_scroll(&mut self) { + let rows = self.row_count(); + self.scroll = self + .scroll + .min(rows.saturating_sub(self.pane_height.max(1))); + } + + /// [`Self::clamp_scroll`] for the unfocused split pane. + pub(crate) fn clamp_alt_scroll(&mut self) { + let role = self.unfocused_split_role(); + let rows = self.role_row_count(self.current, role); + self.alt.scroll = self + .alt + .scroll + .min(rows.saturating_sub(self.alt_height.max(1))); + } + + /// [`Self::clamp_scroll`] for the outline pane. + pub(crate) fn clamp_outline_scroll(&mut self, rows: usize) { + self.outline.scroll = self + .outline + .scroll + .min(rows.saturating_sub(self.outline_height.max(1))); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4535,7 +4596,7 @@ mod tests { use super::{ build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, Summary, - SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -10361,9 +10422,9 @@ mod tests { } #[test] - fn wheel_over_the_outline_focuses_it_and_moves_the_cursor_by_delta_with_scrolloff() { + fn wheel_over_the_outline_scrolls_the_viewport_without_moving_cursor_or_diff() { let mut app = four_committed_changesets_three_files_each(); - app.outline_height = 5; // bottom_margin = 5 - 1 - SCROLLOFF(2) = 2 + app.outline_height = 5; app.outline.cursor = 0; app.derive_outline_scroll(app.outline_items().len()); app.hit_regions.outline = Some(Region { @@ -10373,20 +10434,40 @@ mod tests { h: 5, }); assert!(!app.outline_focused()); + let (cs_before, file_before) = (app.current_cs(), app.current); app.handle_wheel(5, 2, 3); assert!(app.outline_focused(), "a wheel event focuses its pane"); - assert_eq!(app.outline_cursor(), 3); - let scroll = app.outline_scroll(); - assert!( - app.outline_cursor() >= scroll && app.outline_cursor() <= scroll + 2, - "the outline scroll must follow the wheel-moved cursor via the normal scrolloff derive" + assert_eq!( + app.outline_scroll(), + 3, + "the wheel moves the VIEWPORT by delta" + ); + assert_eq!( + app.outline_cursor(), + 0, + "peek model: the cursor never moves with the wheel, even out of the viewport" + ); + assert_eq!( + (app.current_cs(), app.current), + (cs_before, file_before), + "no cursor move means no diff jump, ever" + ); + + // The recovery gesture: the next cursor op re-derives the scroll and snaps the view + // back to the (wheel-abandoned) cursor. + app.outline_move_by(1); + assert_eq!(app.outline_cursor(), 1); + assert_eq!( + app.outline_scroll(), + 0, + "a cursor op after a wheel peek snaps the viewport back to the cursor" ); } #[test] - fn wheel_over_the_focused_diff_pane_moves_the_cursor_by_delta() { + fn wheel_over_the_focused_diff_pane_scrolls_the_viewport_and_leaves_the_cursor() { // Same 40-line fixture as the click test above — enough rows that a ±3 wheel move never // clamps against a tiny real diff. let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); @@ -10398,7 +10479,7 @@ mod tests { let mut app = app_from_fixture(&fixture); app.open_current(); app.pane_height = 10; - app.cursor = 5; + app.cursor = 8; app.scroll = 0; app.hit_regions.single = Some(Region { x: 0, @@ -10406,13 +10487,24 @@ mod tests { w: 40, h: 10, }); - let cursor_before = app.cursor; app.handle_wheel(10, 3, 3); - assert_eq!(app.cursor, cursor_before + 3); + app.handle_wheel(10, 3, 3); + app.handle_wheel(10, 3, 3); + assert_eq!(app.scroll, 9, "three wheel presses move the viewport 3x3"); + assert_eq!( + app.cursor, 8, + "peek model: the cursor stays put even once the viewport has scrolled past it" + ); - app.handle_wheel(10, 3, -3); - assert_eq!(app.cursor, cursor_before); + // The recovery gesture: any cursor op re-derives and snaps the view back. + app.move_cursor_by(1); + assert_eq!(app.cursor, 9); + assert_eq!( + app.scroll, + 9 - SCROLLOFF, + "a cursor op after a wheel peek snaps the viewport back to the cursor's window" + ); } #[test] diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index cd1945f7..c343e44e 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -600,7 +600,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) app.outline_height = area.height as usize; app.hit_regions.outline = Some(region_from(area)); let items = app.outline_items(); - app.derive_outline_scroll(items.len()); + // Bounds-clamp only — NOT a cursor-following derive: under the wheel's peek model a + // scrolled-away viewport must survive the frame; cursor ops re-derive on their own. + app.clamp_outline_scroll(items.len()); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -1312,8 +1314,10 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.alt_height = unfocused_h as usize; app.hit_regions.unstaged = Some(region_from(unstaged_content)); app.hit_regions.staged = Some(region_from(staged_content)); - app.derive_scroll(); - app.derive_alt_scroll(); + // Bounds-clamp only (peek model — see render_outline's identical note); this also brings + // the split arm in line with the Single arm, which never re-derived at render time. + app.clamp_scroll(); + app.clamp_alt_scroll(); render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED", theme); render_caption(frame.buffer_mut(), staged_caption, "STAGED", theme); @@ -2917,6 +2921,46 @@ mod tests { ); } + #[test] + fn render_preserves_a_wheel_scrolled_outline_viewport() { + // The peek model's load-bearing render change: `render_outline` bounds-CLAMPS the + // outline scroll instead of re-deriving it from the cursor, so a wheel-scrolled + // viewport (cursor left outside it) survives the frame instead of snapping back. + use crate::app::Region; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert!(app.outline_open()); + // A 5-row frame leaves a 3-row outline viewport over this fixture's 4 outline rows + // (2 headers + 2 files): max scroll = 1. + app.outline_height = 3; + app.hit_regions.outline = Some(Region { + x: 0, + y: 1, + w: 34, + h: 3, + }); + let cursor_before = app.outline_cursor(); + + app.handle_wheel(2, 2, 3); // clamps to max scroll = 1 + assert_eq!(app.outline_scroll(), 1, "the wheel scrolled the viewport"); + assert_eq!( + app.outline_cursor(), + cursor_before, + "peek model: the wheel never moves the outline cursor" + ); + + render_once(&mut app, OUTLINE_TEST_WIDTH, 5); + assert_eq!( + app.outline_scroll(), + 1, + "a frame must not re-derive the wheeled scroll back to the cursor" + ); + } + #[test] fn outline_cursor_row_carries_cursor_background_when_focused() { let fixture = FixtureBuilder::new() From 9ee33ab9b5d1f50c35a5e50b28c68b05686e51e6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 12 Jul 2026 12:13:12 -0400 Subject: [PATCH 10/19] feat(review): horizontal scroll for long diff lines --- Cargo.lock | 1 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/app.rs | 173 +++++++++++++++++ git-workon-review/src/keymap.rs | 67 ++++++- git-workon-review/src/render.rs | 321 ++++++++++++++++++++++++++++++-- git-workon-review/src/tui.rs | 62 +++++- 6 files changed, 610 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f6b108c..6c490664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,6 +996,7 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "unicode-width 0.2.2", ] [[package]] diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index d9e9a408..ce464c5a 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -54,6 +54,7 @@ tree-sitter-md.workspace = true tree-sitter-rust.workspace = true tree-sitter-toml-ng.workspace = true tree-sitter-typescript.workspace = true +unicode-width.workspace = true [package.metadata.dist] # Redundant with publish = false today; load-bearing at the M3 flip so diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 3659fa76..23fe9c61 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::Path; use git2::Repository; +use unicode_width::UnicodeWidthStr; use workon::{Changeset, ChangesetSpan}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; @@ -40,6 +41,10 @@ use crate::wordiff::{word_diff_spans, Span}; /// [`App::derive_scroll`]. const SCROLLOFF: usize = 2; +/// Display columns panned per `hscroll-left`/`hscroll-right` press — see [`App::hscroll_left`]/ +/// [`App::hscroll_right`]. +const HSCROLL_STEP: usize = 8; + /// Loaded, aligned, highlighted view of one file's combined diff. /// /// Full text is read once per side, from whichever source the file's status says still exists: @@ -1118,6 +1123,13 @@ pub struct App { /// directly by the renderer, but never written except by [`Self::derive_scroll`] — every /// cursor-moving method ends by calling it, so `scroll` always reflects the CURRENT `cursor`. pub scroll: usize, + /// Column pan offset (display columns, not bytes) applied to every diff CONTENT pane — both + /// side-by-side halves and both split panes share this one offset; the gutter stays pinned at + /// column 0. Panned by [`Self::hscroll_left`]/[`Self::hscroll_right`], clamped against the + /// current view's longest row (see those methods), and reset to `0` on file/changeset + /// navigation ([`Self::next_file`]/[`Self::prev_file`]/[`Self::next_changeset`]/ + /// [`Self::prev_changeset`]) — cursor movement within a file leaves it untouched. + pub hscroll: usize, /// Content height of the focused pane, written by the renderer each frame. In a single-pane /// zoom this is the whole body; in a split it's the focused half (see [`Self::alt_height`]). pub pane_height: usize, @@ -1378,6 +1390,7 @@ impl App { current: 0, cursor: 0, scroll: 0, + hscroll: 0, pane_height: 20, alt: PaneState::default(), alt_height: 20, @@ -2359,6 +2372,7 @@ impl App { /// outline-initiated jump (which sets [`OutlineState::cursor`] itself before calling /// `switch_changeset`/`goto_changeset` directly) never re-triggers it. pub fn next_file(&mut self) { + self.hscroll = 0; if self.cur().diff.files.is_empty() { return; } @@ -2378,6 +2392,7 @@ impl App { /// first changeset. See [`Self::next_file`]'s doc comment for why this calls /// [`Self::sync_outline_to_current`] at the end. pub fn prev_file(&mut self) { + self.hscroll = 0; if self.cur().diff.files.is_empty() { return; } @@ -2406,6 +2421,7 @@ impl App { /// DIFF-initiated entry point — see [`Self::next_file`]'s doc comment on the sync-follow /// discipline. pub fn next_changeset(&mut self) { + self.hscroll = 0; if self.current_cs + 1 < self.changesets.len() { self.goto_changeset(self.current_cs + 1); } @@ -2415,6 +2431,7 @@ impl App { /// Jump to the previous changeset's first file (`[c`). A no-op at the first changeset. See /// [`Self::next_file`]'s doc comment on the sync-follow discipline. pub fn prev_changeset(&mut self) { + self.hscroll = 0; if self.current_cs > 0 { self.goto_changeset(self.current_cs - 1); } @@ -3268,6 +3285,59 @@ impl App { .min(rows.saturating_sub(self.outline_height.max(1))); } + /// The widest display-column row currently in the active file's view(s) — both roles when + /// split, since [`Self::hscroll`] pans every content pane together (locked decision #1). + /// Walks the already-built [`FileView::display`] row list (shared by both the SBS and inline + /// layouts — inline just re-derives its own row list from the same text), so this is a pure + /// lookup over rows the renderer rebuilds every frame anyway, not a fresh scan of the file. + /// Used only by [`Self::clamp_hscroll`] to keep at least one column of the longest line + /// reachable; computed on demand rather than cached (cheap — see that method's doc comment). + fn max_row_width(&self) -> usize { + let idx = self.current; + let roles: Vec = match self.effective_zoom_for(idx) { + EffectiveZoom::Single(role) => vec![role], + EffectiveZoom::Split => vec![Role::Unstaged, Role::Staged], + }; + let mut max = 0; + for role in roles { + let Some(view) = self.role_view_ref(idx, role) else { + continue; + }; + for row in &view.display { + let DisplayRow::Row(r) = row else { continue }; + if let Row::Line(n) = r.old { + max = max.max(UnicodeWidthStr::width(view.old_line(n))); + } + if let Row::Line(n) = r.new { + max = max.max(UnicodeWidthStr::width(view.new_line(n))); + } + } + } + max + } + + /// Clamp [`Self::hscroll`] into `[0, max_row_width().saturating_sub(1)]` — the `-1` keeps at + /// least one column of the longest line visible (locked decision #4) rather than letting the + /// pan run all the way to a blank viewport. + fn clamp_hscroll(&mut self) { + let max = self.max_row_width().saturating_sub(1); + self.hscroll = self.hscroll.min(max); + } + + /// `hscroll-left`: pan the diff content panes left by [`HSCROLL_STEP`] columns (floored at + /// `0`). + pub fn hscroll_left(&mut self) { + self.hscroll = self.hscroll.saturating_sub(HSCROLL_STEP); + } + + /// `hscroll-right`: pan the diff content panes right by [`HSCROLL_STEP`] columns, clamped so + /// at least one column of the current view's longest row stays visible (see + /// [`Self::clamp_hscroll`]). + pub fn hscroll_right(&mut self) { + self.hscroll = self.hscroll.saturating_add(HSCROLL_STEP); + self.clamp_hscroll(); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4666,6 +4736,62 @@ mod tests { assert_eq!(view.new_text(), ""); } + // ── diff-hscroll: pan clamping ────────────────────────────────────────────── + + #[test] + fn hscroll_left_floors_at_zero() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + assert_eq!(app.hscroll, 0); + app.hscroll_left(); + assert_eq!(app.hscroll, 0, "cannot pan left of column 0"); + } + + #[test] + fn hscroll_right_clamps_to_the_longest_row_leaving_one_column_visible() { + // A line well over a terminal width, so repeated `hscroll-right` presses hit the clamp + // rather than running out of steps first. + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + for _ in 0..100 { + app.hscroll_right(); + } + // `max_row_width` is 200 (the long line); the clamp keeps one column of it reachable. + assert_eq!(app.hscroll, 199); + } + + #[test] + fn hscroll_right_on_a_file_with_no_long_rows_clamps_to_zero() { + // Every row is a single column wide, so `max_row_width` (1) leaves nothing to pan into — + // the clamp (`max_row_width - 1`) is `0`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "a\n", "a\nb\n") + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.hscroll_right(); + assert_eq!( + app.hscroll, 0, + "every row already fits, so there is nothing to pan into" + ); + } + #[test] fn ensure_loaded_reads_old_path_for_renamed_file() { let fixture = FixtureBuilder::new() @@ -7914,6 +8040,53 @@ mod tests { assert_eq!(app.current, 0); } + // ── diff-hscroll: reset on file/changeset nav, preserved across cursor movement ────── + + #[test] + fn next_file_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.next_file(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn prev_file_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + app.hscroll = 5; + app.prev_file(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn next_changeset_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.next_changeset(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn prev_changeset_resets_hscroll_to_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + app.goto_changeset(1); + app.hscroll = 5; + app.prev_changeset(); + assert_eq!(app.hscroll, 0); + } + + #[test] + fn cursor_movement_within_a_file_preserves_hscroll() { + let mut app = two_committed_changesets_two_and_one_files(); + app.hscroll = 5; + app.move_cursor_by(1); + assert_eq!( + app.hscroll, 5, + "plain cursor movement must not reset the horizontal pan" + ); + } + /// Regression: navigating to an OLDER committed changeset and loading its combined view must /// source the new side from that changeset's `head` commit tree, not the current worktree. The /// same file `f.txt` is touched by both changesets, so `cs-a`'s head (`mid`) content differs diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 10d1dcd3..46d65130 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -64,6 +64,8 @@ pub enum Command { PrevChangeset, ExpandGap, ExpandGapAll, + HscrollLeft, + HscrollRight, // Diff view. FocusOutline, // Outline view. @@ -273,7 +275,21 @@ pub static REGISTRY: &[Registered] = &[ view: View::Diff, name: "focus-outline", default_keys: "h left", - description: "Focus the outline", + description: "Focus the outline (pans the diff back to column 0 first, if panned)", + }, + Registered { + command: Command::HscrollLeft, + view: View::Diff, + name: "hscroll-left", + default_keys: "<", + description: "Pan the diff content left", + }, + Registered { + command: Command::HscrollRight, + view: View::Diff, + name: "hscroll-right", + default_keys: "> l right", + description: "Pan the diff content right", }, Registered { command: Command::ExpandGap, @@ -998,6 +1014,55 @@ mod tests { ); } + // ── diff-hscroll: `hscroll-left`/`hscroll-right` registry rows ───────────── + + #[test] + fn hscroll_commands_are_registered_with_no_collision_warnings() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "the new commands' defaults must not collide with anything: {:?}", + km.warnings() + ); + assert!( + !km.keys_for(Command::HscrollLeft).is_empty(), + "hscroll-left must resolve to at least one bound key" + ); + assert!( + !km.keys_for(Command::HscrollRight).is_empty(), + "hscroll-right must resolve to at least one bound key" + ); + } + + #[test] + fn less_than_and_greater_than_dispatch_hscroll_in_the_diff_view() { + let km = Keymap::defaults(); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('<'))]), + Dispatch::Command(Command::HscrollLeft) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('>'))]), + Dispatch::Command(Command::HscrollRight) + ); + } + + /// `l`/`right` are free in the Diff view (they're only bound in the Outline view, to + /// `focus-diff`) — the handoff's locked decision #2 reuses them for `hscroll-right` there, + /// mirroring the Outline view's `l`/`right` = focus-diff. + #[test] + fn l_and_right_dispatch_hscroll_right_in_the_diff_view() { + let km = Keymap::defaults(); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('l'))]), + Dispatch::Command(Command::HscrollRight) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Right)]), + Dispatch::Command(Command::HscrollRight) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index c343e44e..5ca7bd8e 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -11,6 +11,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; +use unicode_width::UnicodeWidthChar; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ @@ -193,6 +194,30 @@ fn apply_selection_row(line: Line<'static>, width: u16, theme: &Palette) -> Line apply_row_tint(line, width, theme.selection_bg) } +/// Horizontal-scroll right-edge marker (decision #7): if `line` (as already blitted into `area` +/// by the caller's `set_line`) is wider than `area`'s content width, overwrite the pane's last +/// cell with a dim `…` so a panned-right line still signals there's more to the right. Applied +/// AFTER `set_line` (and after any cursor/selection wash, which paints its own background first) +/// so the marker survives on a cursor row — `Buffer::set_string`'s `Cell::set_style` only +/// overwrites `fg` when the given style sets it (leaves `bg` untouched when it doesn't, per +/// ratatui's `Style::patch` semantics), so this only ever changes the glyph + foreground, never +/// erasing the wash underneath. +fn apply_right_edge_marker( + buf: &mut Buffer, + area: Rect, + y: u16, + line: &Line<'static>, + theme: &Palette, +) { + if area.width == 0 { + return; + } + if line.width() > area.width as usize { + let x = area.x + area.width - 1; + buf.set_string(x, y, HSCROLL_MARKER, Style::default().fg(theme.dim)); + } +} + /// One resolved (bg, fg) pair for a byte range of a line. struct Segment { start: usize, @@ -344,6 +369,40 @@ enum Side { New, } +/// Horizontal-scroll left-edge marker (decision #7): replaces the first visible content column +/// whenever a line actually had content panned off to the left. Dim-styled like the gap-row/ +/// filler markers — no new color, just `theme.dim` on the existing `…` glyph. +const HSCROLL_MARKER: &str = "…"; + +/// Find the byte offset that cuts `text` at display column `col` (0 for `col == 0`), for +/// [`content_spans`]'s horizontal-scroll slicing. Column, not byte, is the unit `App::hscroll` +/// counts in, so this walks chars accumulating [`UnicodeWidthChar`] widths rather than indexing +/// `text` directly — indexing by column count would panic on a non-char-boundary byte offset for +/// any multibyte UTF-8 line. +/// +/// Returns `(byte_offset, pad)`: `pad` is `true` when a wide (2-column) char straddles the cut — +/// e.g. `col` lands mid-CJK-glyph — in which case that char is dropped entirely (skipping it +/// half-visible would misalign every column after it) and the caller should prepend a one-column +/// space to keep alignment. `col` at or beyond the line's total width returns `(text.len(), false)` +/// (nothing left to show). +fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { + if col == 0 { + return (0, false); + } + let mut acc = 0usize; + for (i, c) in text.char_indices() { + if acc >= col { + return (i, false); + } + let w = UnicodeWidthChar::width(c).unwrap_or(0); + if acc + w > col { + return (i + c.len_utf8(), true); + } + acc += w; + } + (text.len(), false) +} + /// Build the styled content spans (everything after the gutter) for one line of text, shared by /// [`build_pane_line`] (SBS) and [`build_inline_line`] (inline) — the two differ only in how they /// resolve `text`/`hl`/`emphasis` from a [`Row`] vs an [`InlineRow`] and in their gutter, not in @@ -352,6 +411,15 @@ enum Side { /// `emphasis` is `Some((subtle, strong))` for a `Del`/`Add` line (whole-line subtle background, /// plus per-`word_spans` strong background when `is_word_pair`; whole-line strong when not paired /// — an unpaired excess line) and `None` for `Context`/`Filler` (no background emphasis at all). +/// +/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans: segments are composed +/// over the FULL, unsliced `text` exactly as before (every span offset below stays byte-based), +/// then trimmed to start at `hscroll`'s cut point (decision #6) — dropping a segment entirely if +/// it ends at or before the cut, else re-slicing its tail. When the cut actually removed content +/// (`hscroll > 0` and something preceded it), the first visible column renders [`HSCROLL_MARKER`] +/// instead (decision #7's left-edge affordance) — the real cut point in that case is one column +/// further right, to make room for the marker. +#[allow(clippy::too_many_arguments)] fn content_spans( text: &str, hl: Option<&Vec>, @@ -359,6 +427,7 @@ fn content_spans( word_spans: &[WordSpan], is_word_pair: bool, theme: &Palette, + hscroll: usize, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -374,19 +443,51 @@ fn content_spans( } let segments = compose_segments(text.len(), &bg_spans, hl, theme); - let mut spans = Vec::with_capacity(segments.len().max(1)); - if segments.is_empty() && !text.is_empty() { + + // Nothing panned off yet: the common case, byte-identical to pre-hscroll behavior. + let (cut, pad, marker) = if hscroll == 0 { + (0, false, false) + } else { + let (base_cut, _) = hscroll_cut(text, hscroll); + if base_cut > 0 { + // Something was actually cut — reserve column `hscroll` for the marker by cutting + // one column further in. + let (marker_cut, pad) = hscroll_cut(text, hscroll + 1); + (marker_cut, pad, true) + } else { + (0, false, false) + } + }; + + let mut spans = Vec::with_capacity(segments.len().max(1) + 2); + if marker { + spans.push(TSpan::styled( + HSCROLL_MARKER.to_string(), + Style::default().fg(theme.dim), + )); + } + if pad { + spans.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + } + if segments.is_empty() && !text.is_empty() && cut < text.len() { spans.push(TSpan::styled( - text.to_string(), + text[cut..].to_string(), Style::default().fg(theme.foreground), )); } for seg in segments { + if seg.end <= cut { + continue; + } + let start = seg.start.max(cut); let mut style = Style::default().fg(seg.fg); if let Some(bg) = seg.bg { style = style.bg(bg); } - spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); + spans.push(TSpan::styled(text[start..seg.end].to_string(), style)); } spans } @@ -404,6 +505,7 @@ fn build_pane_line( gutter_w: usize, content_w: usize, theme: &Palette, + hscroll: usize, ) -> Line<'static> { match row { Row::Filler => { @@ -436,6 +538,7 @@ fn build_pane_line( word_spans, is_word_pair, theme, + hscroll, )); Line::from(spans) } @@ -801,14 +904,29 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let idx = app.current + 1; let n = app.files().len(); let text = format!("[{idx}/{n}] {}", current_file_label(app)); - frame.render_widget( - Paragraph::new(text).style( - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), - ), - area, - ); + let mut spans = vec![TSpan::styled( + text, + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + )]; + if let Some(span) = hscroll_indicator_span(app, theme) { + spans.push(span); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// While [`App::hscroll`] is panned, a small dim `»42` (the column offset) appended to the header/ +/// winbar (locked decision #8) — `None` at column `0`, matching the diffstat span's own +/// present-or-absent pattern above/below. +fn hscroll_indicator_span(app: &App, theme: &Palette) -> Option> { + if app.hscroll == 0 { + return None; + } + Some(TSpan::styled( + format!(" »{}", app.hscroll), + Style::default().fg(theme.dim), + )) } /// The multi-changeset winbar (locked decisions #8 + #9): `[i/n] @@ -897,6 +1015,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { .fg(theme.foreground) .add_modifier(Modifier::BOLD), )); + if let Some(span) = hscroll_indicator_span(app, theme) { + spans.push(span); + } frame.render_widget(Paragraph::new(Line::from(spans)), area); } @@ -1417,6 +1538,9 @@ fn render_pane_sbs( let old_area = hlayout[0]; let div_area = hlayout[1]; let new_area = hlayout[2]; + // One offset shared by every content pane (locked decision #1) — read once, before any of + // the `app` borrows below. + let hscroll = app.hscroll; let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), old_area); @@ -1492,6 +1616,7 @@ fn render_pane_sbs( old_gutter_w, old_area.width as usize, theme, + hscroll, ); let new_line = build_pane_line( view, @@ -1504,6 +1629,7 @@ fn render_pane_sbs( new_gutter_w, new_area.width as usize, theme, + hscroll, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let (old_line, new_line) = if is_cursor { @@ -1525,6 +1651,12 @@ fn render_pane_sbs( frame .buffer_mut() .set_line(new_area.x, y, &new_line, new_area.width); + // Right-edge hscroll marker (decision #7) — applied AFTER `set_line` (and thus + // after the cursor/selection wash above, which already painted the background) + // so it survives on a cursor/selected row; `apply_right_edge_marker` only sets + // `fg`, leaving whatever background the wash left in place. + apply_right_edge_marker(frame.buffer_mut(), old_area, y, &old_line, theme); + apply_right_edge_marker(frame.buffer_mut(), new_area, y, &new_line, theme); // The divider column was painted once for the whole pane height above, with the // default background; re-tint just this row's divider cell so the cursor wash // covers the full width (panes AND the `│` between them), like `render_gap_row`. @@ -1557,6 +1689,7 @@ fn gutter_field(n: Option, w: usize) -> String { /// rows show only the old-side column, `Add` rows only the new-side column — the other column is /// blank rather than reused for anything, so a scan down the gutter reads as two honest, /// independent line-number tracks. +#[allow(clippy::too_many_arguments)] fn build_inline_line( view: &FileView, row: &InlineRow, @@ -1565,6 +1698,7 @@ fn build_inline_line( old_gutter_w: usize, new_gutter_w: usize, theme: &Palette, + hscroll: usize, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1615,6 +1749,7 @@ fn build_inline_line( word_spans, is_word_pair, theme, + hscroll, )); Line::from(spans) } @@ -1634,6 +1769,10 @@ fn render_pane_inline( selection: Option<(usize, usize)>, theme: &Palette, ) { + // One offset shared by every content pane (locked decision #1) — read once, before any of + // the `app` borrows below. + let hscroll = app.hscroll; + let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); return; @@ -1695,6 +1834,7 @@ fn render_pane_inline( old_gutter_w, new_gutter_w, theme, + hscroll, ); // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { @@ -1705,6 +1845,9 @@ fn render_pane_inline( line }; frame.buffer_mut().set_line(area.x, y, &line, area.width); + // Right-edge hscroll marker (decision #7) — see `render_pane_sbs`'s identical + // comment on ordering relative to the cursor/selection wash above. + apply_right_edge_marker(frame.buffer_mut(), area, y, &line, theme); } } } @@ -1717,8 +1860,9 @@ mod tests { use ratatui::Terminal; use git_workon_fixture::prelude::*; + use unicode_width::UnicodeWidthChar; - use super::render; + use super::{hscroll_cut, render}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -2806,6 +2950,157 @@ mod tests { ); } + // ── diff-hscroll ───────────────────────────────────────────────────────────── + + #[test] + fn hscroll_cut_ascii() { + // "hello world" — cutting at column 6 lands right after the space, before "world". + assert_eq!(hscroll_cut("hello world", 6), (6, false)); + assert_eq!(hscroll_cut("hello world", 0), (0, false)); + } + + #[test] + fn hscroll_cut_multibyte_narrow() { + // "café" — 'é' is a single (narrow, non-ASCII) column, so cutting at column 3 lands + // exactly at its 2-byte UTF-8 start. + let text = "café"; + assert_eq!(UnicodeWidthChar::width('é'), Some(1)); + let (cut, pad) = hscroll_cut(text, 3); + assert_eq!(&text[cut..], "é"); + assert!(!pad); + } + + #[test] + fn hscroll_cut_wide_cjk_straddling_the_cut_skips_it_and_pads() { + // "a漢b" — 'a' (col 0), '漢' (cols 1-2, a wide CJK glyph), 'b' (col 3). Cutting at column + // 2 lands mid-glyph: the whole wide char is dropped and `pad` signals the caller to + // insert a one-column space to keep the remaining columns aligned. + let text = "a漢b"; + assert_eq!(UnicodeWidthChar::width('漢'), Some(2)); + let (cut, pad) = hscroll_cut(text, 2); + assert!( + pad, + "a wide char straddling the cut must request a pad column" + ); + assert_eq!(&text[cut..], "b"); + } + + #[test] + fn hscroll_cut_emoji() { + // Most terminal-emulator-relevant emoji are wide (2 columns), like CJK. + let text = "a🎉b"; + let w = UnicodeWidthChar::width('🎉').unwrap_or(0); + let (cut, _pad) = hscroll_cut(text, 1 + w); + assert_eq!(&text[cut..], "b"); + } + + #[test] + fn hscroll_cut_beyond_line_width_yields_empty() { + let (cut, pad) = hscroll_cut("short", 100); + assert_eq!(cut, "short".len()); + assert!(!pad); + assert_eq!(&"short"[cut..], ""); + } + + /// Build a single unstaged-file `App` with one long line, for the hscroll rendering tests — + /// long enough that panning by [`crate::app::HSCROLL_STEP`]-sized steps has real room to move + /// (the tests don't reference that constant directly since it's private to `app.rs`; `200` + /// just needs to comfortably exceed a test pane's width either way). + fn app_with_a_long_line() -> App { + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("long.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app + } + + #[test] + fn panning_right_shows_the_left_edge_marker_and_shifted_content() { + let mut app = app_with_a_long_line(); + app.hscroll_right(); + assert!( + app.hscroll > 0, + "the long line must give hscroll room to pan" + ); + + let buf = render_once(&mut app, 60, 20); + // divider (1) + new-side gutter ("{n:>3} ", 4 chars) — the new pane's first content + // column. + let left_w = buf.area.width.saturating_sub(1) / 2; + let content_x = left_w + 1 + 4; + let row_y = (0..buf.area.height) + .find(|&y| cell_text(&buf, content_x, y) == "…") + .expect("the panned long line's first visible content column must show the marker"); + assert_eq!( + cell_text(&buf, content_x + 1, row_y), + "x", + "content immediately after the marker must be the (shifted) line body" + ); + } + + #[test] + fn a_line_wider_than_the_pane_shows_the_right_edge_marker() { + let mut app = app_with_a_long_line(); + // At `hscroll == 0` the long line already overflows a narrow pane's content width. + assert_eq!(app.hscroll, 0); + + let buf = render_once(&mut app, 60, 20); + let right_x = buf.area.width - 1; + assert!( + (0..buf.area.height).any(|y| cell_text(&buf, right_x, y) == "…"), + "a line wider than the pane must show the right-edge marker" + ); + } + + #[test] + fn winbar_shows_the_pan_offset_indicator_once_panned() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + assert_eq!(app.hscroll, 0); + + let buf_unpanned = render_once(&mut app, 80, 20); + let header_unpanned: String = (0..buf_unpanned.area.width) + .map(|x| cell_text(&buf_unpanned, x, 0)) + .collect(); + assert!( + !header_unpanned.contains('»'), + "no indicator at column 0, got: {header_unpanned:?}" + ); + + // The winbar test's fixture files are tiny (`a\n`/`b\n`) — nowhere near wide enough for + // `hscroll_right` to actually move `hscroll` off `0`. This checks the indicator's own + // render logic, not the pan mechanics (covered separately in `app.rs`), so setting the + // field directly is the more honest test: the indicator must key off `App::hscroll` + // exactly, with no dependency on how it got there. + app.hscroll = 42; + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains("»42"), + "expected the pan offset indicator, got: {header:?}" + ); + } + + #[test] + fn single_changeset_header_shows_the_pan_offset_indicator_once_panned() { + let mut app = app_with_a_long_line(); + app.hscroll_right(); + + let buf = render_once(&mut app, 80, 20); + let header: String = (0..buf.area.width).map(|x| cell_text(&buf, x, 0)).collect(); + assert!( + header.contains(&format!("»{}", app.hscroll)), + "expected the pan offset indicator on the lone-changeset header, got: {header:?}" + ); + } + // ── M5 CS3: outline side pane ─────────────────────────────────────────────── /// Every outline test renders at this width so the pane's fixed 35-col + 1-col-divider diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index b8f15ec6..611a5ad8 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -444,6 +444,8 @@ enum Action { StartSelection, ExpandGap, ExpandGapAll, + HscrollLeft, + HscrollRight, ToggleOutline, OutlineMoveBy(i64), OutlineConfirm, @@ -484,6 +486,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::StartSelection => Action::StartSelection, Command::ExpandGap => Action::ExpandGap, Command::ExpandGapAll => Action::ExpandGapAll, + Command::HscrollLeft => Action::HscrollLeft, + Command::HscrollRight => Action::HscrollRight, Command::NextFile => Action::NextFile, Command::PrevFile => Action::PrevFile, Command::NextHunk => Action::NextHunk, @@ -611,11 +615,25 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::StartSelection => app.start_selection(), Action::ExpandGap => app.expand_gap_at_cursor(false), Action::ExpandGapAll => app.expand_gap_at_cursor(true), + Action::HscrollLeft => app.hscroll_left(), + Action::HscrollRight => app.hscroll_right(), Action::ToggleOutline => app.toggle_outline(), Action::OutlineMoveBy(delta) => app.outline_move_by(delta), Action::OutlineConfirm => app.outline_confirm(), Action::OutlineCycleMode => app.outline_cycle_mode(), - Action::FocusOutline => app.focus_outline(), + // `h`/`left` pans the diff back to column 0 first (mirroring the outline's own home + // position) and only actually focuses the outline once there — see the handoff's locked + // decision #2. Implemented here rather than in `App::focus_outline` itself, since that + // method is also called from the outline toggle (`App::toggle_outline`) and the mouse + // click/wheel paths (`App::handle_click`/`handle_wheel`), none of which should gain pan + // behavior. + Action::FocusOutline => { + if app.hscroll > 0 { + app.hscroll_left(); + } else { + app.focus_outline(); + } + } Action::FocusDiff => app.focus_diff(), Action::OutlineTop => app.outline_top(), Action::OutlineBottom => app.outline_bottom(), @@ -3235,4 +3253,46 @@ mod tests { "the active file's view must be cached after its FileReady lands" ); } + + // ── diff-hscroll: `Action::FocusOutline` pans home before focusing ───────────── + + /// Locked decision #2: `h`/`left` (`Action::FocusOutline`) pans the diff back toward column + /// `0` first while panned, and only actually focuses the outline once there — implemented in + /// this dispatch arm rather than in `App::focus_outline` itself (see that arm's comment), so + /// this is only testable at the `apply_action` layer, not through `App` alone. + #[test] + fn focus_outline_action_pans_home_before_focusing_when_panned() { + use git_workon_fixture::prelude::*; + + let long_line = "x".repeat(200); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "short\n", &format!("{long_line}\n")) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.hscroll_right(); + assert!( + app.hscroll > 0, + "the long line must give hscroll room to pan" + ); + assert!(!app.outline_focused()); + + // Panned: the first press pans back toward column 0 rather than focusing the outline. + apply_action(&mut app, Action::FocusOutline); + assert_eq!( + app.hscroll, 0, + "one press from a single hscroll step returns to column 0" + ); + assert!( + !app.outline_focused(), + "still unfocused — this press only panned" + ); + + // Already at column 0: the next press focuses the outline as normal. + apply_action(&mut app, Action::FocusOutline); + assert!(app.outline_focused()); + } } From 8af07110cf49c5d9c492171044cec931837e3b51 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Sun, 12 Jul 2026 13:20:31 -0400 Subject: [PATCH 11/19] feat(review): mouse h-wheel panning and outline hscroll --- git-workon-review/src/app.rs | 241 +++++++++++++++++++++++++++- git-workon-review/src/keymap.rs | 16 ++ git-workon-review/src/render.rs | 268 ++++++++++++++++++++++++++------ git-workon-review/src/tui.rs | 83 +++++++++- 4 files changed, 556 insertions(+), 52 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 23fe9c61..c274d305 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -805,6 +805,14 @@ pub struct OutlineState { /// Which end of the stack the stack-shaped modes display first — `workon.review.outline.order` /// (CS3), defaulting to [`OutlineOrder::HeadFirst`]. Read by [`App::outline_items`]. pub order: OutlineOrder, + /// Column pan offset (display columns) for the outline pane — the outline's own analog of + /// [`App::hscroll`], since a long path is hard-clipped at the outline's fixed width just like + /// a long diff line. Floored at `0` by [`App::outline_hscroll_left`]/ + /// [`App::outline_hscroll_right`]; the upper clamp is render-side (`render_outline`, mirroring + /// [`App::clamp_outline_scroll`]'s own per-frame bounds-clamp under the wheel peek model), not + /// here. Reset to `0` by [`App::outline_cycle_mode`] — the row list (and therefore the set of + /// paths on screen) changes shape there, the same reason that resyncs the cursor. + pub hscroll: usize, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1369,6 +1377,7 @@ impl App { width: DEFAULT_OUTLINE_WIDTH, scroll: 0, order: OutlineOrder::default(), + hscroll: 0, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2571,6 +2580,14 @@ impl App { self.outline.scroll } + /// The outline pane's column pan offset — see [`OutlineState::hscroll`]'s doc comment. Read + /// by `render.rs`'s `render_outline`, which also owns the render-side upper clamp (mirroring + /// [`Self::clamp_outline_scroll`]'s own per-frame bounds-clamp) via + /// [`Self::clamp_outline_hscroll`]. + pub fn outline_hscroll(&self) -> usize { + self.outline.hscroll + } + /// The outline pane's column width — `workon.review.outline.width` (CS7), or /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed /// const. @@ -2740,6 +2757,49 @@ impl App { } } + /// Horizontal mouse wheel (trackpad h-scroll, or a shift-wheel the terminal reports as + /// `ScrollLeft`/`ScrollRight`) at terminal `(col, row)` with `delta` = ±4 columns per tick + /// (`tui::map_key`'s caller maps `ScrollLeft`/`ScrollRight` to -4/+4 — finer than + /// [`HSCROLL_STEP`] since trackpads emit streams of ticks). Same peek-model framing and + /// region-focus rule as [`Self::handle_wheel`] — the difference is WHAT gets panned: unlike + /// the vertical wheel (which always scrolls whichever pane's own row-list viewport), this + /// pans a COLUMN offset shared per PANE KIND — the outline's own `outline.hscroll` over the + /// outline, or the diff panes' shared [`Self::hscroll`] over a diff pane (both halves of a + /// split share the one offset, same as [`Self::hscroll_left`]/[`Self::hscroll_right`]). + /// Outside every recorded region: no-op. + pub fn handle_hwheel(&mut self, col: u16, row: u16, delta: i64) { + let Some((pane, _region)) = self.hit_test(col, row) else { + return; + }; + match pane { + HitPane::Outline => { + self.focus_outline(); + self.outline.hscroll = (self.outline.hscroll as i64 + delta).max(0) as usize; + // No upper clamp here — render-side, mirroring `outline_hscroll_right`'s own + // doc comment. + } + HitPane::Single => { + self.focus_diff_pane(None); + self.pan_hscroll_by(delta); + } + HitPane::Split(target) => { + self.focus_diff_pane(Some(target)); + self.pan_hscroll_by(delta); + } + } + } + + /// Pan the shared diff [`Self::hscroll`] by `delta` columns (floored at `0`), clamping + /// against the current view's longest row on a RIGHTWARD pan only — the same clamp + /// [`Self::hscroll_right`] applies, factored out here so [`Self::handle_hwheel`] doesn't + /// clamp a leftward pan against a bound that only matters when panning right. + fn pan_hscroll_by(&mut self, delta: i64) { + self.hscroll = (self.hscroll as i64 + delta).max(0) as usize; + if delta > 0 { + self.clamp_hscroll(); + } + } + /// Scroll the focused pane's viewport by `delta` rows (mouse wheel), clamped to the row /// list. The cursor is deliberately NOT touched (the peek model: a wheel is "look /// elsewhere", never "select elsewhere"), so it can sit outside the viewport — the next @@ -2778,9 +2838,12 @@ impl App { /// `i` while the outline has focus: cycle [`OutlineMode`], then reposition the cursor onto /// the row matching the current diff position in the NEW mode's row list (the row layout - /// just changed shape, so the raw index would otherwise point at an unrelated row). + /// just changed shape, so the raw index would otherwise point at an unrelated row). Also + /// resets [`OutlineState::hscroll`] to `0` — the row list's shape (and therefore its longest + /// path) just changed too, so a stale pan offset could easily land past the new mode's content. pub fn outline_cycle_mode(&mut self) { self.outline.mode = self.outline.mode.cycle(); + self.outline.hscroll = 0; self.sync_outline_to_current(); } @@ -3285,6 +3348,17 @@ impl App { .min(rows.saturating_sub(self.outline_height.max(1))); } + /// Render-side upper clamp for [`OutlineState::hscroll`] — the outline analog of + /// [`Self::clamp_hscroll`], but taken from the caller rather than computed here: + /// `render_outline` already builds every item's line to paint it, so it's cheaper for it to + /// pass the max width it just measured than for this method to rebuild the whole outline a + /// second time. `max_line_width` is the widest rendered outline row's display-column width; + /// the `-1` keeps at least one column of the longest row visible, same as + /// [`Self::clamp_hscroll`]. + pub(crate) fn clamp_outline_hscroll(&mut self, max_line_width: usize) { + self.outline.hscroll = self.outline.hscroll.min(max_line_width.saturating_sub(1)); + } + /// The widest display-column row currently in the active file's view(s) — both roles when /// split, since [`Self::hscroll`] pans every content pane together (locked decision #1). /// Walks the already-built [`FileView::display`] row list (shared by both the SBS and inline @@ -3338,6 +3412,22 @@ impl App { self.clamp_hscroll(); } + /// `outline-hscroll-left`: pan the outline pane left by [`HSCROLL_STEP`] columns (floored at + /// `0`) — the outline's own analog of [`Self::hscroll_left`]. + pub fn outline_hscroll_left(&mut self) { + self.outline.hscroll = self.outline.hscroll.saturating_sub(HSCROLL_STEP); + } + + /// `outline-hscroll-right`: pan the outline pane right by [`HSCROLL_STEP`] columns. Unlike + /// [`Self::hscroll_right`] this has NO upper clamp here — the outline's row list (every + /// item's rendered line, built by `render.rs`'s `build_outline_line`) isn't cheaply available + /// to `App` the way a [`FileView`]'s rows are, so the clamp is render-side instead + /// (`render::render_outline`, mirroring how [`Self::clamp_outline_scroll`] already + /// bounds-clamps `outline.scroll` once per frame under the wheel peek model). + pub fn outline_hscroll_right(&mut self) { + self.outline.hscroll = self.outline.hscroll.saturating_add(HSCROLL_STEP); + } + /// Re-derive the outline pane's `scroll` from its `cursor` — the outline's counterpart to /// [`Self::derive_scroll`], reusing the same [`derive_scroll_value`] core against /// [`Self::outline_height`]. Called after every outline-cursor mutation (mirroring how every @@ -4666,7 +4756,7 @@ mod tests { use super::{ build_file_views, find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, DiffState, EffectiveZoom, HitRegions, Layout, LoadedViews, Region, Role, Severity, Summary, - SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, SCROLLOFF, + SummaryTarget, Zoom, DEFAULT_OUTLINE_WIDTH, HSCROLL_STEP, SCROLLOFF, }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; use crate::config::ReviewConfig; @@ -8489,6 +8579,38 @@ mod tests { ); } + #[test] + fn outline_cycle_mode_resets_outline_hscroll() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.hscroll = 5; + app.outline_cycle_mode(); + assert_eq!( + app.outline_hscroll(), + 0, + "a mode cycle reshapes the row list, so a stale pan offset must reset" + ); + } + + #[test] + fn outline_hscroll_left_floors_at_zero() { + let mut app = two_committed_changesets_two_and_one_files(); + assert_eq!(app.outline_hscroll(), 0); + app.outline_hscroll_left(); + assert_eq!(app.outline_hscroll(), 0, "cannot pan left of column 0"); + } + + #[test] + fn outline_hscroll_right_has_no_upper_clamp_in_the_method_itself() { + // Locked decision #2: `outline_hscroll_right` floors at 0 but does NOT clamp against the + // outline's content width — that clamp is render-side (`render_outline`), covered in + // `render.rs`'s tests. + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_hscroll_right(); + assert_eq!(app.outline_hscroll(), HSCROLL_STEP); + app.outline_hscroll_right(); + assert_eq!(app.outline_hscroll(), HSCROLL_STEP * 2); + } + #[test] fn stack_mode_outline_items_carry_current_and_restack_markers() { let fixture = FixtureBuilder::new() @@ -10680,6 +10802,121 @@ mod tests { ); } + // ── mouse h-wheel + outline hscroll follow-up ───────────────────────────────── + + #[test] + fn handle_hwheel_over_the_outline_pans_outline_hscroll_not_diff() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; + app.derive_outline_scroll(app.outline_items().len()); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + assert_eq!(app.outline_hscroll(), 0); + assert_eq!(app.hscroll, 0); + + app.handle_hwheel(5, 2, 4); + + assert!( + app.outline_focused(), + "an h-wheel event over the outline focuses it, like the vertical wheel" + ); + assert_eq!( + app.outline_hscroll(), + 4, + "the outline's own pan offset must move" + ); + assert_eq!(app.hscroll, 0, "the diff's shared pan offset must not move"); + } + + #[test] + fn handle_hwheel_over_the_diff_pane_pans_app_hscroll_not_outline() { + // "l1".."l40" — the widest rows ("l10".."l40") are 3 columns, so the clamp + // (`max_row_width - 1`) is 2. + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + assert_eq!(app.hscroll, 0); + + app.handle_hwheel(10, 3, 4); + + assert_eq!( + app.hscroll, 2, + "the diff's shared pan offset moves, clamped like `hscroll_right`" + ); + assert_eq!( + app.outline_hscroll(), + 0, + "the outline's own pan offset must not move" + ); + } + + #[test] + fn handle_hwheel_floors_at_zero() { + let mut app = four_committed_changesets_three_files_each(); + app.outline_height = 5; + app.derive_outline_scroll(app.outline_items().len()); + app.hit_regions.outline = Some(Region { + x: 0, + y: 0, + w: 20, + h: 5, + }); + + app.handle_hwheel(5, 2, -4); + + assert_eq!(app.outline_hscroll(), 0, "cannot pan left of column 0"); + } + + #[test] + fn handle_hwheel_outside_every_region_is_a_no_op() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline_height = 10; + app.pane_height = 10; + app.hit_regions = HitRegions { + outline: Some(Region { + x: 0, + y: 0, + w: 20, + h: 10, + }), + single: Some(Region { + x: 21, + y: 0, + w: 40, + h: 10, + }), + unstaged: None, + staged: None, + }; + let outline_focused_before = app.outline_focused(); + let hscroll_before = app.hscroll; + let outline_hscroll_before = app.outline_hscroll(); + + // On the divider, outside both recorded regions — same column CS10's click no-op test + // uses. + app.handle_hwheel(20, 0, 4); + + assert_eq!(app.outline_focused(), outline_focused_before); + assert_eq!(app.hscroll, hscroll_before); + assert_eq!(app.outline_hscroll(), outline_hscroll_before); + } + #[test] fn click_outside_every_hit_region_is_a_no_op() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 46d65130..2844b1fa 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -78,6 +78,8 @@ pub enum Command { OutlineBottom, OutlineStage, OutlineDiscard, + OutlineHscrollLeft, + OutlineHscrollRight, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -369,6 +371,20 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "d", description: "Discard the file/directory under the cursor", }, + Registered { + command: Command::OutlineHscrollLeft, + view: View::Outline, + name: "outline-hscroll-left", + default_keys: "<", + description: "Pan the outline left", + }, + Registered { + command: Command::OutlineHscrollRight, + view: View::Outline, + name: "outline-hscroll-right", + default_keys: ">", + description: "Pan the outline right", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5ca7bd8e..4b5c883d 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -11,7 +11,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{ @@ -403,6 +403,70 @@ fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { (text.len(), false) } +/// Pan an already-built line of styled spans (diff content, or — as of the mouse/outline +/// follow-up — an outline row) `cols` display columns to the left. The shared core +/// [`content_spans`]/`render::render_outline` both build their spans at FULL width first, then +/// apply this — never the other way around — so every existing style/segment computation +/// (word-diff spans, syntax highlight, outline icon/label coloring) stays untouched by hscroll; +/// this function only ever drops or re-slices spans, never recolors one. +/// +/// Walks `spans` in order with a running column budget (`cols`, plus one extra reserved for the +/// left-edge marker below): a per-span [`hscroll_cut`] call consumes as much of that budget as +/// the span's own display width allows, carrying any remainder into the next span — exactly as +/// if `hscroll_cut` had been called once over the whole line's concatenated text, since spans +/// partition that text contiguously and in original order. A wide char straddling the cut is +/// dropped whole (never half-rendered) and compensated with a one-column space pad, same as +/// [`hscroll_cut`]'s own doc comment describes for a single string. Once the budget reaches `0`, +/// every remaining span is pushed through unchanged. +/// +/// When `cols == 0`, or the line has no content at all to cut, `spans` passes through unchanged +/// (no marker, no pad) — matching [`hscroll_cut`]'s own "nothing to show" cases. +fn pan_spans(spans: Vec>, cols: usize, theme: &Palette) -> Vec> { + if cols == 0 { + return spans; + } + if spans.iter().all(|s| s.content.is_empty()) { + return spans; + } + + // Reserve one extra column for the left-edge marker (decision #7's affordance) — mirrors the + // pre-refactor `content_spans`' own "cut at `hscroll`, then one column further for the + // marker" two-step. + let mut skip = cols + 1; + let mut out = Vec::with_capacity(spans.len() + 2); + out.push(TSpan::styled( + HSCROLL_MARKER.to_string(), + Style::default().fg(theme.dim), + )); + + for span in spans { + if skip == 0 { + out.push(span); + continue; + } + let text = span.content.as_ref(); + let (cut, straddled) = hscroll_cut(text, skip); + if straddled || cut < text.len() { + // The remaining budget was fully spent inside this span — everything from `cut` + // onward (possibly nothing) survives, unchanged in style. + skip = 0; + if straddled { + out.push(TSpan::styled( + " ".to_string(), + Style::default().fg(theme.foreground), + )); + } + if cut < text.len() { + out.push(TSpan::styled(text[cut..].to_string(), span.style)); + } + } else { + // The whole span fit inside the remaining budget — drop it and keep consuming. + skip = skip.saturating_sub(UnicodeWidthStr::width(text)); + } + } + out +} + /// Build the styled content spans (everything after the gutter) for one line of text, shared by /// [`build_pane_line`] (SBS) and [`build_inline_line`] (inline) — the two differ only in how they /// resolve `text`/`hl`/`emphasis` from a [`Row`] vs an [`InlineRow`] and in their gutter, not in @@ -412,13 +476,9 @@ fn hscroll_cut(text: &str, col: usize) -> (usize, bool) { /// plus per-`word_spans` strong background when `is_word_pair`; whole-line strong when not paired /// — an unpaired excess line) and `None` for `Context`/`Filler` (no background emphasis at all). /// -/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans: segments are composed -/// over the FULL, unsliced `text` exactly as before (every span offset below stays byte-based), -/// then trimmed to start at `hscroll`'s cut point (decision #6) — dropping a segment entirely if -/// it ends at or before the cut, else re-slicing its tail. When the cut actually removed content -/// (`hscroll > 0` and something preceded it), the first visible column renders [`HSCROLL_MARKER`] -/// instead (decision #7's left-edge affordance) — the real cut point in that case is one column -/// further right, to make room for the marker. +/// `hscroll` (display columns, [`App::hscroll`]) pans the returned spans via [`pan_spans`] — the +/// segments below are always composed over the FULL, unsliced `text` first (byte-identical to the +/// pre-hscroll behavior), and [`pan_spans`] applies the cut/pad/marker afterward. #[allow(clippy::too_many_arguments)] fn content_spans( text: &str, @@ -443,53 +503,21 @@ fn content_spans( } let segments = compose_segments(text.len(), &bg_spans, hl, theme); - - // Nothing panned off yet: the common case, byte-identical to pre-hscroll behavior. - let (cut, pad, marker) = if hscroll == 0 { - (0, false, false) - } else { - let (base_cut, _) = hscroll_cut(text, hscroll); - if base_cut > 0 { - // Something was actually cut — reserve column `hscroll` for the marker by cutting - // one column further in. - let (marker_cut, pad) = hscroll_cut(text, hscroll + 1); - (marker_cut, pad, true) - } else { - (0, false, false) - } - }; - - let mut spans = Vec::with_capacity(segments.len().max(1) + 2); - if marker { + let mut spans = Vec::with_capacity(segments.len().max(1)); + if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( - HSCROLL_MARKER.to_string(), - Style::default().fg(theme.dim), - )); - } - if pad { - spans.push(TSpan::styled( - " ".to_string(), - Style::default().fg(theme.foreground), - )); - } - if segments.is_empty() && !text.is_empty() && cut < text.len() { - spans.push(TSpan::styled( - text[cut..].to_string(), + text.to_string(), Style::default().fg(theme.foreground), )); } for seg in segments { - if seg.end <= cut { - continue; - } - let start = seg.start.max(cut); let mut style = Style::default().fg(seg.fg); if let Some(bg) = seg.bg { style = style.bg(bg); } - spans.push(TSpan::styled(text[start..seg.end].to_string(), style)); + spans.push(TSpan::styled(text[seg.start..seg.end].to_string(), style)); } - spans + pan_spans(spans, hscroll, theme) } /// Build a single rendered line for one pane at a display row's resolved [`Row`]/[`CellKind`]. @@ -712,6 +740,17 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let scroll = app.outline_scroll(); let icons = app.icon_mode(); + // Render-side upper clamp of the outline's own pan offset (mirroring `clamp_outline_scroll` + // just above) — from EVERY item's built line width, not just the visible rows: outlines are + // small (file trees, not file contents), so re-measuring the whole thing here is cheap. + let max_line_width = items + .iter() + .map(|item| build_outline_line(item, theme, icons).width()) + .max() + .unwrap_or(0); + app.clamp_outline_hscroll(max_line_width); + let hscroll = app.outline_hscroll(); + let buf = frame.buffer_mut(); for row in 0..area.height { let item_idx = scroll + row as usize; @@ -721,6 +760,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) }; let is_cursor = item_idx == cursor; let line = build_outline_line(item, theme, icons); + let line = Line::from(pan_spans(line.spans, hscroll, theme)); let line = if is_cursor && focused { apply_cursor_row(line, area.width, theme) } else if is_cursor { @@ -729,6 +769,7 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) line }; buf.set_line(area.x, y, &line, area.width); + apply_right_edge_marker(buf, area, y, &line, theme); } } @@ -1857,12 +1898,14 @@ fn render_pane_inline( mod tests { use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; + use ratatui::style::Style; + use ratatui::text::Span as TSpan; use ratatui::Terminal; use git_workon_fixture::prelude::*; use unicode_width::UnicodeWidthChar; - use super::{hscroll_cut, render}; + use super::{hscroll_cut, pan_spans, render}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -3002,6 +3045,77 @@ mod tests { assert_eq!(&"short"[cut..], ""); } + // ── mouse h-wheel + outline hscroll follow-up: `pan_spans` ───────────────────── + + fn spans_text(spans: &[TSpan<'static>]) -> String { + spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn span(text: &str) -> TSpan<'static> { + TSpan::styled(text.to_string(), Style::default()) + } + + #[test] + fn pan_spans_at_zero_columns_is_a_pass_through() { + let theme = Palette::dark(); + let spans = vec![span("hello "), span("world")]; + let panned = pan_spans(spans.clone(), 0, &theme); + assert_eq!(spans_text(&panned), "hello world"); + assert_eq!(panned.len(), spans.len(), "unchanged, span for span"); + } + + #[test] + fn pan_spans_cuts_mid_span() { + // "hello world" panned 3 columns — the cut (plus the marker's reserved column) lands + // inside the FIRST span ("hello "), leaving its tail attached to the second span. + let theme = Palette::dark(); + let spans = vec![span("hello "), span("world")]; + let panned = pan_spans(spans, 3, &theme); + assert_eq!(spans_text(&panned), "…o world"); + } + + #[test] + fn pan_spans_cuts_exactly_at_a_span_boundary() { + // "abcdef" as three 2-char spans, panned 2 columns — the cut (plus the marker's reserved + // column) lands exactly on the boundary between the first and second span. + let theme = Palette::dark(); + let spans = vec![span("ab"), span("cd"), span("ef")]; + let panned = pan_spans(spans, 2, &theme); + assert_eq!(spans_text(&panned), "…def"); + } + + #[test] + fn pan_spans_wide_char_straddling_a_span_edge_drops_and_pads() { + // "a漢b" as two spans ("a", "漢b"), panned 1 column — the cut (plus the marker's reserved + // column) straddles the wide CJK glyph at the start of the second span: it's dropped + // whole and compensated with a one-column space. + let theme = Palette::dark(); + assert_eq!(UnicodeWidthChar::width('漢'), Some(2)); + let spans = vec![span("a"), span("漢b")]; + let panned = pan_spans(spans, 1, &theme); + assert_eq!(spans_text(&panned), "… b"); + } + + #[test] + fn pan_spans_beyond_total_width_yields_just_the_marker() { + let theme = Palette::dark(); + let spans = vec![span("ab"), span("cd")]; + let panned = pan_spans(spans, 100, &theme); + assert_eq!(spans_text(&panned), "…"); + } + + #[test] + fn pan_spans_on_empty_content_is_a_pass_through() { + let theme = Palette::dark(); + let spans = vec![span("")]; + let panned = pan_spans(spans, 5, &theme); + assert_eq!( + spans_text(&panned), + "", + "an empty line has nothing to cut, so no marker either" + ); + } + /// Build a single unstaged-file `App` with one long line, for the hscroll rendering tests — /// long enough that panning by [`crate::app::HSCROLL_STEP`]-sized steps has real room to move /// (the tests don't reference that constant directly since it's private to `app.rs`; `200` @@ -3416,6 +3530,68 @@ mod tests { ); } + // ── mouse h-wheel + outline hscroll follow-up: outline panning ───────────────── + + /// A single-changeset `App` with one file whose path is far wider than the outline's fixed + /// 35-column width, focused into the outline — for the outline hscroll rendering tests. + fn app_with_a_long_outline_path() -> App { + let long_path = format!("{}.txt", "a".repeat(80)); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file(&long_path, "content\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.focus_outline(); // opens (a lone changeset defaults closed) and focuses. + app + } + + #[test] + fn outline_panning_shows_the_left_marker_shifted_text_and_the_right_edge_marker() { + let mut app = app_with_a_long_outline_path(); + app.outline_hscroll_right(); + assert!( + app.outline_hscroll() > 0, + "the long path must give outline hscroll room to pan" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + // The header row (a short label) pans fully off and shows a lone marker, so select the + // PATH row: the one where the marker is followed by the shifted 'a…a.txt' body. + let row = content + .iter() + .position(|r| r.contains('…') && r.contains('a')) + .expect("the panned path row must show the left-edge marker plus shifted content"); + // Column 34 is the outline's last column before the divider at 35 (see + // `OUTLINE_TEST_WIDTH`'s doc comment). + assert_eq!( + cell_text(&buf, 34, row as u16), + "…", + "a row wider than the outline pane must show the right-edge marker too" + ); + } + + #[test] + fn outline_render_side_clamp_caps_a_huge_pan_offset() { + let mut app = app_with_a_long_outline_path(); + for _ in 0..1000 { + app.outline_hscroll_right(); + } + assert!( + app.outline_hscroll() > 1000, + "sanity: `outline_hscroll_right` itself has no upper clamp" + ); + + render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + assert!( + app.outline_hscroll() < 1000, + "render_outline must clamp the huge offset down to the content width, got {}", + app.outline_hscroll() + ); + } + // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 611a5ad8..dfe19718 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -118,10 +118,11 @@ impl PartialEq for AppEvent { type InboxMessage = io::Result; /// Map one crossterm terminal [`Event`] to the [`AppEvent`] the loop reacts to — key-press, -/// resize, and (CS10) a left-click or wheel-scroll map; key release/repeat, every other mouse -/// kind (drag, move, non-left buttons, button-up), paste, and focus events are skipped (`None`). -/// Pure and independent of any thread or channel, so it's unit-tested directly; the input -/// thread's loop body is a thin wrapper around it. +/// resize, and (CS10, extended by the mouse h-wheel follow-up) a left-click or vertical/ +/// horizontal wheel-scroll map; key release/repeat, every other mouse kind (drag, move, non-left +/// buttons, button-up), paste, and focus events are skipped (`None`). Pure and independent of any +/// thread or channel, so it's unit-tested directly; the input thread's loop body is a thin wrapper +/// around it. fn map_terminal_event(event: Event) -> Option { match event { Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)), @@ -132,6 +133,8 @@ fn map_terminal_event(event: Event) -> Option { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight ) => { Some(AppEvent::Mouse(m)) @@ -456,6 +459,8 @@ enum Action { OutlineBottom, OutlineStage, OutlineDiscard, + OutlineHscrollLeft, + OutlineHscrollRight, None, } @@ -504,6 +509,8 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineBottom => Action::OutlineBottom, Command::OutlineStage => Action::OutlineStage, Command::OutlineDiscard => Action::OutlineDiscard, + Command::OutlineHscrollLeft => Action::OutlineHscrollLeft, + Command::OutlineHscrollRight => Action::OutlineHscrollRight, } } @@ -639,6 +646,8 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineBottom => app.outline_bottom(), Action::OutlineStage => app.outline_stage(), Action::OutlineDiscard => app.outline_discard(), + Action::OutlineHscrollLeft => app.outline_hscroll_left(), + Action::OutlineHscrollRight => app.outline_hscroll_right(), Action::None => {} } false @@ -751,6 +760,10 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap MouseEventKind::Down(MouseButton::Left) => app.handle_click(m.column, m.row), MouseEventKind::ScrollDown => app.handle_wheel(m.column, m.row, 3), MouseEventKind::ScrollUp => app.handle_wheel(m.column, m.row, -3), + // 4 columns per tick — finer than `HSCROLL_STEP` since trackpads emit streams of + // ticks (see `App::handle_hwheel`'s doc comment). + MouseEventKind::ScrollRight => app.handle_hwheel(m.column, m.row, 4), + MouseEventKind::ScrollLeft => app.handle_hwheel(m.column, m.row, -4), _ => {} } false @@ -1297,6 +1310,24 @@ mod tests { ); } + /// Mouse h-wheel follow-up: `ScrollLeft`/`ScrollRight` (trackpad h-scroll, or a shift-wheel + /// the terminal reports this way) map through exactly like the vertical `ScrollUp`/ + /// `ScrollDown` pair above. + #[test] + fn map_terminal_event_maps_scroll_left_and_right() { + let scroll_left = mouse(MouseEventKind::ScrollLeft); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_left)), + Some(AppEvent::Mouse(m)) if m == scroll_left + )); + + let scroll_right = mouse(MouseEventKind::ScrollRight); + assert!(matches!( + map_terminal_event(Event::Mouse(scroll_right)), + Some(AppEvent::Mouse(m)) if m == scroll_right + )); + } + /// `AppEvent` dropped `PartialEq`/`Eq` in ADR-037 (`FileReady`'s `LoadedViews` payload wraps /// `FileView`, which has neither) — this test-only helper is the `matches!`-based replacement /// for the `assert_eq!(event, AppEvent::Key(key(...)))` shape used throughout this module's @@ -3295,4 +3326,48 @@ mod tests { apply_action(&mut app, Action::FocusOutline); assert!(app.outline_focused()); } + + /// Mouse h-wheel follow-up: a `ScrollRight` event reaches `App::handle_hwheel` (not the + /// vertical `App::handle_wheel`) when dispatched through the full `update` path — mirroring + /// how the existing vertical-wheel tests exercise `App::handle_wheel` directly, but this one + /// goes through `map_terminal_event` + `update`'s mouse arm to also pin the event mapping. + #[test] + fn scroll_right_event_reaches_handle_hwheel_via_update() { + use git_workon_fixture::prelude::*; + use workon_review::app::Region; + + let lines: String = (1..=40).map(|n| format!("l{n}\n")).collect(); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("big.txt", &lines) + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + app.pane_height = 10; + app.hit_regions.single = Some(Region { + x: 0, + y: 0, + w: 40, + h: 10, + }); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert_eq!(app.hscroll, 0); + + let raw = MouseEvent { + kind: MouseEventKind::ScrollRight, + column: 10, + row: 3, + modifiers: KeyModifiers::NONE, + }; + // Round-trip through the real mapping first, matching how the input thread feeds `update`. + let mapped = map_terminal_event(Event::Mouse(raw)).expect("ScrollRight must map"); + update(&mut app, &km, &mut pending, mapped); + + assert!( + app.hscroll > 0, + "a ScrollRight event over the diff pane must pan App::hscroll via handle_hwheel" + ); + } } From 8766313ab5c8299827465343dde6b665f7db140b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 13:53:41 -0400 Subject: [PATCH 12/19] feat(review): distinguish outline changeset headers with counter and accent --- git-workon-review/src/app.rs | 5 + git-workon-review/src/outline.rs | 16 +++ git-workon-review/src/render.rs | 196 ++++++++++++++++++++++++++++--- git-workon-review/src/theme.rs | 32 +++++ 4 files changed, 232 insertions(+), 17 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c274d305..c0042723 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -8671,6 +8671,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -8686,6 +8687,7 @@ mod tests { header_b, &OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -8781,6 +8783,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-pending".to_string(), current: true, needs_restack: false, @@ -8789,6 +8792,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-failed".to_string(), current: false, needs_restack: false, @@ -8961,6 +8965,7 @@ mod tests { items_after[app.outline_cursor()], OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: false, diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 2403c608..15540bfa 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -193,6 +193,10 @@ pub enum OutlineItem { /// A changeset header — emitted in [`OutlineMode::Stack`]/[`OutlineMode::StackTree`]. Header { cs_idx: usize, + /// Changeset count (CS1, `outline-header-polish`) — paired with `cs_idx` at render time + /// to draw the `[i/n]` counter (`i` = `cs_idx + 1`, base=1). Always `changesets.len()` at + /// build time, so it's the same for every `Header` row a given `build_items` call emits. + n: usize, label: String, current: bool, needs_restack: bool, @@ -274,10 +278,12 @@ pub fn build_items( /// `file_idx` are computed from the ORIGINAL (base -> head) enumeration before any reversal, so /// they stay true indices into `App::changesets` either way. fn build_stack(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, + n, label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, @@ -487,10 +493,12 @@ fn build_tree(changesets: &[OutlineChangeset]) -> Vec { /// changeset's own copy gets its own row" rule). `order` picks which end of the stack paints /// first, same as [`build_stack`]; `cs_idx`/`file_idx` stay true indices regardless. fn build_stack_tree(changesets: &[OutlineChangeset], order: OutlineOrder) -> Vec { + let n = changesets.len(); let mut items = Vec::new(); for (cs_idx, cs) in scan_order(changesets, order) { items.push(OutlineItem::Header { cs_idx, + n, label: cs.label.clone(), current: cs.current, needs_restack: cs.needs_restack, @@ -591,6 +599,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -607,6 +616,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -639,6 +649,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-pending".to_string(), current: false, needs_restack: false, @@ -647,6 +658,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-failed".to_string(), current: false, needs_restack: false, @@ -914,6 +926,7 @@ mod tests { vec![ OutlineItem::Header { cs_idx: 0, + n: 2, label: "cs-a".to_string(), current: false, needs_restack: false, @@ -936,6 +949,7 @@ mod tests { }, OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: true, @@ -971,6 +985,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 2, + n: 3, label: "cs-c".to_string(), current: true, needs_restack: false, @@ -1025,6 +1040,7 @@ mod tests { items[0], OutlineItem::Header { cs_idx: 1, + n: 2, label: "cs-b".to_string(), current: true, needs_restack: false, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 4b5c883d..5d211cbe 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -106,17 +106,22 @@ fn diffstat_prefixes(icons: IconMode) -> (String, String) { } } -/// The shared changeset-title span run — `[current-marker] [branch-icon] label [warn-marker]` — -/// drawn identically by `build_outline_line`'s Header arm and [`changeset_summary_lines`] (whose -/// doc comment promises exactly that sameness); extracting it makes the promise structural -/// instead of hand-synced. Failed/loading markers are NOT included: the two call sites place -/// them differently (trailing spans on the header row vs. a line of their own in the summary). +/// The shared changeset-title span run — `[current-marker] [branch-icon] ([i/n] )label +/// [warn-marker]` — drawn by both `build_outline_line`'s Header arm and +/// [`changeset_summary_lines`]. **The two call sites no longer render identically** (CS1, +/// `outline-header-polish`): `counter` is `Some((cs_idx + 1, n))` for the outline's Header row +/// only, and its presence ALSO switches the label from the plain [`Palette::foreground`] look to +/// [`Palette::heading_fg`] + bold — the summary panel passes `None` and keeps the original +/// foreground-bold label with no counter, matching its pre-CS1 appearance exactly. Failed/loading +/// markers are still NOT included: the two call sites place them differently (trailing spans on +/// the header row vs. a line of their own in the summary). fn changeset_title_spans( label: &str, current: bool, needs_restack: bool, theme: &Palette, icons: IconMode, + counter: Option<(usize, usize)>, ) -> Vec> { let marker = if current { format!("{} ", current_marker(icons)) @@ -130,11 +135,22 @@ fn changeset_title_spans( Style::default().fg(theme.dim), )); } + // The `[i/n]` counter and the accented label are outline-only (`counter.is_some()`) — see + // this fn's doc comment for why the summary panel's `None` call site is unaffected. + let label_fg = if counter.is_some() { + theme.heading_fg + } else { + theme.foreground + }; + if let Some((i, n)) = counter { + spans.push(TSpan::styled( + format!("[{i}/{n}] "), + Style::default().fg(theme.dim), + )); + } spans.push(TSpan::styled( label.to_string(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + Style::default().fg(label_fg).add_modifier(Modifier::BOLD), )); if needs_restack { spans.push(TSpan::styled( @@ -715,8 +731,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect } /// Render the outline side pane's rows into `area`: [`OutlineItem::Header`]s (Stack mode only) -/// carry the changeset's position marker (green ● for `cs.current`) and needs-restack glyph -/// (amber ⚠, [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an +/// carry the changeset's position marker (green ● for `cs.current`), a `[i/n]` TRUE-stack-position +/// counter, an accented ([`Palette::heading_fg`]) bold label (CS1, `outline-header-polish` — see +/// [`changeset_title_spans`]'s doc comment), and needs-restack glyph (amber ⚠, +/// [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -817,14 +835,22 @@ fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { match item { OutlineItem::Header { + cs_idx, + n, label, current, needs_restack, loading, failed, - .. } => { - let mut spans = changeset_title_spans(label, *current, *needs_restack, theme, icons); + let mut spans = changeset_title_spans( + label, + *current, + *needs_restack, + theme, + icons, + Some((cs_idx + 1, *n)), + ); // ADR-037: a Failed changeset's marker wins over Pending's (a slot is never both, // but Failed is the more actionable state to surface if it somehow were). if *failed { @@ -1252,8 +1278,10 @@ fn push_summary_body( } /// Build a [`ChangesetSummary`]'s lines: title line (the same current/needs-restack markers -/// `build_outline_line`'s Header arm draws — structurally shared via [`changeset_title_spans`]), -/// a loading/failed line OR the per-file list + totals line. +/// `build_outline_line`'s Header arm draws, structurally shared via [`changeset_title_spans`] — +/// but passing `None` for that fn's `counter` param, so this title keeps its pre-CS1 plain- +/// foreground look with no `[i/n]` counter; see [`changeset_title_spans`]'s doc comment), a +/// loading/failed line OR the per-file list + totals line. fn changeset_summary_lines( summary: &ChangesetSummary, height: usize, @@ -1268,6 +1296,7 @@ fn changeset_summary_lines( summary.needs_restack, theme, icons, + None, ))); if summary.failed { @@ -3330,6 +3359,135 @@ mod tests { ); } + #[test] + fn outline_header_shows_true_position_counter_regardless_of_display_order() { + // CS1 (`outline-header-polish`): the `[i/n]` counter is the TRUE stack position + // (`cs_idx + 1`), never a display-order index — HeadFirst (the default) paints cs-b + // (true index 1) before cs-a (true index 0), so the counter must read `[2/2]` on cs-b's + // row and `[1/2]` on cs-a's, in that display order, not `[1/2]` then `[2/2]`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + // Skip y=0: the full-width winbar also renders a `[i/n] ` fragment for the + // CURRENT changeset (cs-b) — an unskipped search for "[2/2]" would false-positive on it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_b = content + .iter() + .position(|r| r.contains("[2/2]")) + .expect("cs-b (true index 1) must show counter [2/2]"); + let row_a = content + .iter() + .position(|r| r.contains("[1/2]")) + .expect("cs-a (true index 0) must show counter [1/2]"); + assert!( + row_b < row_a, + "HeadFirst shows cs-b's header before cs-a's, but the counter stays the true stack \ + position, not a display-order index — got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_header_label_carries_the_heading_accent_color() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + + // Skip y=0: the full-width winbar ALSO names cs-b (it's `current`) via its own + // `[i/n] ` fragment — in plain foreground, not the outline's heading + // accent — so an unskipped search for "cs-b" would false-positive onto the winbar's own + // label instead of the outline header row this test means to inspect. `content`'s index + // `i` is buffer row `i + 1` (the skip), so every `buf` query below adds 1 back. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .position(|r| r.contains("cs-b")) + .expect("cs-b's header row present (it has no title, so falls back to its name)"); + // `String::find` returns a BYTE offset, not a display column — the row has multi-byte + // glyphs (`●`/`⚠`) ahead of/around the label, so a byte offset would target the wrong + // cell. Every rendered cell here is exactly one column wide, so a `chars()` (not byte) + // position IS the display column. + let label_chars: Vec = "cs-b".chars().collect(); + let row_chars: Vec = content[row].chars().collect(); + let label_x = row_chars + .windows(label_chars.len()) + .position(|w| w == label_chars.as_slice()) + .expect("cs-b's label text present in its own header row") as u16; + assert_eq!( + buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, + Some(Palette::dark().heading_fg), + "expected the outline header's label to carry Palette::dark().heading_fg" + ); + } + + #[test] + fn summary_panel_title_has_no_counter_and_keeps_the_plain_foreground_look() { + // CS1's Gotcha: the counter + accent are outline-only — the summary panel's title (shared + // via `changeset_title_spans`, `counter: None`) must render exactly as it did pre-CS1. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.toggle_outline(); + app.toggle_outline(); + let header_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's header row present in Stack mode") as i64; + let delta = header_idx - app.outline_cursor() as i64; + app.outline_move_by(delta); + app.focus_outline(); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: the full-width winbar spans every column (including the body's 36.. slice), + // and it too names the current changeset (cs-b) — same false-positive risk as the outline + // tests above. `body_rows`' index `i` is buffer row `i + 1` (the skip), so every `buf` + // query below adds 1 back. + let body_rows: Vec = (1..buf.area.height) + .map(|y| { + (36..buf.area.width) + .map(|x| cell_text(&buf, x, y)) + .collect::() + }) + .collect(); + let joined = body_rows.join("\n"); + assert!( + !joined.contains("[2/2]") && !joined.contains("[1/2]"), + "the outline-only counter must not leak into the summary panel's title, got:\n{joined}" + ); + let row = body_rows + .iter() + .position(|r| r.contains("cs-b")) + .expect("summary panel's title (cs-b's label) present"); + // `String::find` is a BYTE offset, not a display column (the title carries a multi-byte + // `●` marker ahead of the label, since cs-b is `current`) — a `chars()` position over the + // 36.. slice IS the column offset within that slice (every cell here is one column wide), + // so add the slice's own start column (36) back to get the absolute buffer column. + let label_chars: Vec = "cs-b".chars().collect(); + let row_chars: Vec = body_rows[row].chars().collect(); + let label_x = row_chars + .windows(label_chars.len()) + .position(|w| w == label_chars.as_slice()) + .expect("cs-b's label text present in the summary panel's title") + as u16 + + 36; + assert_eq!( + buf.cell((label_x, row as u16 + 1)).unwrap().style().fg, + Some(Palette::dark().foreground), + "the summary panel's title must keep its plain foreground look, not the outline's \ + heading accent" + ); + } + #[test] fn render_preserves_a_wheel_scrolled_outline_viewport() { // The peek model's load-bearing render change: `render_outline` bounds-CLAMPS the @@ -3558,11 +3716,15 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // The header row (a short label) pans fully off and shows a lone marker, so select the - // PATH row: the one where the marker is followed by the shifted 'a…a.txt' body. + // The header row's own label is short, but CS1's `[i/n] ` counter widens it enough that a + // single hscroll step no longer pans it fully off — it can now ALSO show a lone marker + // plus a stray `a` (from a branch name like `main`), so a bare "contains 'a'" check no + // longer picks out the PATH row unambiguously. Look for a run of the synthetic path's + // repeated `a`s instead (`app_with_a_long_outline_path`'s path is 80 `a`s + `.txt`) — no + // header label plausibly contains four `a`s in a row. let row = content .iter() - .position(|r| r.contains('…') && r.contains('a')) + .position(|r| r.contains('…') && r.contains("aaaa")) .expect("the panned path row must show the left-edge marker plus shifted content"); // Column 34 is the outline's last column before the divider at 35 (see // `OUTLINE_TEST_WIDTH`'s doc comment). diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index a4b0d4a0..be012fdc 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -26,6 +26,11 @@ //! precedent as its diff/cursor tints); `light()` takes `ONE_LIGHT`'s base08/base0A/base0B; //! `from_terminal()` takes the probed scheme's base08/base0A/base0B directly (matching the syntax //! slots' reasoning, not the curated-tint-borrowing the diff/cursor washes use). +//! +//! **CS1 addition (`outline-header-polish`):** [`Palette::heading_fg`] (base0C, cyan) is a fourth +//! semantic-chrome field, same reasoning and same three-scheme mapping as the CS2 trio above — +//! it's the outline's changeset-header-row accent, used only there (see +//! `render::changeset_title_spans`'s doc comment for the outline-only gating). use ratatui::style::Color; @@ -224,6 +229,12 @@ pub struct Palette { /// "current" reads unambiguously at a glance. Promoted from `render.rs`'s `FG_CURRENT` const /// (CS2). pub current_fg: Color, + /// Accent tone for a changeset header row's label (CS1, `outline-header-polish`) — a cyan + /// (base0C), distinct from [`Palette::current_fg`]'s green so "this is a section heading" + /// reads independently of "this is the current changeset." Used ONLY by the outline's Header + /// rows (`render::changeset_title_spans`'s `counter` param gates it) — the summary panel's + /// changeset title keeps the plain [`Palette::foreground`] look. + pub heading_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -264,6 +275,9 @@ impl Palette { error_fg: Color::Rgb(220, 60, 60), warn_fg: Color::Rgb(214, 158, 46), current_fg: Color::Rgb(96, 200, 128), + // CS1: brand new (no historical `render.rs` const to reproduce), so this takes the + // scheme's base0C directly rather than an authored literal. + heading_fg: base.slot(12), paint_canvas: true, } } @@ -316,6 +330,7 @@ impl Palette { error_fg: red, warn_fg: base.slot(10), // base0A current_fg: green, + heading_fg: cyan, paint_canvas: true, } } @@ -359,6 +374,7 @@ impl Palette { error_fg: base.slot(8), warn_fg: base.slot(10), current_fg: base.slot(11), + heading_fg: base.slot(12), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -441,6 +457,15 @@ mod tests { assert_eq!(t.current_fg, Color::Rgb(96, 200, 128)); } + #[test] + fn dark_heading_fg_takes_the_eighties_dark_cyan_accent() { + // CS1: no historical constant to reproduce (this field is new) — unlike + // `dark_semantic_fg_matches_the_historical_render_rs_constants` above, it takes base0C + // straight from the scheme. + let t = Palette::dark(); + assert_eq!(t.heading_fg, Color::Rgb(0x66, 0xcc, 0xcc)); // base0C + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -554,6 +579,12 @@ mod tests { assert_eq!(t.current_fg, Color::Rgb(0x50, 0xa1, 0x4f)); // base0B } + #[test] + fn light_heading_fg_takes_one_lights_cyan_accent() { + let t = Palette::light(); + assert_eq!(t.heading_fg, Color::Rgb(0x01, 0x84, 0xbc)); // base0C + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -636,6 +667,7 @@ mod tests { assert_eq!(palette.error_fg, probed.slot(8)); assert_eq!(palette.warn_fg, probed.slot(10)); assert_eq!(palette.current_fg, probed.slot(11)); + assert_eq!(palette.heading_fg, probed.slot(12)); assert_ne!(palette.error_fg, Palette::dark().error_fg); } From a318dc6a76c0a56ba342e3e265ae98356004b54a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 14:29:25 -0400 Subject: [PATCH 13/19] feat(review): smart path render and tighter tree indent --- git-workon-review/src/render.rs | 237 +++++++++++++++++++++++++++++--- 1 file changed, 217 insertions(+), 20 deletions(-) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5d211cbe..f8b42bbc 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -737,7 +737,9 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and -/// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the +/// the path — Flat/Stack rows (CS2) split it into `basename dim/dirname` (no suffix for a +/// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so +/// `path` there is just the bare basename. The cursor row (the outline's OWN cursor — a separate coordinate space from the /// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer /// [`Palette::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). `&mut App` (CS2, precedent: [`render_body`] @@ -796,19 +798,21 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) /// its parent's last child) or blank space (if it was), and the last element draws the row's own /// `╰─`/`├─` connector — CS4 rounds the last-child corner (`╰`, U+2570) from the square `└` /// (U+2514); there's no widely-supported rounded "tee" glyph, so the non-last `├─` connector is -/// unchanged. +/// unchanged. CS2 tightens indent to 2 cols/level: continuation is `│ ` (bar + space, no third +/// column), and connectors (`├─`/`╰─`) carry no trailing space — the glyph that follows hugs the +/// connector directly. fn tree_prefix(guides: &[bool]) -> String { let mut s = String::new(); let Some((&is_last, ancestors)) = guides.split_last() else { return s; }; for &last in ancestors { - s.push_str(if last { " " } else { "\u{2502} " }); + s.push_str(if last { " " } else { "\u{2502} " }); } s.push_str(if is_last { - "\u{2570}\u{2500} " + "\u{2570}\u{2500}" } else { - "\u{251C}\u{2500} " + "\u{251C}\u{2500}" }); s } @@ -932,10 +936,34 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L Style::default().fg(color.unwrap_or(theme.foreground)), )); } - spans.push(TSpan::styled( - path.clone(), - Style::default().fg(theme.foreground), - )); + // Flat/Stack rows (empty `guides`) split `path` at render time into `basename dim/ + // dirname` — basename first (bright, matching the tree modes' bare-name leaves) so + // truncation eats the dim dirname before the name a user is scanning for (CS2 + // gotcha). Tree/StackTree rows (non-empty `guides`) already carry the path via + // ancestor Dir rows, so `path` there is already just the basename — render it as-is. + if guides.is_empty() { + match path.rsplit_once('/') { + Some((dir, base)) => { + spans.push(TSpan::styled( + base.to_string(), + Style::default().fg(theme.foreground), + )); + spans.push(TSpan::styled( + format!(" {dir}"), + Style::default().fg(theme.dim), + )); + } + None => spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )), + } + } else { + spans.push(TSpan::styled( + path.clone(), + Style::default().fg(theme.foreground), + )); + } Line::from(spans) } } @@ -3644,20 +3672,46 @@ mod tests { // buffer row starting at y=1 (y=0 is the winbar): `src/` (dir, root, NOT the root's last // child — `top.txt` follows), `a.txt` nested one level under `src/` (the only — hence // last — child of `src/`), then `top.txt` (file, root, IS the root's last child). - assert!( - content[1].contains('\u{251C}') && content[1].contains("src/"), - "expected row 1 to be the src/ directory row with a non-last '├─' guide, got:\n{}", + // + // CS2 tightens `tree_prefix` to 2 cols/level with no trailing space on the connector, so + // these are exact-column checks (not just `contains`) — every rendered cell here is one + // column wide, so `chars()` (not byte) indexing IS the display column (the guide glyphs + // themselves are multi-byte, which is exactly why byte indexing would be wrong). + let row1: Vec = content[1].chars().collect(); + assert_eq!( + row1[0..6], + ['\u{251C}', '\u{2500}', 's', 'r', 'c', '/'], + "expected row 1 to be a tight '├─src/' (2-col connector, no trailing space), got:\n{}", content.join("\n") ); - assert!( - content[2].contains('\u{2570}') && content[2].contains("a.txt"), - "expected row 2 to be src/a.txt, indented under src/ with its own last-child \ - rounded '╰─' guide, got:\n{}", + let row2: Vec = content[2].chars().collect(); + assert_eq!( + row2[0..4], + ['\u{2502}', ' ', '\u{2570}', '\u{2500}'], + "expected row 2's guide to be a tight '│ ╰─' (continuation + last-child connector, \ + both 2 cols), got:\n{}", content.join("\n") ); - assert!( - content[3].contains('\u{2570}') && content[3].contains("top.txt"), - "expected row 3 to be top.txt with a last-child rounded '╰─' guide, got:\n{}", + assert_eq!( + row2[7..12], + ['a', '.', 't', 'x', 't'], + "expected src/a.txt's basename to start immediately after the 4-col guide + 1-col \ + glyph + 1-col letter + 1-col space, got:\n{}", + content.join("\n") + ); + let row3: Vec = content[3].chars().collect(); + assert_eq!( + row3[0..2], + ['\u{2570}', '\u{2500}'], + "expected row 3's guide to be a tight '╰─' (root-level last-child, 2 cols, no \ + trailing space), got:\n{}", + content.join("\n") + ); + assert_eq!( + row3[5..12], + ['t', 'o', 'p', '.', 't', 'x', 't'], + "expected top.txt to start immediately after the 2-col guide + 1-col glyph + 1-col \ + letter + 1-col space, got:\n{}", content.join("\n") ); } @@ -3680,7 +3734,14 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); // Row 3 is top.txt (see the test above) — a File row with a non-empty guide vector. let row = outline_row(&buf, 3); - let guide_x = row.find('\u{2570}').expect("rounded guide present") as u16; + // `String::find` returns a BYTE offset, not a display column — the rounded guide glyph is + // multi-byte, so a `chars()` (not byte) position is what actually lines up with the + // column-indexed `buf.cell` lookup below (every rendered cell here is one column wide). + let row_chars: Vec = row.chars().collect(); + let guide_x = row_chars + .iter() + .position(|&c| c == '\u{2570}') + .expect("rounded guide present") as u16; assert_eq!( buf.cell((guide_x, 3)).unwrap().style().fg, Some(Palette::dark().dim), @@ -3688,6 +3749,142 @@ mod tests { ); } + // ── CS2 (outline-row-shape): smart path render ───────────────────────────────── + + #[test] + fn outline_stack_mode_file_row_splits_basename_and_dim_dirname() { + // Stack mode keeps `guides` empty, so a nested path (`src/a.txt`) must split at render + // time into basename-first, then the dirname in `theme.dim` — ancestors don't carry the + // path here (unlike Tree mode), so the row has to spell it out itself. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + assert_eq!( + app.outline_mode(), + crate::outline::OutlineMode::Stack, + "sanity: default mode is Stack, so guides stay empty and this exercises CS2's split" + ); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: the full-width winbar also names the current file (possibly `src/a.txt` + // itself), so an unskipped search could false-positive onto it instead of the outline's + // own row below it. `content`'s index `i` is buffer row `i + 1` (the skip), so every + // `buf` query below adds 1 back. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content + .iter() + .position(|r| r.contains("a.txt") && r.contains("src")) + .expect("src/a.txt's split row present"); + let row_chars: Vec = content[row_idx].chars().collect(); + let buf_y = row_idx as u16 + 1; + + let basename_x = row_chars + .windows(5) + .position(|w| w == ['a', '.', 't', 'x', 't']) + .expect("basename 'a.txt' present in its own row") as u16; + assert_eq!( + buf.cell((basename_x, buf_y)).unwrap().style().fg, + Some(Palette::dark().foreground), + "expected the basename to carry the plain (bright) foreground, got:\n{}", + content[row_idx] + ); + + // Two blank columns separate the basename from the dirname (CS2: "basename dim/ + // dirname"), so the dirname starts right after them. + let dirname_x = basename_x + 5 + 2; + assert_eq!( + row_chars[dirname_x as usize], 's', + "expected the dirname 'src' to start two columns after the basename, got:\n{}", + content[row_idx] + ); + assert_eq!( + buf.cell((dirname_x, buf_y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the dirname to carry theme.dim, got:\n{}", + content[row_idx] + ); + assert!( + basename_x < dirname_x, + "basename must render BEFORE the dim dirname (basename-first ordering is what makes \ + truncation eat the dirname first), got:\n{}", + content[row_idx] + ); + } + + #[test] + fn outline_root_level_file_gets_no_dirname_suffix() { + // A root-level file (no `/` in its path) gets no suffix at all — no "(root)" + // placeholder, just the bare basename. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); // top.txt is root-level + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: see the split test above — the winbar also names the current file. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .find(|r| r.contains("top.txt")) + .expect("top.txt's row present"); + assert!( + row.trim_end().ends_with("top.txt"), + "a root-level file must render with no trailing suffix after its basename, got: \ + {row:?}" + ); + } + + #[test] + fn outline_flat_row_truncation_eats_the_dim_dirname_first() { + // A pane-width-exceeding Flat-mode row must truncate the (later, dim) dirname before it + // ever touches the (earlier, bright) basename — that ordering is the whole point of + // basename-first rendering (CS2 gotcha). + let long_dir = "reallyquiteverbosedirectoryname"; + let path = format!("{long_dir}/x.txt"); + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file(&path, "content\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0: see the split test above — the winbar also names the current file (the long + // path itself here), so an unskipped search would false-positive onto it. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content + .iter() + .position(|r| r.contains("x.txt")) + .expect("x.txt's row present"); + assert!( + !content[row_idx].contains(long_dir), + "the full dirname must NOT fit/appear — truncation should have eaten part of it, \ + got: {:?}", + content[row_idx] + ); + // Column 34 is the outline's last column before the divider at 35 (see + // `OUTLINE_TEST_WIDTH`'s doc comment). `content`'s index is buffer row `+ 1` (the y=0 + // skip above). + assert_eq!( + cell_text(&buf, 34, row_idx as u16 + 1), + "\u{2026}", + "the truncated row must show the right-edge marker at the pane's last column" + ); + } + // ── mouse h-wheel + outline hscroll follow-up: outline panning ───────────────── /// A single-changeset `App` with one file whose path is far wider than the outline's fixed From dae859adee0070b615078baa488cf8a4fdce346b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 15:03:47 -0400 Subject: [PATCH 14/19] feat(review): git-style XY status matrix for outline files --- git-workon-review/src/outline.rs | 36 +-- git-workon-review/src/render.rs | 457 +++++++++++++++++++++++++++---- git-workon-review/src/theme.rs | 42 +++ 3 files changed, 446 insertions(+), 89 deletions(-) diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 15540bfa..6b510bff 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -80,12 +80,15 @@ fn scan_order( entries } -/// A file's staged-ness for the outline's status column — a minimal indicator (locked CS3 -/// scope: NOT the prototype's X/Y two-column git-status matrix). Only meaningful for the -/// uncommitted changeset's files; a committed changeset's files always resolve to `None` -/// because their `unstaged_idx`/`staged_idx` maps are always-empty (see +/// A file's staged-ness for the outline's status column — the data model `render.rs` derives its +/// git-porcelain-style X/Y two-column status matrix from (CS3, `outline-status-xy`). Only +/// meaningful for the uncommitted changeset's files; a committed changeset's files always +/// resolve to `None` because their `unstaged_idx`/`staged_idx` maps are always-empty (see /// `DiffState::from_committed`) — the same "derive, don't special-case" collapse /// `effective_zoom` already relies on, so no committed-specific branch is needed here either. +/// `render::build_outline_line`'s File arm reads `None` as "render a committed single letter + +/// pad column" and `Unstaged`/`Staged`/`Partial` as "render the X/Y matrix" — see that fn's doc +/// comment. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StagedStatus { /// No staged/unstaged sub-diff info for this file (a committed changeset's file, or an @@ -112,31 +115,6 @@ impl StagedStatus { (false, false) => StagedStatus::None, } } - - /// The single-character glyph the outline renders in the status column, or a blank space - /// for [`StagedStatus::None`] (keeps every file row's path starting at the same column - /// regardless of whether it carries a status). - pub fn glyph(self) -> char { - match self { - StagedStatus::None => ' ', - StagedStatus::Unstaged => '+', - StagedStatus::Staged => '\u{2713}', // ✓ - StagedStatus::Partial => '\u{25D0}', // ◐ - } - } - - /// The nerd-font equivalent of [`StagedStatus::glyph`] (CS3, `workon.review.icons = - /// nerd`) — picked from the classic BMP `fa` set for wider font compatibility (see - /// `icons.rs`'s module doc). [`StagedStatus::None`] stays a blank space, same as - /// [`StagedStatus::glyph`], since there's no status to convey. - pub fn nerd_glyph(self) -> char { - match self { - StagedStatus::None => ' ', - StagedStatus::Unstaged => '\u{f067}', // nf-fa-plus - StagedStatus::Staged => '\u{f00c}', // nf-fa-check - StagedStatus::Partial => '\u{f042}', // nf-fa-adjust - } - } } /// One file's outline-relevant data, as extracted from its owning changeset by diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index f8b42bbc..aea10146 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -735,8 +735,8 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// counter, an accented ([`Palette::heading_fg`]) bold label (CS1, `outline-header-polish` — see /// [`changeset_title_spans`]'s doc comment), and needs-restack glyph (amber ⚠, /// [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an -/// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see -/// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and +/// indent, a two-column git-porcelain-style status matrix (CS3, `outline-status-xy` — see +/// [`outline_status_spans`]'s doc comment for the X/Y-vs-single-letter split), and /// the path — Flat/Stack rows (CS2) split it into `basename dim/dirname` (no suffix for a /// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so /// `path` there is just the bare basename. The cursor row (the outline's OWN cursor — a separate coordinate space from the @@ -817,17 +817,82 @@ fn tree_prefix(guides: &[bool]) -> String { s } -/// The [`FileStatus`] change-letter's foreground color (CS5): a create-like status (Added/ -/// Untracked) reuses the theme's `add_strong` tint, a destroy-like status (Deleted) reuses -/// `del_strong`, and everything else (Modified/Renamed/Copied/Unmerged — a change to EXISTING -/// content, not a create/destroy) gets the theme's neutral `foreground`. No new [`Palette`] -/// fields — this is deliberately just a remap of tints CS4's summary rows already use. -fn change_letter_color(change: FileStatus, theme: &Palette) -> Color { +/// Placeholder glyph for an empty XY status column (CS3, `outline-status-xy`) — U+00B7 middle +/// dot, always `theme.dim`, standing in for "nothing to report on this axis." Deliberately not a +/// space: the two-column matrix should read as a grid even when one side is empty, not look like +/// a ragged single-letter row. +const STATUS_PLACEHOLDER: char = '\u{b7}'; + +/// A committed changeset's single-letter status color (CS3): A green (`add_strong`), D red +/// (`del_strong`), M/R/C (a change to EXISTING content, not a create/destroy) the dedicated amber +/// [`Palette::modified_fg`], and `?`/`U` dim (Untracked never reaches here — see +/// [`outline_status_spans`]'s doc comment — and Unmerged is a worktree-only conflict state a +/// committed changeset can't carry; both fold to `dim` only so this match stays exhaustive). +fn committed_letter_color(change: FileStatus, theme: &Palette) -> Color { match change { - FileStatus::Added | FileStatus::Untracked => theme.add_strong, + FileStatus::Added => theme.add_strong, FileStatus::Deleted => theme.del_strong, - FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied | FileStatus::Unmerged => { - theme.foreground + FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => theme.modified_fg, + FileStatus::Untracked | FileStatus::Unmerged => theme.dim, + } +} + +/// Build a file row's two-column status matrix (CS3, `outline-status-xy`) — always exactly 2 +/// [`TSpan`]s' worth of display columns, in every mode, so committed and uncommitted rows stay +/// aligned (the changeset's Gotcha). +/// +/// - `change == FileStatus::Untracked` wins over everything else and renders a dim `??` — noise, +/// not danger, regardless of `status` (see [`crate::outline::StagedStatus`]'s doc comment: an +/// untracked worktree file is always `Unstaged`, but git's own convention for untracked is `??`, +/// not a staged-ness-derived letter). +/// - `StagedStatus::None` is the committed-changeset case (see that type's doc comment for why no +/// special-casing is needed to detect it): a single [`FileStatus::letter`] colored by +/// [`committed_letter_color`], plus a blank pad column. +/// - `Unstaged`/`Staged`/`Partial` render the git-porcelain X/Y matrix: `letter` (from the SAME +/// underlying [`FileStatus`] — there's only one change kind per file, not separate staged/ +/// unstaged kinds) in whichever column(s) that axis has a change, [`STATUS_PLACEHOLDER`] in the +/// other; X (staged/index) is `add_strong` green, Y (worktree) is `del_strong` red, matching +/// git's own status convention. +fn outline_status_spans( + status: crate::outline::StagedStatus, + change: FileStatus, + theme: &Palette, +) -> Vec> { + use crate::outline::StagedStatus; + + if change == FileStatus::Untracked { + return vec![TSpan::styled( + "??".to_string(), + Style::default().fg(theme.dim), + )]; + } + match status { + StagedStatus::None => { + let letter = change.letter(); + vec![ + TSpan::styled( + letter.to_string(), + Style::default().fg(committed_letter_color(change, theme)), + ), + TSpan::styled(" ".to_string(), Style::default().fg(theme.foreground)), + ] + } + StagedStatus::Unstaged | StagedStatus::Staged | StagedStatus::Partial => { + let letter = change.letter(); + let staged = matches!(status, StagedStatus::Staged | StagedStatus::Partial); + let unstaged = matches!(status, StagedStatus::Unstaged | StagedStatus::Partial); + let x_char = if staged { letter } else { STATUS_PLACEHOLDER }; + let y_char = if unstaged { letter } else { STATUS_PLACEHOLDER }; + let x_color = if staged { theme.add_strong } else { theme.dim }; + let y_color = if unstaged { + theme.del_strong + } else { + theme.dim + }; + vec![ + TSpan::styled(x_char.to_string(), Style::default().fg(x_color)), + TSpan::styled(y_char.to_string(), Style::default().fg(y_color)), + ] } } } @@ -890,22 +955,18 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L guides, .. } => { - let glyph = match icons { - IconMode::Nerd => status.nerd_glyph(), - IconMode::None => status.glyph(), - }; - let letter = change.letter(); // Empty `guides` (Flat/Stack modes) keeps the original two-space indent; a // non-empty `guides` (Tree/StackTree modes) draws tree connectors instead — see // `OutlineItem`'s doc comment for why emptiness is the mode signal. CS4: a non-empty // prefix (real tree connectors) gets its own `theme.dim`-styled span — matching the // Dir row's already-dim guides — so the guide lines read as quiet structure, not part - // of the file's own status glyph; the empty two-space indent has nothing visible to - // dim, so it stays bundled with the glyph span below. + // of the file's own status column. The status matrix itself (CS3, + // `outline_status_spans`) is always exactly 2 display columns, same width the old + // glyph+letter pair occupied, so this swap doesn't shift anything after it. let mut spans = Vec::new(); if guides.is_empty() { spans.push(TSpan::styled( - format!(" {glyph}"), + " ".to_string(), Style::default().fg(theme.foreground), )); } else { @@ -913,15 +974,8 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L tree_prefix(guides), Style::default().fg(theme.dim), )); - spans.push(TSpan::styled( - glyph.to_string(), - Style::default().fg(theme.foreground), - )); } - spans.push(TSpan::styled( - letter.to_string(), - Style::default().fg(change_letter_color(*change, theme)), - )); + spans.extend(outline_status_spans(*status, *change, theme)); spans.push(TSpan::styled( " ".to_string(), Style::default().fg(theme.foreground), @@ -1962,7 +2016,7 @@ mod tests { use git_workon_fixture::prelude::*; use unicode_width::UnicodeWidthChar; - use super::{hscroll_cut, pan_spans, render}; + use super::{hscroll_cut, pan_spans, render, STATUS_PLACEHOLDER}; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; @@ -3951,45 +4005,325 @@ mod tests { ); } - // ── CS5: file status letter + opt-in nerd-font icons ─────────────────────────── + // ── CS3 (`outline-status-xy`): git-style X/Y status matrix ───────────────────── + + /// Render `fixture` (a lone uncommitted changeset with one file at `path`) and return the + /// buffer row + its char cells for the file row matching `path`. Skips y=0 (the winbar also + /// names the current file, which can false-positive a `contains(path)` search). + fn render_outline_file_row(fixture: &Fixture, path: &str) -> (Buffer, u16, Vec) { + let mut app = app_from_fixture(fixture); + // A lone changeset defaults the outline closed — force it open so this render test can + // inspect its rows (same pattern as `outline_tree_mode_renders_directory_rows_with_tree_guides`). + if !app.outline_open() { + app.toggle_outline(); + } + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let (row_idx, row) = content + .iter() + .enumerate() + .find(|(_, r)| r.contains(path)) + .map(|(i, r)| (i, r.clone())) + .unwrap_or_else(|| panic!("{path}'s file row present")); + let y = row_idx as u16 + 1; // +1 to undo the y=0 skip above. + (buf, y, row.chars().collect()) + } #[test] - fn outline_file_row_shows_the_modified_change_letter_in_its_own_color() { + fn outline_unstaged_file_renders_the_y_column_letter_in_del_strong() { + // Unstaged-only (worktree change, no staged one): X is the placeholder, Y carries the + // change letter in del_strong (git convention: worktree column is red). let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .unstaged_file("a.rs", "one\n", "one\nCHANGED\n") .build() .unwrap(); - let mut app = app_from_fixture(&fixture); - // A lone changeset defaults the outline closed — force it open so this render test can - // inspect its rows (same pattern as `outline_tree_mode_renders_directory_rows_with_tree_guides`). + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == STATUS_PLACEHOLDER) + .expect("expected the X (staged) column placeholder '·'") as u16; + assert_eq!( + row[x as usize + 1], + 'M', + "expected the Y (worktree) column to carry the Modified letter right after the \ + X placeholder, got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the empty X placeholder to carry theme.dim" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().del_strong), + "expected the Y column's Modified letter to carry theme.del_strong" + ); + } + + #[test] + fn outline_fully_staged_file_renders_the_x_column_letter_in_add_strong() { + // `staged_file` writes+stages a brand-new path (Added, not Modified — there's no prior + // commit for it to modify). Fully staged (index change, no worktree one): X carries the + // letter in add_strong, Y is the placeholder. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .staged_file("a.rs", "new content\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == 'A') + .expect("expected the Added letter in the X (staged) column") as u16; + assert_eq!( + row[x as usize + 1], + STATUS_PLACEHOLDER, + "expected the Y (worktree) column to be the empty placeholder, got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().add_strong), + "expected the X column's Added letter to carry theme.add_strong" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the empty Y placeholder to carry theme.dim" + ); + } + + #[test] + fn outline_partially_staged_file_renders_mm_with_green_x_and_red_y() { + // Partially staged (both a staged AND an unstaged change): both columns show the change + // letter, X in add_strong (green), Y in del_strong (red). + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .partially_staged_file("a.rs", "one\n", "one\nSTAGED\n", "one\nSTAGED\nWORKTREE\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "a.rs"); + + let x = row + .iter() + .position(|&c| c == 'M') + .expect("expected the Modified letter in the X column") as u16; + assert_eq!( + row[x as usize + 1], + 'M', + "expected the Modified letter in the Y column too (partial = both axes), got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().add_strong), + "expected the X (staged) column's letter to carry theme.add_strong" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().del_strong), + "expected the Y (worktree) column's letter to carry theme.del_strong" + ); + } + + #[test] + fn outline_untracked_file_renders_a_dim_double_question_mark() { + // Untracked overrides the staged-ness-derived matrix entirely: always a dim `??`, even + // though an untracked worktree file's StagedStatus is Unstaged under the hood. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .untracked_file("new.txt", "brand new\n") + .build() + .unwrap(); + let (buf, y, row) = render_outline_file_row(&fixture, "new.txt"); + + let x = row + .iter() + .position(|&c| c == '?') + .expect("expected the untracked '??' marker") as u16; + assert_eq!( + row[x as usize + 1], + '?', + "expected '??' (both columns), got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the untracked '?' to carry theme.dim, not an add/del tint" + ); + assert_eq!( + buf.cell((x + 1, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected BOTH untracked '?' chars to carry theme.dim" + ); + } + + #[test] + fn outline_committed_modified_file_renders_a_single_amber_letter() { + // A committed changeset's file has StagedStatus::None — single letter + pad column, not + // the X/Y matrix. M/R/C get the dedicated `modified_fg` amber, distinct from `warn_fg`. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("a.rs", "one\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("a.rs", "one\nCHANGED\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + let cs = workon::Changeset { + name: "cs".to_string(), + span: workon::ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let view = crate::app::ChangesetView::from_changeset_diff( + cs.clone(), + crate::acquire::diff_changeset(repo, &cs).unwrap(), + ); + let owned = git2::Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); if !app.outline_open() { app.toggle_outline(); } let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - // Skip y=0: the full-width winbar also names the file ("[1/1] a.rs"), so an unskipped - // search would match it instead of the outline's own row below it. - let row = content + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row_idx = content .iter() - .enumerate() - .skip(1) - .find(|(_, r)| r.contains("a.rs")) - .map(|(i, _)| i) + .position(|r| r.contains("a.rs")) .expect("a.rs's file row present"); - assert!( - content[row].contains('M'), - "expected the Modified change letter 'M' in a.rs's row, got: {:?}", - content[row] + let row: Vec = content[row_idx].chars().collect(); + let y = row_idx as u16 + 1; + + let x = row + .iter() + .position(|&c| c == 'M') + .expect("expected the Modified letter") as u16; + assert_eq!( + row[x as usize + 1], + ' ', + "expected the pad column after a committed file's single letter to be a blank space, \ + got: {:?}", + row + ); + assert_eq!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().modified_fg), + "expected the committed Modified letter to carry theme.modified_fg (amber), got a \ + different color" + ); + assert_ne!( + buf.cell((x, y)).unwrap().style().fg, + Some(Palette::dark().warn_fg), + "modified_fg must stay a distinct field from warn_fg even though both default to amber" ); + } - let letter_x = content[row].find('M').unwrap() as u16; + #[test] + fn outline_committed_added_and_deleted_files_render_add_strong_and_del_strong() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("deleted.txt", "keep\n") + .create("base") + .unwrap(); + let stage = fixture + .commit("main") + .file("deleted.txt", "keep\n") + .file("added.txt", "new\n") + .create("stage") + .unwrap(); + let _ = stage; // only needed to move the branch tip forward before the manual deletion below + let repo = fixture.repo().unwrap(); + let workdir = repo.workdir().unwrap().to_path_buf(); + std::fs::remove_file(workdir.join("deleted.txt")).unwrap(); + let mut index = repo.index().unwrap(); + // `CommitBuilder::create` wrote the index/commit through its OWN `Repository::open` + // handle, so `repo`'s cached index is stale until forced to re-read from disk. + index.read(true).unwrap(); + index + .remove_path(std::path::Path::new("deleted.txt")) + .unwrap(); + index.write().unwrap(); + let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); + let sig = git2::Signature::now("Test User", "test@example.com").unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + let head = repo + .commit(Some("HEAD"), &sig, &sig, "head", &tree, &[&parent]) + .unwrap(); + + let cs = workon::Changeset { + name: "cs".to_string(), + span: workon::ChangesetSpan::Committed { base, head }, + title: None, + current: true, + needs_restack: false, + }; + let view = crate::app::ChangesetView::from_changeset_diff( + cs.clone(), + crate::acquire::diff_changeset(repo, &cs).unwrap(), + ); + let owned = git2::Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view]); + app.open_current(); + if !app.outline_open() { + app.toggle_outline(); + } + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + + let added_row_idx = content + .iter() + .position(|r| r.contains("added.txt")) + .expect("added.txt's file row present"); + let added_row: Vec = content[added_row_idx].chars().collect(); + let added_x = added_row + .iter() + .position(|&c| c == 'A') + .expect("expected the Added letter") as u16; assert_eq!( - buf.cell((letter_x, row as u16)).unwrap().style().fg, - Some(Palette::dark().foreground), - "Modified is a change-to-existing-content status, so its letter must carry the \ - theme's neutral foreground, not an add/del tint" + buf.cell((added_x, added_row_idx as u16 + 1)) + .unwrap() + .style() + .fg, + Some(Palette::dark().add_strong), + "expected a committed Added file's letter to carry theme.add_strong" + ); + + let deleted_row_idx = content + .iter() + .position(|r| r.contains("deleted.txt")) + .expect("deleted.txt's file row present"); + let deleted_row: Vec = content[deleted_row_idx].chars().collect(); + let deleted_x = deleted_row + .iter() + .position(|&c| c == 'D') + .expect("expected the Deleted letter") as u16; + assert_eq!( + buf.cell((deleted_x, deleted_row_idx as u16 + 1)) + .unwrap() + .style() + .fg, + Some(Palette::dark().del_strong), + "expected a committed Deleted file's letter to carry theme.del_strong" ); } @@ -4132,7 +4466,12 @@ mod tests { } #[test] - fn outline_file_status_nerd_glyph_replaces_the_plain_glyph() { + fn outline_file_status_xy_column_is_unaffected_by_icon_mode() { + // CS3 retires StagedStatus's nerd/plain glyph split entirely — the X/Y status matrix is + // now plain letters + `STATUS_PLACEHOLDER`, icon-mode-independent (only the devicons + // per-file icon toggles on `IconMode::Nerd`). A fully staged file (`staged_file` writes a + // brand-new path, so it's Added, not Modified) still renders `A·` whether or not nerd + // icons are on. let fixture = FixtureBuilder::new() .config("core.autocrlf", "false") .staged_file("a.txt", "one\nCHANGED\n") @@ -4145,16 +4484,14 @@ mod tests { } let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); - let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); - let joined = content.join("\n"); - - assert!( - joined.contains(crate::outline::StagedStatus::Staged.nerd_glyph()), - "expected the nerd staged glyph (fa-check), got:\n{joined}" - ); + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let row = content + .iter() + .find(|r| r.contains("a.txt")) + .expect("a.txt's file row present"); assert!( - !joined.contains(crate::outline::StagedStatus::Staged.glyph()), - "nerd mode must not leave the plain ✓ glyph behind, got:\n{joined}" + row.contains(&format!("A{}", '\u{b7}')), + "expected the fully-staged 'A·' status pair to survive nerd icon mode, got: {row:?}" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index be012fdc..f5a27c8e 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -31,6 +31,13 @@ //! semantic-chrome field, same reasoning and same three-scheme mapping as the CS2 trio above — //! it's the outline's changeset-header-row accent, used only there (see //! `render::changeset_title_spans`'s doc comment for the outline-only gating). +//! +//! **CS3 addition (`outline-status-xy`):** [`Palette::modified_fg`] (base09, orange/amber) is a +//! fifth semantic-chrome field, same three-scheme mapping again — the outline's committed-file +//! "modified" tint (M/R/C letters). Deliberately a NEW field rather than reusing +//! [`Palette::warn_fg`]: "this changeset needs restacking" and "this file was modified" are +//! unrelated facts that happen to both want an amber tone, and collapsing them onto one field +//! would make them un-independently themeable. use ratatui::style::Color; @@ -235,6 +242,12 @@ pub struct Palette { /// rows (`render::changeset_title_spans`'s `counter` param gates it) — the summary panel's /// changeset title keeps the plain [`Palette::foreground`] look. pub heading_fg: Color, + /// Tone for a committed changeset's Modified/Renamed/Copied outline file-status letter (CS3, + /// `outline-status-xy`) — an amber (base09), distinct from [`Palette::warn_fg`]'s amber + /// (base0A) so "needs restack" and "modified" stay independently themeable even though both + /// default to the same amber family. Used ONLY by the outline's committed-file status column + /// (`render::committed_letter_color`). + pub modified_fg: Color, /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` @@ -278,6 +291,9 @@ impl Palette { // CS1: brand new (no historical `render.rs` const to reproduce), so this takes the // scheme's base0C directly rather than an authored literal. heading_fg: base.slot(12), + // CS3: brand new, same reasoning as `heading_fg` above — takes the scheme's base09 + // directly rather than an authored literal. + modified_fg: base.slot(9), paint_canvas: true, } } @@ -331,6 +347,7 @@ impl Palette { warn_fg: base.slot(10), // base0A current_fg: green, heading_fg: cyan, + modified_fg: base.slot(9), // base09 paint_canvas: true, } } @@ -375,6 +392,7 @@ impl Palette { warn_fg: base.slot(10), current_fg: base.slot(11), heading_fg: base.slot(12), + modified_fg: base.slot(9), // Unlike the curated schemes, `auto` must NOT paint over the terminal's own // background — base00 here IS the probed terminal bg, so painting a solid canvas // would defeat terminal transparency/background images for no benefit (the probed @@ -466,6 +484,19 @@ mod tests { assert_eq!(t.heading_fg, Color::Rgb(0x66, 0xcc, 0xcc)); // base0C } + #[test] + fn dark_modified_fg_takes_the_eighties_dark_orange_accent() { + // CS3: no historical constant to reproduce (this field is new, same reasoning as + // `dark_heading_fg_takes_the_eighties_dark_cyan_accent` above) — takes base09 straight + // from the scheme. + let t = Palette::dark(); + assert_eq!(t.modified_fg, Color::Rgb(0xf9, 0x91, 0x57)); // base09 + assert_ne!( + t.modified_fg, t.warn_fg, + "modified_fg must stay independently themeable from warn_fg" + ); + } + #[test] fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints @@ -585,6 +616,16 @@ mod tests { assert_eq!(t.heading_fg, Color::Rgb(0x01, 0x84, 0xbc)); // base0C } + #[test] + fn light_modified_fg_takes_one_lights_orange_accent() { + let t = Palette::light(); + assert_eq!(t.modified_fg, Color::Rgb(0xd7, 0x5f, 0x00)); // base09 + assert_ne!( + t.modified_fg, t.warn_fg, + "modified_fg must stay independently themeable from warn_fg" + ); + } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated /// one) and read the base00 luminance branch. @@ -668,6 +709,7 @@ mod tests { assert_eq!(palette.warn_fg, probed.slot(10)); assert_eq!(palette.current_fg, probed.slot(11)); assert_eq!(palette.heading_fg, probed.slot(12)); + assert_eq!(palette.modified_fg, probed.slot(9)); assert_ne!(palette.error_fg, Palette::dark().error_fg); } From 765996caab709e46b93f3c99ac9cf577c0a08942 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 15:19:04 -0400 Subject: [PATCH 15/19] feat(review): reorder outline cycle and show next mode in footer --- git-workon-review/src/app.rs | 9 ++-- git-workon-review/src/keymap.rs | 72 ++++++++++++++++++++++++++++---- git-workon-review/src/outline.rs | 31 +++++++++----- git-workon-review/src/render.rs | 69 +++++++++++++++++++++++++----- 4 files changed, 149 insertions(+), 32 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index c0042723..b0560d3e 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -9206,10 +9206,13 @@ mod tests { app.outline.mode = OutlineMode::Stack; app.outline_cycle_mode(); - assert_eq!(app.outline_mode(), OutlineMode::Tree); + assert_eq!(app.outline_mode(), OutlineMode::StackTree); app.outline_cycle_mode(); - assert_eq!(app.outline_mode(), OutlineMode::StackTree); + assert_eq!(app.outline_mode(), OutlineMode::Flat); + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), OutlineMode::Tree); } /// A single committed changeset touching two files under `src/`, for the Dir-row no-op @@ -9480,7 +9483,7 @@ mod tests { "precondition: scrolled away from the top" ); - app.outline_cycle_mode(); // -> Tree + app.outline_cycle_mode(); // -> StackTree let cursor = app.outline_cursor(); let scroll = app.outline_scroll(); assert!( diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 2844b1fa..48a5bd5b 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -25,6 +25,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::{RawBinding, View}; +use crate::outline::OutlineMode; /// One rebindable action. The action *identity* — distinct from `tui.rs`'s `Action`, which is the /// concrete effect applied to the `App` (and carries runtime data like a half-page scroll delta @@ -334,7 +335,8 @@ pub static REGISTRY: &[Registered] = &[ view: View::Outline, name: "cycle-mode", default_keys: "i", - description: "Cycle the outline mode", + description: + "Cycle the outline mode (stack \u{25b8} stack-tree \u{25b8} flat \u{25b8} tree)", }, Registered { command: Command::FocusDiff, @@ -806,9 +808,17 @@ enum HintItem { Pair(Command, Command, &'static str), } -fn render_hint_item(keymap: &Keymap, item: &HintItem) -> Option { +/// CS4 (`outline-mode-cycle`): most hint labels are the static string baked into the `HintItem`, +/// but `OutlineCycleMode`'s label shows the mode `i` would switch TO instead — computed from +/// `outline_mode` (the outline's CURRENT mode, so this is `outline_mode.cycle()`'s label). +fn render_hint_item(keymap: &Keymap, item: &HintItem, outline_mode: OutlineMode) -> Option { match item { HintItem::One(command, label) => { + let label = if *command == Command::OutlineCycleMode { + format!("\u{2192}{}", outline_mode.cycle().label()) + } else { + (*label).to_string() + }; primary_key(keymap, *command).map(|k| format!("{k} {label}")) } HintItem::Pair(down, up, label) => { @@ -848,7 +858,7 @@ const OUTLINE_HINTS: &[HintItem] = &[ /// string, so a rebind shows here too. A notice temporarily replaces this in the footer (the /// caller's job, see `render::render_footer`); an unbound curated action is simply dropped from /// the string rather than leaving a stale/wrong key visible. -pub fn footer_hint(keymap: &Keymap, focused: View) -> String { +pub fn footer_hint(keymap: &Keymap, focused: View, outline_mode: OutlineMode) -> String { let items: &[HintItem] = match focused { View::Diff => DIFF_HINTS, View::Outline => OUTLINE_HINTS, @@ -856,7 +866,7 @@ pub fn footer_hint(keymap: &Keymap, focused: View) -> String { }; items .iter() - .filter_map(|item| render_hint_item(keymap, item)) + .filter_map(|item| render_hint_item(keymap, item, outline_mode)) .collect::>() .join(" \u{b7} ") } @@ -1228,6 +1238,29 @@ mod tests { assert_eq!(outline_sections[1].title, "Outline"); } + #[test] + fn help_sections_cycle_mode_entry_spells_out_the_full_order() { + // CS4: descriptions are static `&'static str`s baked into `REGISTRY`, so the help + // overlay can't mark the CURRENT mode dynamically without a broader refactor — the + // locked fallback is a static full-order description, with the dynamic `→next` shown + // only in the footer hint (see `footer_hint_outline_cycle_label_tracks_the_current_mode`). + let km = Keymap::defaults(); + let sections = help_sections(&km, View::Outline); + let outline = §ions[1]; + let entry = outline + .entries + .iter() + .find(|e| e.description.contains("Cycle the outline mode")) + .expect("cycle-mode row present"); + assert!( + entry + .description + .contains("stack \u{25b8} stack-tree \u{25b8} flat \u{25b8} tree"), + "got: {:?}", + entry.description + ); + } + #[test] fn help_sections_skip_an_unbound_action() { let km = Keymap::from_bindings(&[RawBinding { @@ -1266,7 +1299,7 @@ mod tests { #[test] fn footer_hint_renders_the_curated_diff_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("s stage"), "got: {hint:?}"); assert!(hint.contains("d discard"), "got: {hint:?}"); @@ -1278,10 +1311,31 @@ mod tests { #[test] fn footer_hint_renders_the_curated_outline_entries() { let km = Keymap::defaults(); - let hint = footer_hint(&km, View::Outline); + let hint = footer_hint(&km, View::Outline, OutlineMode::Stack); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("enter open"), "got: {hint:?}"); - assert!(hint.contains("i mode"), "got: {hint:?}"); + assert!( + hint.contains("i \u{2192}stack-tree"), + "cycling from Stack must show the next mode, StackTree; got: {hint:?}" + ); + } + + #[test] + fn footer_hint_outline_cycle_label_tracks_the_current_mode() { + let km = Keymap::defaults(); + for (mode, next) in [ + (OutlineMode::Stack, "stack-tree"), + (OutlineMode::StackTree, "flat"), + (OutlineMode::Flat, "tree"), + (OutlineMode::Tree, "stack"), + ] { + let hint = footer_hint(&km, View::Outline, mode); + let want = format!("i \u{2192}{next}"); + assert!( + hint.contains(&want), + "mode {mode:?} should hint the NEXT mode {next:?}; got: {hint:?}" + ); + } } #[test] @@ -1291,7 +1345,7 @@ mod tests { action: "stage-hunk".to_string(), keys: "x".to_string(), }]); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(hint.contains("x stage"), "got: {hint:?}"); assert!(!hint.contains("s stage"), "got: {hint:?}"); } @@ -1303,7 +1357,7 @@ mod tests { action: "stage-hunk".to_string(), keys: String::new(), }]); - let hint = footer_hint(&km, View::Diff); + let hint = footer_hint(&km, View::Diff, OutlineMode::default()); assert!(!hint.contains("stage"), "got: {hint:?}"); // The rest of the curated set is unaffected. assert!(hint.contains("d discard"), "got: {hint:?}"); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 6b510bff..8fcf93d3 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -37,16 +37,27 @@ pub enum OutlineMode { } impl OutlineMode { - /// `i`'s cycle order: `Flat -> Stack -> Tree -> StackTree -> Flat`. Flat/Stack (the - /// non-trie modes) come first since they're the CS3 default pair; the trie modes follow in - /// the same flat/grouped pairing (Tree mirrors Flat's cross-stack dedup, StackTree mirrors - /// Stack's per-changeset grouping). + /// `i`'s cycle order: `Stack -> StackTree -> Flat -> Tree -> Stack` (CS4) — the default + /// [`Self::Stack`] leads, its trie sibling [`Self::StackTree`] follows immediately, then the + /// non-grouped pair [`Self::Flat`]/[`Self::Tree`] closes the loop. pub fn cycle(self) -> Self { match self { - OutlineMode::Flat => OutlineMode::Stack, - OutlineMode::Stack => OutlineMode::Tree, - OutlineMode::Tree => OutlineMode::StackTree, + OutlineMode::Stack => OutlineMode::StackTree, OutlineMode::StackTree => OutlineMode::Flat, + OutlineMode::Flat => OutlineMode::Tree, + OutlineMode::Tree => OutlineMode::Stack, + } + } + + /// The kebab-cased display name (CS4, `outline-mode-cycle`) — used by the footer's `i + /// →` hint and mirrors `App::parse_outline_mode`'s config strings (`app.rs`), so the + /// two never drift apart. + pub fn label(self) -> &'static str { + match self { + OutlineMode::Stack => "stack", + OutlineMode::StackTree => "stack-tree", + OutlineMode::Flat => "flat", + OutlineMode::Tree => "tree", } } } @@ -787,10 +798,10 @@ mod tests { #[test] fn mode_cycle_round_trips_all_four_modes() { - assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Stack); - assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::Tree); - assert_eq!(OutlineMode::Tree.cycle(), OutlineMode::StackTree); + assert_eq!(OutlineMode::Stack.cycle(), OutlineMode::StackTree); assert_eq!(OutlineMode::StackTree.cycle(), OutlineMode::Flat); + assert_eq!(OutlineMode::Flat.cycle(), OutlineMode::Tree); + assert_eq!(OutlineMode::Tree.cycle(), OutlineMode::Stack); } /// Deep-path fixture used by the tree-mode tests: a top-level file, a top-level directory diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index aea10146..5db2605c 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -1203,7 +1203,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, them } else { View::Diff }; - let text = footer_hint(keymap, focused); + let text = footer_hint(keymap, focused, app.outline_mode()); frame.render_widget( Paragraph::new(text).style(Style::default().fg(theme.dim)), area, @@ -2733,8 +2733,48 @@ mod tests { .map(|x| cell_text(&buf, x, footer_y)) .collect(); assert!( - footer.contains("open") && footer.contains("mode") && footer.contains("? help"), - "expected the curated outline hint string in the footer, got: {footer:?}" + footer.contains("open") + && footer.contains(&format!( + "i \u{2192}{}", + crate::outline::OutlineMode::StackTree.label() + )) + && footer.contains("? help"), + "expected the curated outline hint string, with CS4's dynamic next-mode label \ + (Stack's default -> StackTree), in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_outline_hint_next_mode_label_updates_as_the_mode_cycles() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.toggle_outline(); + assert!(app.outline_focused()); + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Stack); + + let footer_text = |app: &mut App| { + let buf = render_once(app, 80, 10); + let footer_y = buf.area.height - 1; + (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect::() + }; + + let footer = footer_text(&mut app); + assert!( + footer.contains("i \u{2192}stack-tree"), + "Stack's next mode is StackTree; got: {footer:?}" + ); + + app.outline_cycle_mode(); + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::StackTree); + let footer = footer_text(&mut app); + assert!( + footer.contains("i \u{2192}flat"), + "StackTree's next mode is Flat; got: {footer:?}" ); } @@ -3638,8 +3678,7 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - app.outline_cycle_mode(); // Stack -> Tree - app.outline_cycle_mode(); // Tree -> StackTree + app.outline_cycle_mode(); // Stack -> StackTree app.outline_cycle_mode(); // StackTree -> Flat assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Flat); @@ -3716,7 +3755,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); @@ -3782,7 +3823,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); @@ -4366,7 +4409,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree, so `src/` renders as its own Dir row + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree, so `src/` renders as its own Dir row assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); app.set_icon_mode(crate::icons::IconMode::Nerd); @@ -4405,7 +4450,9 @@ mod tests { if !app.outline_open() { app.toggle_outline(); } - app.outline_cycle_mode(); // Stack -> Tree + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); assert_eq!( app.icon_mode(), @@ -4504,7 +4551,9 @@ mod tests { let mut app = changeset_with_nested_paths(&fixture); app.set_icon_mode(crate::icons::IconMode::Nerd); app.focus_outline(); // opens (a lone changeset defaults closed) and focuses - app.outline_cycle_mode(); // Stack -> Tree, so a Dir row exists to focus + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree, so a Dir row exists to focus assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); let dir_idx = app .outline_items() From 01f276ddabc1638865b32ea987120cf99c5c00c0 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Mon, 13 Jul 2026 16:25:47 -0400 Subject: [PATCH 16/19] feat(review): collapse and expand outline headers and directories --- git-workon-review/src/app.rs | 415 ++++++++++++++++++++++++---- git-workon-review/src/keymap.rs | 2 +- git-workon-review/src/outline.rs | 446 ++++++++++++++++++++++++++++++- git-workon-review/src/render.rs | 154 ++++++++++- git-workon-review/src/tui.rs | 78 +++++- 5 files changed, 1019 insertions(+), 76 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index b0560d3e..bd61ca33 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -9,7 +9,7 @@ //! handle so it can lazily read blob/worktree content per file as the user navigates to it, //! independent of whatever handle acquired the [`DiffModel`] it was built from. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; use git2::Repository; @@ -27,7 +27,9 @@ use crate::highlight::{lang_key_for_ext, FgSpan, TsHighlighter}; use crate::icons::IconMode; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; -use crate::outline::{self, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder}; +use crate::outline::{ + self, FoldKey, OutlineChangeset, OutlineFile, OutlineItem, OutlineMode, OutlineOrder, +}; use crate::queue::{OpOutcome, StagingOp, StagingQueue}; use crate::refresh::{IndexSignature, RefreshCoordinator}; use crate::scope::enclosing_scope_lines; @@ -813,6 +815,17 @@ pub struct OutlineState { /// here. Reset to `0` by [`App::outline_cycle_mode`] — the row list (and therefore the set of /// paths on screen) changes shape there, the same reason that resyncs the cursor. pub hscroll: usize, + /// CS5 (`outline-fold`): per-[`OutlineMode`] sets of collapsed [`FoldKey`]s — a Header row's + /// changeset label PLUS its `cs_idx`, or a Dir row's full path (+ owning changeset `cs_idx` in + /// `StackTree`) — see [`FoldKey`]'s own doc comment for why `cs_idx` is load-bearing there, + /// not decorative (a changeset's `label` alone can collide with its own uncommitted layer's). + /// Each mode keeps its own independent set (folding a dir in `Tree` doesn't affect + /// `StackTree`'s copy of the same path), survives mode cycling and auto-refresh (this lives on + /// `App`, not in the rebuilt-every-call row list), and starts empty — everything expanded by + /// default. Mutated only by [`App::outline_toggle_fold`]; never explicitly cleared, so a fold + /// outlives its own toggling row's disappearance and reappearance (e.g. a discard-then-recreate + /// of the same path) for as long as the session runs. + pub folds: HashMap>, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -1378,6 +1391,7 @@ impl App { scroll: 0, order: OutlineOrder::default(), hscroll: 0, + folds: HashMap::new(), }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -2478,14 +2492,58 @@ impl App { .collect() } - /// Build the current [`OutlineMode`]'s row list — the outline cursor's index space, and the - /// source of truth `render.rs` draws from. Rebuilt fresh on every call (cheap: a small stack - /// times a handful of files each, no caching, same posture as [`Self::effective_zoom_for`]) - /// rather than cached on `App`, so it's never stale across a mode toggle, a nav, or a - /// refresh. + /// Build (via [`outline::fold_outline`]) the current [`OutlineMode`]'s FOLD-FILTERED row list + /// — the outline cursor's SINGLE index space, and the source of truth every other outline + /// consumer reads: `render.rs`, [`Self::outline_move_by`]/[`Self::outline_move_to`], + /// [`Self::outline_confirm`], [`Self::summary_target`], and the staging-verb resolution in + /// [`Self::outline_row_targets`] all funnel through this SAME method (CS5, `outline-fold`) — + /// so folding a Header/Dir can never silently retarget a cursor move or a stage/discard verb + /// onto the wrong row: there is no OTHER row list any of them could accidentally read + /// instead. Rebuilt fresh on every call (cheap: a small stack times a handful of files each, + /// no caching, same posture as [`Self::effective_zoom_for`]) rather than cached on `App`, so + /// it's never stale across a mode toggle, a nav, a fold, or a refresh. `render.rs`'s marker + /// needs the per-row hidden-file counts this discards — see + /// [`Self::outline_items_with_hidden_counts`]. pub fn outline_items(&self) -> Vec { + self.outline_folded().items + } + + /// [`Self::outline_items`], plus (aligned by index) each row's CS5 hidden-file marker count — + /// `render_outline`'s data source. Every OTHER outline consumer uses [`Self::outline_items`] + /// instead, which just discards the counts it doesn't need; both funnel through the same + /// [`Self::outline_folded`] build, so they can never disagree about which rows are visible. + pub fn outline_items_with_hidden_counts(&self) -> (Vec, Vec) { + let folded = self.outline_folded(); + (folded.items, folded.hidden_counts) + } + + /// The shared build [`Self::outline_items`]/[`Self::outline_items_with_hidden_counts`]/ + /// [`Self::outline_target_index`] all read from — [`outline::fold_outline`] applied to the + /// current mode/order/fold-set, so there's exactly one place that pairs "which changesets by + /// which state" with "the fold set for the CURRENT mode" (`self.outline.folds` is keyed by + /// [`OutlineMode`]; a mode with no folds recorded yet reads as "everything expanded", the + /// default). + fn outline_folded(&self) -> outline::FoldedOutline { let snapshot = self.outline_snapshot(); - outline::build_items(&snapshot, self.outline.mode, self.outline.order) + let folds = self.outline.folds.get(&self.outline.mode); + outline::fold_outline(&snapshot, self.outline.mode, self.outline.order, |key| { + folds.is_some_and(|set| set.contains(key)) + }) + } + + /// Resolve a target row matched against the FULL (unfiltered) row list to its position in + /// [`Self::outline_items`]'s FILTERED list — its own index if it's visible, or its nearest + /// visible (collapsed) ancestor's if a fold hides it (CS5's "`sync_outline_to_current` + /// targeting a file hidden under a collapsed node lands on the collapsed ancestor WITHOUT + /// auto-expanding" rule — see [`outline::FoldedOutline::visible_index`]'s doc comment). `find` + /// matches against the full build (via `outline::build_items` directly, not + /// [`Self::outline_items`]) since a fold-hidden target has no index in the filtered list at + /// all to match against. + fn outline_target_index(&self, find: impl Fn(&OutlineItem) -> bool) -> Option { + let snapshot = self.outline_snapshot(); + let full = outline::build_items(&snapshot, self.outline.mode, self.outline.order); + let full_idx = full.iter().position(find)?; + self.outline_folded().visible_index.get(full_idx).copied() } /// CS4: the outline row a Header/Dir cursor selection resolves to — `None` when the outline @@ -2706,10 +2764,14 @@ impl App { /// click landed in, matching the keyboard-driven equivalent for that region. Outline: focuses /// the outline and jumps the cursor to the clicked row via [`Self::outline_move_to`] — a File /// row jumps the diff there (same single-jump semantics `g`/`G` use), a Header/Dir row just - /// selects (the summary panel follows via [`Self::summary_target`]). Diff pane (single or - /// split): focuses that pane (flipping `split_focus` first if the click landed in the - /// unfocused half) and moves its cursor to the clicked row. Outside every recorded region - /// (header/footer/divider/captions): no-op. + /// selects (the summary panel follows via [`Self::summary_target`]) WITHOUT toggling its fold + /// (CS5, `outline-fold`) — a click has always been "move the cursor here", a strictly weaker + /// action than `Enter`'s "act on this row" even before folding existed (pre-CS5, `Enter` on a + /// Header jumped to its first file; a click on the same row never did), so a click staying + /// select-only here keeps that existing asymmetry rather than inventing a new "click mirrors + /// Enter" rule this pane never had. Diff pane (single or split): focuses that pane (flipping + /// `split_focus` first if the click landed in the unfocused half) and moves its cursor to the + /// clicked row. Outside every recorded region (header/footer/divider/captions): no-op. pub fn handle_click(&mut self, col: u16, row: u16) { let Some((pane, region)) = self.hit_test(col, row) else { return; @@ -2964,30 +3026,50 @@ impl App { self.outline_move_to(last); } - /// `Enter` while the outline has focus: jump the diff to the row under the outline cursor (a - /// file row jumps straight there; a header row jumps to that changeset's first file — the - /// one case [`Self::outline_move_by`] deliberately does NOT do on a bare cursor move), then - /// return focus to the diff. + /// `Enter` while the outline has focus: a FILE row jumps the diff straight there and returns + /// focus to the diff (unchanged since CS3). A HEADER or DIR row instead TOGGLES that row's + /// fold state (CS5, `outline-fold`) and deliberately does NOT return focus — you're + /// manipulating the outline's own structure, not confirming a jump, so there's nothing to + /// hand focus back to yet. This REMOVES Enter's pre-CS5 jump-to-changeset-first-file behavior + /// on a Header row (still reachable via Enter on any of that changeset's own file rows, or + /// `[c`/`]c`) and Dir's pre-CS5 no-op (CS4 shipped Dir rows before any fold state existed to + /// toggle). pub fn outline_confirm(&mut self) { let items = self.outline_items(); match items.get(self.outline.cursor) { Some(OutlineItem::File { cs_idx, file_idx, .. - }) => self.switch_changeset(*cs_idx, *file_idx), - Some(OutlineItem::Header { cs_idx, .. }) => { - let cs_idx = *cs_idx; - self.goto_changeset(cs_idx); - // `goto_changeset` is the shared outline/diff core and deliberately does not - // self-sync (see its doc comment) — this outline-initiated call syncs explicitly - // so the cursor follows off the header row onto the file it just jumped to. - self.sync_outline_to_current(); + }) => { + self.switch_changeset(*cs_idx, *file_idx); + self.outline.focused = false; + } + Some(OutlineItem::Header { .. } | OutlineItem::Dir { .. }) => { + self.outline_toggle_fold(); } - // A directory row (Tree/StackTree modes) is not a jump target — no expand/collapse - // state exists to toggle (CS4 decision), so Enter here is a no-op beyond the - // unconditional unfocus below, same as confirming on nothing at all. - Some(OutlineItem::Dir { .. }) | None => {} + None => self.outline.focused = false, } - self.outline.focused = false; + } + + /// `Enter` on a Header/Dir row (CS5, `outline-fold`): flip that row's collapsed state in the + /// CURRENT [`OutlineMode`]'s fold set (see [`OutlineState::folds`]), then re-derive the + /// outline scroll — the row list's length just changed shape (more/fewer rows), the same + /// reason every other row-count-changing op does. The cursor's own INDEX never needs + /// re-finding: toggling a row's fold only changes what's visible AFTER it in the list (its + /// descendants), never before, so the row under the cursor — the one just toggled — stays + /// exactly where it was. + fn outline_toggle_fold(&mut self) { + let items = self.outline_items(); + let Some(item) = items.get(self.outline.cursor) else { + return; + }; + let Some(key) = FoldKey::for_item(item) else { + return; + }; + let set = self.outline.folds.entry(self.outline.mode).or_default(); + if !set.remove(&key) { + set.insert(key); + } + self.derive_outline_scroll(self.outline_items().len()); } // ── Outline staging (CS7) ─────────────────────────────────────────────────── @@ -3227,20 +3309,21 @@ impl App { } /// Reposition (never rebuild/refocus) the outline cursor onto the row matching the CURRENT - /// diff changeset+file, or clamp it into bounds if no such row exists (e.g. Flat mode - /// deduped the current file's changeset out of the list). The sync-follow discipline's echo - /// break: called ONLY from the diff-initiated nav entry points (`next_file`/`prev_file`/ - /// `next_changeset`/`prev_changeset`/`refresh`, plus the two outline actions that explicitly - /// opt in after a header jump) — never from `switch_changeset`/`goto_changeset` themselves, - /// since those are the shared core an OUTLINE-initiated jump also calls, and an - /// outline-initiated jump has already set [`OutlineState::cursor`] to the row the user - /// selected. If this ran unconditionally inside `switch_changeset`, an outline `j`/`k` move - /// past a HEADER row (which never calls `switch_changeset`, so nothing would resync) would - /// be fine, but any accidental future call site wired into the shared core would instantly - /// stomp a manually-positioned outline cursor back onto the diff's last position — the exact - /// oscillation the prototype's `_suppress_sync` flag existed to prevent. Keeping the sync - /// calls only at the diff-facing entry points achieves the same break without needing a - /// mutable suppression flag on `App`. + /// diff changeset+file — or, if a fold hides that row, its nearest visible (collapsed) + /// ancestor instead, WITHOUT auto-expanding it (CS5, `outline-fold` — preserves the user's + /// fold intent; see [`Self::outline_target_index`]) — or clamps into bounds if no such row + /// exists in the FULL build at all (e.g. Flat mode deduped the current file's changeset out + /// of the list entirely). The sync-follow discipline's echo break: called ONLY from the + /// diff-initiated nav entry points (`next_file`/`prev_file`/`next_changeset`/`prev_changeset`/ + /// `refresh`) — never from `switch_changeset`/`goto_changeset` themselves, since those are the + /// shared core an OUTLINE-initiated jump also calls, and an outline-initiated jump has already + /// set [`OutlineState::cursor`] to the row the user selected. If this ran unconditionally + /// inside `switch_changeset`, an outline `j`/`k` move past a HEADER row (which never calls + /// `switch_changeset`, so nothing would resync) would be fine, but any accidental future call + /// site wired into the shared core would instantly stomp a manually-positioned outline cursor + /// back onto the diff's last position — the exact oscillation the prototype's + /// `_suppress_sync` flag existed to prevent. Keeping the sync calls only at the diff-facing + /// entry points achieves the same break without needing a mutable suppression flag on `App`. fn sync_outline_to_current(&mut self) { let items = self.outline_items(); if items.is_empty() { @@ -3248,11 +3331,13 @@ impl App { self.derive_outline_scroll(0); return; } - if let Some(idx) = items.iter().position(|it| { + let current_cs = self.current_cs; + let current = self.current; + if let Some(idx) = self.outline_target_index(|it| { matches!( it, OutlineItem::File { cs_idx, file_idx, .. } - if *cs_idx == self.current_cs && *file_idx == self.current + if *cs_idx == current_cs && *file_idx == current ) }) { self.outline.cursor = idx; @@ -9121,28 +9206,48 @@ mod tests { } #[test] - fn outline_confirm_on_a_header_row_jumps_to_its_first_file_and_returns_focus() { + fn outline_confirm_on_a_header_row_toggles_fold_instead_of_jumping_and_keeps_focus() { + // CS5 (`outline-fold`) removes Enter's pre-CS5 jump-to-changeset-first-file behavior on a + // Header row — it now toggles that row's fold instead, and deliberately does NOT return + // focus (you're manipulating the outline, not confirming a jump). let mut app = two_committed_changesets_two_and_one_files(); app.outline.mode = OutlineMode::Stack; // CS3: pin BaseFirst explicitly — cursor 3 is hardcoded to cs-b's header under base -> - // head row order; the confirm mechanic under test is order-agnostic. + // head row order; the toggle mechanic under test is order-agnostic. app.outline.order = OutlineOrder::BaseFirst; app.outline.open = true; app.outline.focused = true; app.outline.cursor = 3; // cs-b's header row + let before_cs = app.current_cs(); + let before_file = app.current; + let rows_before = app.outline_items().len(); app.outline_confirm(); assert_eq!( app.current_cs(), - 1, - "Enter on a header must jump to that changeset" + before_cs, + "Enter on a header must NOT jump the diff (CS5)" ); - assert_eq!(app.current, 0, "...landing on its FIRST file"); + assert_eq!(app.current, before_file); assert!( - !app.outline_focused(), - "confirming returns focus to the diff" + app.outline_focused(), + "toggling a fold must NOT return focus to the diff" + ); + assert_eq!( + app.outline_items().len(), + rows_before - 1, + "cs-b's single file row is now hidden under its collapsed header" + ); + assert_eq!( + app.outline_cursor(), + 3, + "the cursor stays on the header row it just toggled" ); + + // Toggling again expands it back. + app.outline_confirm(); + assert_eq!(app.outline_items().len(), rows_before); } #[test] @@ -9277,6 +9382,7 @@ mod tests { app.outline.cursor = dir_idx; app.outline.focused = true; + let rows_before = app.outline_items().len(); app.outline_confirm(); assert_eq!(app.current_cs(), before_cs); assert_eq!( @@ -9284,8 +9390,12 @@ mod tests { "confirming a Dir row must not jump the diff" ); assert!( - !app.outline_focused(), - "confirm still returns focus to the diff, even as a no-op" + app.outline_focused(), + "confirming a Dir row toggles its fold (CS5) rather than returning focus" + ); + assert!( + app.outline_items().len() < rows_before, + "src/'s files must now be hidden under its collapsed row" ); } @@ -10211,6 +10321,203 @@ mod tests { } } + // ── CS5 (`outline-fold`): collapse/expand ─────────────────────────────────── + + #[test] + fn outline_toggle_fold_hides_the_headers_files_and_move_by_skips_them() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + // Row order (BaseFirst): [Header cs-a, File a1, File a2, Header cs-b, File b1]. + let rows_before = app.outline_items().len(); + assert_eq!(rows_before, 5); + + app.outline.cursor = 3; // cs-b's header + app.outline_confirm(); // toggle fold + let items = app.outline_items(); + assert_eq!(items.len(), 4, "cs-b's single file row is now hidden"); + assert!( + items + .iter() + .all(|it| !matches!(it, OutlineItem::File { cs_idx: 1, .. })), + "no cs-b file row should be reachable while its header is collapsed" + ); + + // `j` from the last visible row (now the folded header, index 3) must clamp there — there + // is nothing further to move onto. + app.outline.cursor = 3; + app.outline_move_by(5); + assert_eq!( + app.outline.cursor, 3, + "the cursor clamps at the collapsed header — b1.txt's row isn't in the index space \ + to land on at all" + ); + } + + #[test] + fn outline_toggle_fold_expanding_again_restores_every_row() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let rows_before = app.outline_items().len(); + + app.outline.cursor = 3; + app.outline_confirm(); // collapse + assert!(app.outline_items().len() < rows_before); + app.outline_confirm(); // expand again + assert_eq!( + app.outline_items(), + { + app.outline.folds.clear(); + app.outline_items() + }, + "re-expanding must reproduce exactly the same rows an empty fold set would" + ); + } + + #[test] + fn outline_fold_state_is_independent_per_mode() { + // Folding cs-b's header in Stack mode must not affect StackTree's own (separate) fold + // set, even though both modes emit a Header row keyed by the SAME label. + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header in Stack mode + app.outline_confirm(); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty()), + "Stack mode's own fold set recorded the toggle" + ); + + app.outline.mode = OutlineMode::StackTree; + assert!( + app.outline + .folds + .get(&OutlineMode::StackTree) + .is_none_or(|s| s.is_empty()), + "StackTree mode must start with its OWN empty fold set, untouched by Stack mode's" + ); + let stack_tree_items = app.outline_items(); + assert!( + stack_tree_items + .iter() + .any(|it| matches!(it, OutlineItem::File { cs_idx: 1, .. })), + "cs-b's file row must still be visible in StackTree mode — Stack mode's fold doesn't \ + leak across modes" + ); + } + + #[test] + fn sync_outline_to_current_lands_on_the_collapsed_ancestor_without_auto_expanding() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let header_b = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's header present"); + app.outline.cursor = header_b; + app.outline_confirm(); // collapse cs-b's header + assert!( + app.outline_focused(), + "toggling a fold keeps focus (CS5) — sanity for the nav below" + ); + + // A diff-initiated nav lands the diff on cs-b's (now-hidden) first file. + app.next_changeset(); + assert_eq!(app.current_cs(), 1, "the diff itself did jump to cs-b"); + + let folded_header_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 1, .. })) + .expect("cs-b's collapsed header row still present"); + assert_eq!( + app.outline_cursor(), + folded_header_idx, + "the outline cursor must land on cs-b's collapsed header row, not an arbitrary clamp" + ); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty()), + "landing on the collapsed ancestor must NOT auto-expand it" + ); + } + + #[test] + fn outline_stage_targets_the_correct_row_when_an_unrelated_header_is_folded() { + // The highest-risk CS5 interaction: folding one changeset's header shifts every LATER + // row's index in `outline_items()` — a stage/discard verb resolved against a stale + // (unfiltered) index space would silently act on the wrong file. `outline_stage` reads + // `outline_row_targets`, which reads `outline_items()` at the CURSOR's own index — the + // same fold-filtered list the cursor itself was placed against — so it must stay correct. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .unstaged_file("dirty.txt", "one\n", "one\nCHANGED\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + repo.set_head("refs/heads/a").unwrap(); + repo.checkout_head(None).unwrap(); + + let changesets = crate::acquire::resolve_changesets(repo, "a").unwrap(); + assert_eq!( + changesets.len(), + 2, + "expected the 'a' Graphite node plus the dirty tree's uncommitted layer" + ); + let diffs = crate::acquire::diff_changesets(repo, &changesets).unwrap(); + let views: Vec = changesets + .into_iter() + .zip(diffs) + .map(|(cs, diff)| ChangesetView::from_changeset_diff(cs, diff)) + .collect(); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + + // Fold the committed "a" node's header — hides its own file row, shifting dirty.txt's + // row index one earlier in the filtered list. + let header_a = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::Header { cs_idx: 0, .. })) + .expect("'a's header present"); + app.outline.cursor = header_a; + app.outline_confirm(); + + let dirty_idx = app + .outline_items() + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "dirty.txt")) + .expect("dirty.txt's row is still visible — its own header isn't folded"); + app.outline.cursor = dirty_idx; + + app.outline_stage(); + + assert!(app.notice.is_none(), "stage must succeed: {:?}", app.notice); + repo.assert(predicate::repo::has_staged_file("dirty.txt")); + } + // ── CS8: progressive gap expansion ────────────────────────────────────── /// A single-file fixture with two hunks separated by a wide (40-line) unchanged run — wide diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 48a5bd5b..353e380d 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -328,7 +328,7 @@ pub static REGISTRY: &[Registered] = &[ view: View::Outline, name: "open", default_keys: "enter", - description: "Jump to the selected outline entry", + description: "Jump to a file, or fold/unfold a header or directory", }, Registered { command: Command::OutlineCycleMode, diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 8fcf93d3..5b80597e 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -12,6 +12,15 @@ //! `crate::model::FileStatus` keeps this module's pure-data posture intact: `model.rs` is itself //! a pure data module (no `App`/`ChangesetView` dependency), so importing its plain enum doesn't //! reintroduce the `App` coupling this module was factored out to avoid. +//! +//! CS5 (`outline-fold`) also adds a second stage layered on top of [`build_items`]: collapse/ +//! expand. [`build_items`] itself stays wholly unaware of fold state (its extensive mode/dedup/ +//! guide tests below are untouched by CS5) — [`apply_fold`] takes its output and a per-row +//! collapsed predicate and returns the filtered row list plus the two extra pieces of data render/ +//! cursor logic needs (a collapsed row's hidden-file count, and a full-list -> filtered-list index +//! map for re-finding a fold-hidden target). [`fold_outline`] is the two steps composed — +//! `App::outline_items`'s single entry point (see that method's doc comment for why every +//! cursor/staging/render consumer funnels through the SAME filtered list). use std::collections::HashMap; @@ -19,7 +28,7 @@ use crate::model::FileStatus; /// Which of the outline's row-building strategies is active — cycled by `i` (only while the /// outline pane has focus; see `App::outline_cycle_mode`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum OutlineMode { /// Every changed path across the whole stack, once each, no changeset headers. Flat, @@ -196,9 +205,10 @@ pub enum OutlineItem { }, /// A directory row — only emitted in [`OutlineMode::Tree`]/[`OutlineMode::StackTree`]. Not a /// jump target: it carries no `file_idx`, so `App::outline_move_by` no-ops on it (same as - /// [`Self::Header`]) and `App::outline_confirm` also no-ops on it (CS4 decision — there's no - /// expand/collapse state to toggle, so Enter on a directory row does nothing but still - /// returns focus to the diff, matching every other confirm outcome). + /// [`Self::Header`]); `App::outline_confirm` toggles this row's fold state instead of jumping + /// (CS5, `outline-fold`) and deliberately does NOT return focus to the diff — see that + /// method's doc comment. Fold state itself lives on `App` (per-[`OutlineMode`] sets keyed by + /// [`FoldKey`]), not here — this row stays a plain data snapshot either way. Dir { name: String, /// The FULL path from the trie root (e.g. `"src/cmd"`), unlike `name` which is just the @@ -248,7 +258,11 @@ impl OutlineItem { /// (that array's own base -> head storage order never changes) regardless of `order` — only the /// ROW SEQUENCE the outline paints flips. [`build_tree`]'s de-dupe is order-independent (see its /// own doc comment), so `order` is accepted but unused there. -pub fn build_items( +/// +/// `pub(crate)` (CS5): this is the "unfiltered build" [`fold_outline`]'s doc comment refers to — +/// every outside-the-module consumer (i.e. `App`) goes through `fold_outline`/`apply_fold` +/// instead, so a fold is never accidentally bypassed by calling this directly. +pub(crate) fn build_items( changesets: &[OutlineChangeset], mode: OutlineMode, order: OutlineOrder, @@ -261,6 +275,186 @@ pub fn build_items( } } +// ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────────── + +/// A foldable outline row's identity — the key `App`'s per-[`OutlineMode`] fold sets store. +/// [`OutlineItem::Header`] is keyed by its changeset's label PLUS its `cs_idx`; [`OutlineItem::Dir`] +/// by its full path plus, in [`OutlineMode::StackTree`], its owning changeset's `cs_idx` (`None` +/// in [`OutlineMode::Tree`], mirroring [`OutlineItem::Dir::cs_idx`]'s own `Option` — that mode's +/// single trie has no one owning changeset to key by). +/// +/// `cs_idx` is load-bearing here, not just belt-and-suspenders: a changeset's own `label` is NOT +/// unique across a single snapshot — [`crate::acquire::uncommitted_changeset`] and Graphite's +/// `insert_uncommitted_layer` both name the synthetic uncommitted-layer changeset after the SAME +/// branch its committed node is named after (no title on either), so a branch's committed node +/// and its own uncommitted worktree layer are two DIFFERENT rows that render the identical label. +/// Keying by label alone would fold both together the moment either was toggled. `cs_idx` is +/// still the true index into `App::changesets` (stable across an ordinary refresh — only a +/// structural stack change, e.g. a changeset added/removed, shifts it), matching the same +/// "identity survives refresh via `cs_idx`" precedent [`crate::app::OutlineRowIdentity`] already +/// relies on for staging-verb restore. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FoldKey { + Header { label: String, cs_idx: usize }, + Dir { path: String, owner: Option }, +} + +impl FoldKey { + /// `item`'s [`FoldKey`], or `None` for a [`OutlineItem::File`] row (never foldable — it + /// carries no fold state of its own). Reads only fields the item already carries on itself + /// (`cs_idx`, `label`/`path`) — no external lookup needed. `pub(crate)`: also + /// `App::outline_toggle_fold`'s way of turning "the row under the cursor" into the key its + /// fold set is keyed by, without duplicating this match. + pub(crate) fn for_item(item: &OutlineItem) -> Option { + match item { + OutlineItem::Header { cs_idx, label, .. } => Some(FoldKey::Header { + label: label.clone(), + cs_idx: *cs_idx, + }), + OutlineItem::Dir { path, cs_idx, .. } => Some(FoldKey::Dir { + path: path.clone(), + owner: *cs_idx, + }), + OutlineItem::File { .. } => None, + } + } +} + +/// The outline's row list after CS5's fold filtering is layered on top of [`build_items`]'s raw +/// build — see [`apply_fold`]/[`fold_outline`]'s doc comments for how it's derived, and +/// `App::outline_items`'s doc comment for why this is the SINGLE choke point every cursor/ +/// staging/render consumer reads through. +#[derive(Debug, Clone)] +pub(crate) struct FoldedOutline { + /// The visible rows, in order — a subsequence of [`build_items`]'s full (unfiltered) output. + pub items: Vec, + /// Parallel to `items`: the count of hidden FILE rows (not dirs — CS5's locked "N = hidden + /// FILE rows only" rule) under a collapsed Header/Dir row. `0` for every other row, including + /// an EXPANDED Header/Dir — render reads `0` as "no marker", so an expanded row never draws + /// the trailing ` ▸ N` chevron. + pub hidden_counts: Vec, + /// Parallel to the FULL (unfiltered) [`build_items`] output, NOT to `items`: for original row + /// `i`, the index into `items`/`hidden_counts` a cursor targeting that row should land on — + /// its own filtered position if it survived filtering, or its nearest VISIBLE ancestor's if a + /// fold hides it (CS5's "lands on the collapsed ancestor without auto-expanding" rule). Used + /// by `App::sync_outline_to_current` to re-target a diff-initiated jump onto a folded row's + /// row instead of leaving the outline cursor on an arbitrary clamp. + pub visible_index: Vec, +} + +/// Filter `items` (a fresh [`build_items`] call's output) down to the rows `is_folded`'s per-mode +/// fold set leaves visible, computing each collapsed row's hidden-file marker and the full-list -> +/// filtered-list index map described on [`FoldedOutline::visible_index`]. Needs no `changesets` +/// snapshot of its own — [`FoldKey::for_item`] reads only what each item already carries on +/// itself (see that fn's doc comment on why `cs_idx`, not a label lookup, is what disambiguates). +/// +/// One linear pass with an explicit stack of "open ancestor" frames, mirroring [`emit`]'s own +/// depth-first row order: a [`OutlineItem::Header`] frame's scope is "everything up to the next +/// Header" (depth `-1`, a sentinel shallower than every real tree depth); a [`OutlineItem::Dir`] +/// frame's scope is "everything with a deeper tree `guides` prefix than its own" (its +/// [`OutlineItem::depth`]). Both close the same way: popping frames whose recorded depth is `>=` +/// the current row's depth, since a shallower-or-equal row can't be that frame's descendant. A row +/// hidden by ANY currently-open ancestor being folded is dropped from the output entirely, but a +/// hidden File row still bumps every open ancestor's running hidden-file count (even an unfolded +/// one — that count is simply never read unless the frame turns out to be folded when it's +/// popped), so a doubly-nested fold's OUTER marker still counts files hidden two levels down. +pub(crate) fn apply_fold( + items: &[OutlineItem], + is_folded: impl Fn(&FoldKey) -> bool, +) -> FoldedOutline { + struct Frame { + depth: isize, + folded: bool, + hidden: usize, + /// Index into the output `items`/`hidden_counts` this frame's OWN row landed at — `None` + /// if the frame's own row was itself hidden by a still-further-out fold (a doubly-nested + /// collapse), in which case it never got a marker to write into. + out_idx: Option, + } + + /// Write a popped frame's final hidden-file count into its own row's marker slot — only if + /// the frame is folded (an expanded frame's count is dead data, never read) and was itself + /// visible (`out_idx: Some`; a hidden frame has no marker slot to write into at all). + fn finalize(frame: Frame, hidden_counts: &mut [usize]) { + if frame.folded { + if let Some(idx) = frame.out_idx { + hidden_counts[idx] = frame.hidden; + } + } + } + + let mut stack: Vec = Vec::new(); + let mut out_items: Vec = Vec::new(); + let mut hidden_counts: Vec = Vec::new(); + let mut visible_index: Vec = Vec::with_capacity(items.len()); + + for item in items { + let depth: isize = match item { + OutlineItem::Header { .. } => -1, + OutlineItem::Dir { .. } | OutlineItem::File { .. } => item.depth() as isize, + }; + while stack.last().is_some_and(|f| f.depth >= depth) { + finalize( + stack.pop().expect("just checked the stack is non-empty"), + &mut hidden_counts, + ); + } + + let hidden = stack.iter().any(|f| f.folded); + if hidden && matches!(item, OutlineItem::File { .. }) { + for f in &mut stack { + f.hidden += 1; + } + } + + let out_idx = + if hidden { + stack.iter().rev().find_map(|f| f.out_idx).expect( + "row 0 of any build is always visible, so some open ancestor must be too", + ) + } else { + let idx = out_items.len(); + out_items.push(item.clone()); + hidden_counts.push(0); + idx + }; + visible_index.push(out_idx); + + if let OutlineItem::Header { .. } | OutlineItem::Dir { .. } = item { + let key = FoldKey::for_item(item) + .expect("just matched Header/Dir, both of which always resolve a FoldKey"); + stack.push(Frame { + depth, + folded: is_folded(&key), + hidden: 0, + out_idx: if hidden { None } else { Some(out_idx) }, + }); + } + } + while let Some(frame) = stack.pop() { + finalize(frame, &mut hidden_counts); + } + + FoldedOutline { + items: out_items, + hidden_counts, + visible_index, + } +} + +/// [`build_items`] + [`apply_fold`] composed — `App::outline_items`'s (and its private +/// `App::outline_folded` helper's) single entry point, so `app.rs` never has to import both +/// functions and remember to always pair them. +pub(crate) fn fold_outline( + changesets: &[OutlineChangeset], + mode: OutlineMode, + order: OutlineOrder, + is_folded: impl Fn(&FoldKey) -> bool, +) -> FoldedOutline { + let items = build_items(changesets, mode, order); + apply_fold(&items, is_folded) +} + /// [`OutlineMode::Stack`]: a header per changeset, then its files in order — no de-duplication, /// every changeset's own copy of a path (if touched more than once across the stack) gets its /// own row under its own header. `order` picks which end of the stack paints first; `cs_idx`/ @@ -1051,4 +1245,246 @@ mod tests { "cs-b's own file follows immediately under its head-first header" ); } + + // ── Fold (collapse/expand), CS5 `outline-fold` ────────────────────────────── + + #[test] + fn apply_fold_with_nothing_folded_leaves_every_row_visible_with_zero_markers() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, true, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |_| false); + assert_eq!( + folded.items, items, + "nothing folded, so nothing is filtered" + ); + assert!( + folded.hidden_counts.iter().all(|&n| n == 0), + "no collapsed row, so no marker anywhere" + ); + assert_eq!( + folded.visible_index, + (0..items.len()).collect::>(), + "every row maps onto its own (only) position" + ); + } + + #[test] + fn apply_fold_hides_a_collapsed_headers_files_and_marks_the_hidden_count() { + let changesets = vec![ + cs( + "cs-a", + false, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + ), + cs("cs-b", true, false, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }); + assert_eq!( + folded.items.len(), + 3, + "cs-a's header survives (its 2 files hidden); cs-b's header AND its own file both \ + survive (cs-b isn't folded)" + ); + assert!(matches!( + folded.items[0], + OutlineItem::Header { ref label, .. } if label == "cs-a" + )); + assert_eq!( + folded.hidden_counts[0], 2, + "cs-a's collapsed header marks its 2 hidden files" + ); + assert!(matches!( + folded.items[1], + OutlineItem::Header { ref label, .. } if label == "cs-b" + )); + assert_eq!(folded.hidden_counts[1], 0, "cs-b is not collapsed"); + assert!(matches!( + folded.items[2], + OutlineItem::File { ref path, .. } if path == "b1.txt" + )); + } + + #[test] + fn apply_fold_leaves_a_sibling_headers_files_untouched() { + let changesets = vec![ + cs("cs-a", false, false, &[("a1.txt", StagedStatus::None)]), + cs("cs-b", true, false, &[("b1.txt", StagedStatus::None)]), + ]; + let items = build_items(&changesets, OutlineMode::Stack, OutlineOrder::BaseFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }); + let paths: Vec<&str> = folded + .items + .iter() + .filter_map(|it| match it { + OutlineItem::File { path, .. } => Some(path.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + paths, + vec!["b1.txt"], + "cs-b's own file stays visible; only cs-a's collapsed section is hidden" + ); + } + + #[test] + fn apply_fold_collapsing_a_dir_hides_its_nested_files_and_subdirs_but_counts_only_files() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + // `src` (depth 0) contains `src/a` (a nested dir, depth 1) and `src/d.rs`, and `src/a` + // itself contains `src/a/b.rs` + `src/a/c.rs` — collapsing `src` should hide all 3 files + // (b.rs, c.rs, d.rs) it contains at any depth, but the marker counts files only, not the + // nested `src/a` dir row itself. + let folded = apply_fold(&items, |key| { + *key == FoldKey::Dir { + path: "src".to_string(), + owner: None, + } + }); + assert_eq!( + folded.items.len(), + 2, + "src/ (collapsed) and top.rs (an unrelated sibling) survive" + ); + let src_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ row survives collapsed"); + assert_eq!( + folded.hidden_counts[src_idx], 3, + "b.rs, c.rs, and d.rs are all hidden under collapsed src/ — src/a/ itself doesn't count" + ); + } + + #[test] + fn apply_fold_doubly_nested_collapse_still_counts_toward_the_outer_markers_hidden_files() { + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + // Collapse BOTH `src` and its nested `src/a` — `src/a`'s own row is hidden (nested inside + // the already-collapsed `src`), but its 2 files must still count toward `src`'s own + // marker, even though `src/a`'s marker is never written (it has no visible row to write + // into). + let folded = apply_fold(&items, |key| { + matches!( + key, + FoldKey::Dir { path, owner: None } if path == "src" || path == "src/a" + ) + }); + assert_eq!( + folded.items.len(), + 2, + "only src/ (collapsed) and top.rs survive; src/a/ is hidden under src/'s own fold" + ); + let src_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ row survives collapsed"); + assert_eq!( + folded.hidden_counts[src_idx], 3, + "src/'s marker still counts all 3 descendant files, even the 2 nested two levels down \ + under the also-collapsed (and therefore invisible) src/a/" + ); + } + + #[test] + fn apply_fold_visible_index_maps_a_hidden_files_full_list_position_to_its_visible_ancestor() { + // `deep_path_changeset`'s full (unfolded) Tree-mode row order is exactly: + // [src/ (0), src/a/ (1), src/a/b.rs (2), src/a/c.rs (3), src/d.rs (4), top.rs (5)] — see + // `tree_mode_builds_dirs_before_files_alpha_within_group_with_correct_depth_and_guides` + // above, which pins this same order. Collapsing `src/` hides everything at indices 1..=4 + // (all nested under it, regardless of their own depth); index 5 (`top.rs`) is a sibling, + // untouched. + let changesets = vec![deep_path_changeset("cs-a", true, false)]; + let items = build_items(&changesets, OutlineMode::Tree, OutlineOrder::HeadFirst); + let folded = apply_fold(&items, |key| { + *key == FoldKey::Dir { + path: "src".to_string(), + owner: None, + } + }); + let src_visible_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::Dir { name, .. } if name == "src")) + .expect("src/ survives collapsed"); + + assert_eq!( + folded.visible_index[0], src_visible_idx, + "src/'s own row maps onto itself" + ); + for (full_idx, item) in items.iter().enumerate().take(5).skip(1) { + assert_eq!( + folded.visible_index[full_idx], src_visible_idx, + "row {full_idx} ({item:?}) is hidden under collapsed src/, so it must map onto \ + src/'s own visible row" + ); + } + let top_rs_visible_idx = folded + .items + .iter() + .position(|it| matches!(it, OutlineItem::File { path, .. } if path == "top.rs")) + .expect("top.rs survives, unaffected by src/'s fold"); + assert_eq!( + folded.visible_index[5], top_rs_visible_idx, + "top.rs (a sibling of src/, not nested under it) maps onto its own visible row" + ); + } + + #[test] + fn fold_outline_composes_build_items_and_apply_fold() { + let changesets = vec![cs( + "cs-a", + true, + false, + &[ + ("a1.txt", StagedStatus::None), + ("a2.txt", StagedStatus::None), + ], + )]; + let folded = fold_outline( + &changesets, + OutlineMode::Stack, + OutlineOrder::HeadFirst, + |key| { + *key == FoldKey::Header { + label: "cs-a".to_string(), + cs_idx: 0, + } + }, + ); + assert_eq!( + folded.items, + vec![OutlineItem::Header { + cs_idx: 0, + n: 1, + label: "cs-a".to_string(), + current: true, + needs_restack: false, + loading: false, + failed: false, + }], + "the header survives collapsed; both files are hidden" + ); + assert_eq!(folded.hidden_counts, vec![2]); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5db2605c..30037802 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -739,7 +739,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`outline_status_spans`]'s doc comment for the X/Y-vs-single-letter split), and /// the path — Flat/Stack rows (CS2) split it into `basename dim/dirname` (no suffix for a /// root-level file); Tree/StackTree rows already carry the directory via ancestor Dir rows, so -/// `path` there is just the bare basename. The cursor row (the outline's OWN cursor — a separate coordinate space from the +/// `path` there is just the bare basename. A COLLAPSED [`OutlineItem::Header`]/[`OutlineItem::Dir`] +/// row (CS5, `outline-fold`) additionally carries a trailing dim ` ▸ N` (`N` = hidden FILE rows +/// only), from [`App::outline_items_with_hidden_counts`]'s per-row marker count — an expanded row +/// gets no chevron at all. The cursor row (the outline's OWN cursor — a separate coordinate space from the /// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer /// [`Palette::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). `&mut App` (CS2, precedent: [`render_body`] @@ -750,7 +753,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { app.outline_height = area.height as usize; app.hit_regions.outline = Some(region_from(area)); - let items = app.outline_items(); + let (items, hidden_counts) = app.outline_items_with_hidden_counts(); // Bounds-clamp only — NOT a cursor-following derive: under the wheel's peek model a // scrolled-away viewport must survive the frame; cursor ops re-derive on their own. app.clamp_outline_scroll(items.len()); @@ -765,7 +768,8 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) // small (file trees, not file contents), so re-measuring the whole thing here is cheap. let max_line_width = items .iter() - .map(|item| build_outline_line(item, theme, icons).width()) + .zip(&hidden_counts) + .map(|(item, &hidden)| build_outline_line(item, theme, icons, hidden).width()) .max() .unwrap_or(0); app.clamp_outline_hscroll(max_line_width); @@ -778,8 +782,9 @@ fn render_outline(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) let Some(item) = items.get(item_idx) else { continue; }; + let hidden = hidden_counts.get(item_idx).copied().unwrap_or(0); let is_cursor = item_idx == cursor; - let line = build_outline_line(item, theme, icons); + let line = build_outline_line(item, theme, icons, hidden); let line = Line::from(pan_spans(line.spans, hscroll, theme)); let line = if is_cursor && focused { apply_cursor_row(line, area.width, theme) @@ -897,11 +902,32 @@ fn outline_status_spans( } } +/// CS5 (`outline-fold`): a collapsed Header/Dir row's trailing marker — dim ` ▸ N`, `N` being the +/// count of hidden FILE rows (not dirs) [`App::outline_items_with_hidden_counts`] attached to that +/// row. `None` for `hidden == 0` (an EXPANDED Header/Dir — or a File row, which never carries a +/// hidden count at all) — the locked "no chevron when expanded" rule reads a zero count as "don't +/// draw a marker" rather than "draw ` ▸ 0`". +fn fold_marker(hidden: usize, theme: &Palette) -> Option> { + (hidden > 0).then(|| { + TSpan::styled( + format!(" \u{25b8} {hidden}"), + Style::default().fg(theme.dim), + ) + }) +} + /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the /// marker rules. `icons` (CS5, `workon.review.icons`) is [`IconMode::None`] by /// default, which reproduces the pre-CS5 row text exactly (no icon glyph, no extra space); only -/// [`IconMode::Nerd`] inserts an icon before the name/path. -fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> Line<'static> { +/// [`IconMode::Nerd`] inserts an icon before the name/path. `hidden` (CS5, `outline-fold`) is the +/// row's collapsed hidden-file count from [`App::outline_items_with_hidden_counts`] — `0` for +/// every row that isn't a collapsed Header/Dir; see [`fold_marker`]. +fn build_outline_line( + item: &OutlineItem, + theme: &Palette, + icons: IconMode, + hidden: usize, +) -> Line<'static> { match item { OutlineItem::Header { cs_idx, @@ -933,6 +959,7 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L Style::default().fg(theme.dim), )); } + spans.extend(fold_marker(hidden, theme)); Line::from(spans) } OutlineItem::Dir { name, guides, .. } => { @@ -941,12 +968,14 @@ fn build_outline_line(item: &OutlineItem, theme: &Palette, icons: IconMode) -> L IconMode::None => String::new(), }; let text = format!("{}{icon}{name}/", tree_prefix(guides)); - Line::from(TSpan::styled( + let mut spans = vec![TSpan::styled( text, Style::default() .fg(theme.dim) .add_modifier(Modifier::ITALIC), - )) + )]; + spans.extend(fold_marker(hidden, theme)); + Line::from(spans) } OutlineItem::File { path, @@ -3846,6 +3875,115 @@ mod tests { ); } + // ── CS5 (`outline-fold`): collapse/expand marker ──────────────────────────────── + + #[test] + fn outline_collapsed_header_renders_a_trailing_dim_hidden_file_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.set_outline_order(crate::outline::OutlineOrder::BaseFirst); + app.focus_outline(); + app.outline_top(); // cs-a's header row (BaseFirst: cs-a's header renders first) + app.outline_confirm(); // toggle fold — collapses cs-a, hiding its single file (a.txt) + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — it names the current file too (e.g. `[i/n] path`), which can + // false-positive a bare `contains` search, same gotcha `render_outline_file_row` already + // documents. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let (row_idx, header_row) = content + .iter() + .enumerate() + .find(|(_, r)| r.contains("Add a")) + .map(|(i, r)| (i, r.clone())) + .expect("cs-a's header row present"); + let y = row_idx as u16 + 1; // +1 to undo the y=0 skip above. + assert!( + header_row.contains("\u{25b8} 1"), + "collapsed header must show its 1 hidden file, got: {header_row:?}" + ); + assert!( + !content.iter().any(|r| r.contains("a.txt")), + "a.txt's row must be hidden while its header is collapsed, got:\n{}", + content.join("\n") + ); + + let row_chars: Vec = header_row.chars().collect(); + let marker_x = row_chars + .iter() + .position(|&c| c == '\u{25b8}') + .expect("marker glyph present") as u16; + assert_eq!( + buf.cell((marker_x, y)).unwrap().style().fg, + Some(Palette::dark().dim), + "expected the collapsed marker to carry theme.dim, got: {header_row:?}" + ); + } + + #[test] + fn outline_expanded_header_renders_no_chevron_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — see the gotcha noted above. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + assert!( + !content.iter().any(|r| r.contains('\u{25b8}')), + "no row should carry the collapsed marker while every Header/Dir is expanded, got:\n{}", + content.join("\n") + ); + } + + #[test] + fn outline_collapsed_dir_renders_a_trailing_dim_hidden_file_marker() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = changeset_with_nested_paths(&fixture); + if !app.outline_open() { + app.toggle_outline(); + } + app.outline_cycle_mode(); // Stack -> StackTree + app.outline_cycle_mode(); // StackTree -> Flat + app.outline_cycle_mode(); // Flat -> Tree + assert_eq!(app.outline_mode(), crate::outline::OutlineMode::Tree); + + app.focus_outline(); + app.outline_top(); // src/ (dirs-before-files root ordering — see the tree-guide test above) + app.outline_confirm(); // toggle fold — collapses src/, hiding its one file (a.txt) + + let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); + // Skip y=0 (the winbar) — its OWN current-file label can itself contain `src/` (e.g. + // `[1/1] src/a.txt`) and false-positive the `contains("src/")` search below if included. + let content: Vec = (1..buf.area.height).map(|y| outline_row(&buf, y)).collect(); + let dir_row = content + .iter() + .find(|r| r.contains("src/")) + .expect("src/ row present"); + assert!( + dir_row.contains("\u{25b8} 1"), + "collapsed src/ must show its 1 hidden file, got: {dir_row:?}" + ); + assert!( + !content.iter().any(|r| r.contains("a.txt")), + "a.txt must be hidden under collapsed src/, got:\n{}", + content.join("\n") + ); + assert!( + content.iter().any(|r| r.contains("top.txt")), + "top.txt (a sibling, not nested under src/) must remain visible, got:\n{}", + content.join("\n") + ); + } + // ── CS2 (outline-row-shape): smart path render ───────────────────────────────── #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index dfe19718..80056b9b 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -2418,15 +2418,26 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - // CS3: pin BaseFirst explicitly — this test exercises Enter's header-jump + focus - // return, which is orthogonal to display order, but the `-3` row offset below assumes - // the base->head row layout. + // CS3: pin BaseFirst explicitly — this test exercises Enter's File-row jump + focus + // return, which is orthogonal to display order, but the row offset below assumes the + // base->head row layout. app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); app.toggle_outline(); // close app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row assert!(app.outline_focused()); - // Move the outline cursor up onto cs-a's header row. - app.outline_move_by(-3); + // Move the outline cursor onto cs-a's FILE row (rows, BaseFirst: [Header a, File a.txt, + // Header b, File b.txt] — cursor starts at 3; -2 lands on File a.txt at row 1). CS5 + // (`outline-fold`) removed Enter's old header-jump behavior — see + // `enter_on_a_header_row_toggles_fold_and_keeps_focus` below for that case — so this + // keybinding-dispatch test needs a File row to still exercise a real jump+unfocus. + app.outline_move_by(-2); + assert_eq!( + app.current_cs(), + 0, + "sanity: the move itself already landed on cs-a's file (moving onto a File row \ + always jumps, per `outline_move_by`'s own contract) — Enter below re-confirms the \ + same jump through the real keybinding-dispatch path" + ); let km = Keymap::defaults(); let mut pending: Vec = Vec::new(); @@ -2440,12 +2451,63 @@ mod tests { assert_eq!( app.current_cs(), 0, - "Enter on cs-a's header must jump there" + "Enter on a File row must (still) land on cs-a" ); - assert_eq!(app.current, 0, "...landing on its first file"); + assert_eq!(app.current, 0, "...landing on its file"); assert!( !app.outline_focused(), - "Enter returns focus to the diff after jumping" + "Enter on a File row returns focus to the diff" + ); + } + + #[test] + fn enter_on_a_header_row_toggles_fold_and_keeps_focus() { + // CS5 (`outline-fold`): Enter on a Header/Dir row no longer jumps+unfocuses — it toggles + // that row's fold and deliberately keeps focus. This is the header-row counterpart to + // `enter_confirms_an_outline_jump_and_returns_focus_to_the_diff` above, verified through + // the same real keybinding-dispatch path (`update`/`map_key`), not a direct + // `App::outline_confirm()` call. + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = two_committed_changesets_app(&fixture); + app.set_outline_order(workon_review::outline::OutlineOrder::BaseFirst); + app.toggle_outline(); // close + app.toggle_outline(); // open + focus, cursor synced onto cs-b's file row + // Move the outline cursor onto cs-a's header row (rows, BaseFirst: [Header a, File a.txt, + // Header b, File b.txt] — cursor starts at 3; -3 lands on Header a at row 0, which never + // jumps). + app.outline_move_by(-3); + let before_cs = app.current_cs(); + let before_file = app.current; + let rows_before = app.outline_items().len(); + + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Enter)), + ); + + assert_eq!( + app.current_cs(), + before_cs, + "Enter on a header must NOT jump the diff (CS5)" + ); + assert_eq!(app.current, before_file); + assert!( + app.outline_focused(), + "Enter on a header toggles its fold and keeps focus (CS5), rather than confirming a \ + jump" + ); + assert!( + app.outline_items().len() < rows_before, + "cs-a's file row must now be hidden under its collapsed header" ); } From a612cf4cf9375ccd0b9c29021238f75f5632ac6d Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 12:34:42 -0400 Subject: [PATCH 17/19] fix(review): outline header label, indent, and marker polish --- git-workon-review/src/app.rs | 110 ++++++++++++++++++++++++++++--- git-workon-review/src/outline.rs | 20 +++--- git-workon-review/src/render.rs | 33 +++++----- git-workon-review/src/summary.rs | 5 +- 4 files changed, 131 insertions(+), 37 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index bd61ca33..42b206a4 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -1339,6 +1339,20 @@ pub struct Notice { pub severity: Severity, } +/// The one display-label rule for a changeset, shared by the outline header +/// ([`App::outline_snapshot`]), the summary panel ([`App::summary_for`]), and the winbar +/// (`render::render_winbar`): title, falling back to name — except the synthetic uncommitted +/// worktree layer, which is named after the SAME branch as its committed node (see +/// `workon::Changeset`'s `insert_uncommitted_layer` / [`crate::acquire::uncommitted_changeset`]) +/// and so renders as "Uncommitted changes" instead of duplicating that label. +pub(crate) fn display_label(cs: &Changeset) -> String { + if cs.span == ChangesetSpan::Uncommitted { + "Uncommitted changes".to_string() + } else { + cs.title.clone().unwrap_or_else(|| cs.name.clone()) + } +} + impl App { /// Build an [`App`] reviewing a single uncommitted changeset — the M2–M4 shape, and still /// what a non-Graphite (or clean-Graphite-tip) repo degrades to under M5's auto-detect @@ -2473,7 +2487,7 @@ impl App { self.changesets .iter() .map(|v| OutlineChangeset { - label: v.cs.title.clone().unwrap_or_else(|| v.cs.name.clone()), + label: display_label(&v.cs), current: v.cs.current, needs_restack: v.cs.needs_restack, loading: v.is_pending(), @@ -2571,11 +2585,7 @@ impl App { match target { SummaryTarget::Changeset(cs_idx) => { let view = &self.changesets[cs_idx]; - let label = view - .cs - .title - .clone() - .unwrap_or_else(|| view.cs.name.clone()); + let label = display_label(&view.cs); let failure_message = view.failure_message().map(|s| s.to_string()); Summary::Changeset(summary::changeset_summary( label, @@ -8786,11 +8796,15 @@ mod tests { /// A minimal [`Changeset`] descriptor for the slot tests below — the slot model only cares /// about the metadata `ChangesetView::pending`/`failed` carry alongside a diff-free - /// [`DiffState`], not any real git content. + /// [`DiffState`], not any real git content. The span must be a committed variant (zero OID + /// is fine, nothing diffs it) so the outline labels these by name rather than as the + /// "Uncommitted changes" layer. fn bare_changeset(name: &str, current: bool) -> Changeset { Changeset { name: name.to_string(), - span: ChangesetSpan::Uncommitted, + span: ChangesetSpan::CommittedRoot { + head: git2::Oid::ZERO_SHA1, + }, title: None, current, needs_restack: false, @@ -9121,6 +9135,86 @@ mod tests { assert_eq!(change_for("u1.txt"), FileStatus::Untracked); } + /// `outline_snapshot`'s label fallback (`title` else `name`) used to render the SAME label + /// for a branch's committed node and its own uncommitted worktree layer — both are named + /// after the same branch, no title on either (see `FoldKey`'s doc comment, outline.rs). The + /// uncommitted layer must instead say "Uncommitted changes", so the branch name appears + /// exactly once (on the committed node). + #[test] + fn outline_snapshot_labels_the_uncommitted_layer_uncommitted_changes_not_the_branch_name() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let base = fixture + .commit("main") + .file("base.txt", "b\n") + .create("base") + .unwrap(); + let head = fixture + .commit("main") + .file("c1.txt", "c1\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + std::fs::write(repo.workdir().unwrap().join("u1.txt"), "u1\n").unwrap(); + + let committed = Changeset { + name: "feature".to_string(), + span: ChangesetSpan::Committed { base, head }, + title: None, + current: false, + needs_restack: false, + }; + let uncommitted = Changeset { + name: "feature".to_string(), + span: ChangesetSpan::Uncommitted, + title: None, + current: true, + needs_restack: false, + }; + let view_c = ChangesetView::from_changeset_diff( + committed.clone(), + crate::acquire::diff_changeset(repo, &committed).unwrap(), + ); + let view_u = ChangesetView::from_changeset_diff( + uncommitted.clone(), + crate::acquire::diff_changeset(repo, &uncommitted).unwrap(), + ); + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_c, view_u]); + app.open_current(); + app.outline.mode = OutlineMode::Stack; + // Pin BaseFirst: this asserts an exact label vec, and display order is incidental here. + app.outline.order = OutlineOrder::BaseFirst; + + let labels: Vec = app + .outline_items() + .into_iter() + .filter_map(|it| match it { + OutlineItem::Header { label, .. } => Some(label), + _ => None, + }) + .collect(); + assert_eq!( + labels, + vec!["feature", "Uncommitted changes"], + "the committed node keeps the branch name; the uncommitted layer must say \ + \"Uncommitted changes\" instead of duplicating it" + ); + + // Label parity: the summary panel (and the winbar, which reads the same + // `display_label` helper) must agree with the outline header — the uncommitted layer + // is `current: true` in this fixture, so both non-outline surfaces target it. + let Summary::Changeset(summary) = app.summary_for(SummaryTarget::Changeset(1)) else { + panic!("expected a changeset summary for the uncommitted layer"); + }; + assert_eq!( + summary.label, "Uncommitted changes", + "the summary panel must use the same display-label rule as the outline header" + ); + } + #[test] fn outline_move_by_on_a_file_row_jumps_the_diff() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/outline.rs b/git-workon-review/src/outline.rs index 5b80597e..3a34f5de 100644 --- a/git-workon-review/src/outline.rs +++ b/git-workon-review/src/outline.rs @@ -157,8 +157,9 @@ pub struct OutlineFile { /// to know about [`crate::app::ChangesetView`] or `workon::Changeset` at all. #[derive(Debug, Clone)] pub struct OutlineChangeset { - /// The changeset's title, falling back to its name — same rule the winbar (render.rs) - /// already uses. + /// The changeset's display label (`crate::app::display_label` — title falling back to name, + /// with the uncommitted layer rendered as "Uncommitted changes"), the same rule the winbar + /// and summary panel use. pub label: String, /// Mirrors `workon::Changeset::current` — drives the outline's green current marker. pub current: bool, @@ -284,15 +285,12 @@ pub(crate) fn build_items( /// single trie has no one owning changeset to key by). /// /// `cs_idx` is load-bearing here, not just belt-and-suspenders: a changeset's own `label` is NOT -/// unique across a single snapshot — [`crate::acquire::uncommitted_changeset`] and Graphite's -/// `insert_uncommitted_layer` both name the synthetic uncommitted-layer changeset after the SAME -/// branch its committed node is named after (no title on either), so a branch's committed node -/// and its own uncommitted worktree layer are two DIFFERENT rows that render the identical label. -/// Keying by label alone would fold both together the moment either was toggled. `cs_idx` is -/// still the true index into `App::changesets` (stable across an ordinary refresh — only a -/// structural stack change, e.g. a changeset added/removed, shifts it), matching the same -/// "identity survives refresh via `cs_idx`" precedent [`crate::app::OutlineRowIdentity`] already -/// relies on for staging-verb restore. +/// guaranteed unique across a single snapshot in general (e.g. two changesets could otherwise +/// share a title), so keying by label alone would risk folding unrelated rows together the +/// moment either was toggled. `cs_idx` is still the true index into `App::changesets` (stable +/// across an ordinary refresh — only a structural stack change, e.g. a changeset added/removed, +/// shifts it), matching the same "identity survives refresh via `cs_idx`" precedent +/// [`crate::app::OutlineRowIdentity`] already relies on for staging-verb restore. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum FoldKey { Header { label: String, cs_idx: usize }, diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 30037802..bb24d2cd 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -42,8 +42,8 @@ use crate::wordiff::Span as WordSpan; // auto-detection ever picks Nerd for the user). Picked from the classic BMP nerd-font sets // (`fa`/`oct`) rather than devicons' broader (partly supplementary-plane) table, for wider // font compatibility — see `icons.rs`'s v3 doc note. -/// Nerd-mode "this is the current changeset" marker, replacing the plain `●` (U+25CF). -const NERD_CURRENT_MARKER: char = '\u{f111}'; // nf-fa-circle +/// Nerd-mode "this is the current changeset" marker, replacing the plain `•` (U+2022). +const NERD_CURRENT_MARKER: char = '\u{f444}'; // nf-oct-dot-fill /// Nerd-mode needs-restack marker, replacing the plain `⚠` (U+26A0). const NERD_WARN_MARKER: char = '\u{f071}'; // nf-fa-warning /// Nerd-mode failed-changeset marker, replacing the plain `✗` (U+2717). @@ -66,7 +66,7 @@ const NERD_DIFF_REMOVED: char = '\u{f458}'; // nf-oct-diff-removed fn current_marker(icons: IconMode) -> char { match icons { IconMode::Nerd => NERD_CURRENT_MARKER, - IconMode::None => '\u{25CF}', + IconMode::None => '\u{2022}', } } @@ -123,12 +123,13 @@ fn changeset_title_spans( icons: IconMode, counter: Option<(usize, usize)>, ) -> Vec> { - let marker = if current { - format!("{} ", current_marker(icons)) - } else { - " ".to_string() - }; - let mut spans = vec![TSpan::styled(marker, Style::default().fg(theme.current_fg))]; + let mut spans = Vec::new(); + if current { + spans.push(TSpan::styled( + format!("{} ", current_marker(icons)), + Style::default().fg(theme.current_fg), + )); + } if icons == IconMode::Nerd { spans.push(TSpan::styled( format!("{NERD_BRANCH_ICON} "), @@ -731,7 +732,7 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect } /// Render the outline side pane's rows into `area`: [`OutlineItem::Header`]s (Stack mode only) -/// carry the changeset's position marker (green ● for `cs.current`), a `[i/n]` TRUE-stack-position +/// carry the changeset's position marker (green • for `cs.current`), a `[i/n]` TRUE-stack-position /// counter, an accented ([`Palette::heading_fg`]) bold label (CS1, `outline-header-polish` — see /// [`changeset_title_spans`]'s doc comment), and needs-restack glyph (amber ⚠, /// [`crate::theme::Palette::warn_fg`] — locked decision #9's outline half); [`OutlineItem::File`]s carry an @@ -1122,7 +1123,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); - let title = cs.title.as_deref().unwrap_or(cs.name.as_str()); + let title = crate::app::display_label(cs); let icons = app.icon_mode(); let mut spans = vec![TSpan::styled( @@ -3478,9 +3479,9 @@ mod tests { let content: Vec = (0..buf.area.height).map(|y| outline_row(&buf, y)).collect(); let row = content .iter() - .position(|r| r.contains('\u{25CF}')) + .position(|r| r.contains('\u{2022}')) .expect("current marker present in the outline"); - let marker_x = content[row].find('\u{25CF}').unwrap() as u16; + let marker_x = content[row].find('\u{2022}').unwrap() as u16; assert_eq!( buf.cell((marker_x, row as u16)).unwrap().style().fg, Some(Palette::dark().current_fg), @@ -3562,7 +3563,7 @@ mod tests { .position(|r| r.contains("cs-b")) .expect("cs-b's header row present (it has no title, so falls back to its name)"); // `String::find` returns a BYTE offset, not a display column — the row has multi-byte - // glyphs (`●`/`⚠`) ahead of/around the label, so a byte offset would target the wrong + // glyphs (`•`/`⚠`) ahead of/around the label, so a byte offset would target the wrong // cell. Every rendered cell here is exactly one column wide, so a `chars()` (not byte) // position IS the display column. let label_chars: Vec = "cs-b".chars().collect(); @@ -3620,7 +3621,7 @@ mod tests { .position(|r| r.contains("cs-b")) .expect("summary panel's title (cs-b's label) present"); // `String::find` is a BYTE offset, not a display column (the title carries a multi-byte - // `●` marker ahead of the label, since cs-b is `current`) — a `chars()` position over the + // `•` marker ahead of the label, since cs-b is `current`) — a `chars()` position over the // 36.. slice IS the column offset within that slice (every cell here is one column wide), // so add the slice's own start column (36) back to get the absolute buffer column. let label_chars: Vec = "cs-b".chars().collect(); @@ -4641,7 +4642,7 @@ mod tests { "expected the nerd needs-restack marker, got:\n{joined}" ); assert!( - !joined.contains('\u{25CF}') && !joined.contains('\u{26A0}'), + !joined.contains('\u{2022}') && !joined.contains('\u{26A0}'), "nerd mode must not leave the plain unicode markers behind in the outline pane, got:\n{joined}" ); assert!( diff --git a/git-workon-review/src/summary.rs b/git-workon-review/src/summary.rs index b6eab27d..dea09925 100644 --- a/git-workon-review/src/summary.rs +++ b/git-workon-review/src/summary.rs @@ -9,8 +9,9 @@ //! //! `workon::Changeset` (see `git-workon-lib/src/changeset.rs`) exposes only `name`/`title` for a //! changeset today — no commit body/message. [`changeset_summary`] therefore renders the -//! label (title, falling back to name — the same rule the winbar/outline header already use) -//! plus the diffstat; there is no commit-message row. Surfacing the commit body would need a +//! label (`crate::app::display_label` — title falling back to name, with the uncommitted layer +//! rendered as "Uncommitted changes"; the same rule the winbar/outline header use) plus the +//! diffstat; there is no commit-message row. Surfacing the commit body would need a //! `repo.find_commit` lookup keyed off the changeset's head OID — left as a follow-up, not part //! of this changeset's scope. From f3c6330c32161a508eb998fff3c7b4043413318c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 15 Jul 2026 12:58:05 -0400 Subject: [PATCH 18/19] feat(review): outline changeset nav and fold-all keybindings --- git-workon-review/src/app.rs | 261 ++++++++++++++++++++++++++++++++ git-workon-review/src/keymap.rs | 93 +++++++++++- git-workon-review/src/tui.rs | 12 ++ 3 files changed, 364 insertions(+), 2 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 42b206a4..af3b9665 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -3036,6 +3036,37 @@ impl App { self.outline_move_to(last); } + /// `n` while the outline has focus: jump the cursor to the next [`OutlineItem::Header`] row + /// AFTER the current cursor position, or no-op (no wraparound) when there isn't one. Goes + /// through [`Self::outline_move_to`], so — like `g`/`G` — landing on a Header row never jumps + /// the diff (only a Header's own `Enter`/fold toggle or a File-row nav does that). + pub fn outline_next_changeset(&mut self) { + let items = self.outline_items(); + let cursor = self.outline.cursor; + if let Some(off) = items + .iter() + .skip(cursor + 1) + .position(|item| matches!(item, OutlineItem::Header { .. })) + { + self.outline_move_to(cursor + 1 + off); + } + } + + /// `p` while the outline has focus: jump the cursor to the next [`OutlineItem::Header`] row + /// BEFORE the current cursor position, or no-op (no wraparound) when there isn't one. The + /// counterpart to [`Self::outline_next_changeset`] — see its doc comment for the shared + /// no-diff-jump invariant. + pub fn outline_prev_changeset(&mut self) { + let items = self.outline_items(); + let cursor = self.outline.cursor; + if let Some(idx) = items[..cursor] + .iter() + .rposition(|item| matches!(item, OutlineItem::Header { .. })) + { + self.outline_move_to(idx); + } + } + /// `Enter` while the outline has focus: a FILE row jumps the diff straight there and returns /// focus to the diff (unchanged since CS3). A HEADER or DIR row instead TOGGLES that row's /// fold state (CS5, `outline-fold`) and deliberately does NOT return focus — you're @@ -3082,6 +3113,41 @@ impl App { self.derive_outline_scroll(self.outline_items().len()); } + /// `zM` while the outline has focus: collapse every foldable (Header/Dir) row of the CURRENT + /// [`OutlineMode`], unlike [`Self::outline_toggle_fold`]'s single-row flip. Scans the + /// UNFOLDED build ([`outline::build_items`] over the current snapshot — the same source + /// [`Self::outline_folded`] itself folds) rather than [`Self::outline_items`], so a row + /// already hidden under an existing fold still gets its own key recorded (collapsing + /// everything must be idempotent regardless of what's already collapsed). Unlike + /// [`Self::outline_toggle_fold`], this can hide the row the cursor itself sits on, so it + /// re-derives the cursor via [`Self::sync_outline_to_current`] (the same reseat + /// [`Self::outline_cycle_mode`] uses for its own row-list reshape) rather than trusting the + /// toggle's "only descendants move" invariant, which doesn't hold here. + pub fn outline_collapse_all(&mut self) { + let snapshot = self.outline_snapshot(); + let full = outline::build_items(&snapshot, self.outline.mode, self.outline.order); + let set = self.outline.folds.entry(self.outline.mode).or_default(); + for item in &full { + if let Some(key) = FoldKey::for_item(item) { + set.insert(key); + } + } + self.sync_outline_to_current(); + } + + /// `zR` while the outline has focus: expand every folded row of the CURRENT [`OutlineMode`] — + /// clears that mode's fold set entirely. See [`Self::outline_collapse_all`] for the cursor + /// reseat rationale (shared here too, even though expanding can only ever ADD rows, never + /// hide the cursor's own). + pub fn outline_expand_all(&mut self) { + self.outline + .folds + .entry(self.outline.mode) + .or_default() + .clear(); + self.sync_outline_to_current(); + } + // ── Outline staging (CS7) ─────────────────────────────────────────────────── /// Whether the changeset at `cs_idx` is a committed range rather than the uncommitted @@ -10552,6 +10618,201 @@ mod tests { ); } + // ── n/p (outline changeset nav) + zM/zR (collapse/expand all) ────────────── + + #[test] + fn outline_next_changeset_jumps_to_the_next_header_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + // Row order (BaseFirst): [Header cs-a, File a1, File a2, Header cs-b, File b1]. + app.outline.cursor = 1; // a1's row + let cursor_before = (app.current_cs(), app.current); + + app.outline_next_changeset(); + assert_eq!(app.outline.cursor, 3, "must land on cs-b's header row"); + assert_eq!( + (app.current_cs(), app.current), + cursor_before, + "a header landing must not jump the diff" + ); + } + + #[test] + fn outline_next_changeset_does_not_wrap_past_the_last_header() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header, the LAST header row + + app.outline_next_changeset(); + assert_eq!( + app.outline.cursor, 3, + "no next header to jump to — the cursor must not move" + ); + } + + #[test] + fn outline_prev_changeset_jumps_to_the_previous_header_without_jumping_the_diff() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 4; // b1's row + let cursor_before = (app.current_cs(), app.current); + + app.outline_prev_changeset(); + assert_eq!(app.outline.cursor, 3, "must land on cs-b's own header row"); + assert_eq!( + (app.current_cs(), app.current), + cursor_before, + "a header landing must not jump the diff" + ); + + app.outline_prev_changeset(); + assert_eq!(app.outline.cursor, 0, "must land on cs-a's header row"); + } + + #[test] + fn outline_prev_changeset_does_not_wrap_past_the_first_header() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 0; // cs-a's header, the FIRST header row + + app.outline_prev_changeset(); + assert_eq!( + app.outline.cursor, 0, + "no previous header to jump to — the cursor must not move" + ); + } + + #[test] + fn outline_collapse_all_folds_every_header_leaving_only_header_rows() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + assert_eq!(app.outline_items().len(), 5, "sanity: both stacks expanded"); + + app.outline_collapse_all(); + let items = app.outline_items(); + assert_eq!( + items.len(), + 2, + "only the two Header rows remain once every changeset is collapsed" + ); + assert!( + items + .iter() + .all(|it| matches!(it, OutlineItem::Header { .. })), + "every remaining row must be a Header row: {items:?}" + ); + } + + #[test] + fn outline_collapse_all_is_idempotent_when_a_header_is_already_folded() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 3; // cs-b's header + app.outline_confirm(); // pre-collapse cs-b only + + app.outline_collapse_all(); + assert_eq!( + app.outline_items().len(), + 2, + "collapse-all must still fold cs-a even though cs-b was already folded" + ); + } + + #[test] + fn outline_collapse_all_reseats_a_cursor_on_a_row_that_just_got_hidden() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + app.outline.cursor = 1; // a1's row — about to be hidden under cs-a's header + + app.outline_collapse_all(); + let items = app.outline_items(); + assert!( + app.outline.cursor < items.len(), + "the cursor must land inside the shrunk row list, not stay at a now-invalid index" + ); + assert!( + matches!( + items[app.outline.cursor], + OutlineItem::Header { cs_idx: 0, .. } + ), + "the cursor must reseat onto cs-a's collapsed header, the ancestor of the hidden row \ + it was on" + ); + } + + #[test] + fn outline_expand_all_restores_every_row() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + let rows_before = app.outline_items().len(); + + app.outline_collapse_all(); + assert!(app.outline_items().len() < rows_before); + + app.outline_expand_all(); + assert_eq!( + app.outline_items().len(), + rows_before, + "expand-all must restore every row collapse-all hid" + ); + assert!( + app.outline + .folds + .get(&OutlineMode::Stack) + .is_none_or(|s| s.is_empty()), + "expand-all must clear the CURRENT mode's fold set" + ); + } + + #[test] + fn outline_collapse_all_and_expand_all_are_scoped_to_the_current_mode() { + let mut app = two_committed_changesets_two_and_one_files(); + app.outline.mode = OutlineMode::Stack; + app.outline.order = OutlineOrder::BaseFirst; + app.outline.open = true; + app.outline.focused = true; + + app.outline_collapse_all(); + assert!(app + .outline + .folds + .get(&OutlineMode::Stack) + .is_some_and(|s| !s.is_empty())); + + app.outline.mode = OutlineMode::StackTree; + assert!( + app.outline + .folds + .get(&OutlineMode::StackTree) + .is_none_or(|s| s.is_empty()), + "Stack's collapse-all must not leak into StackTree's own fold set" + ); + } + #[test] fn outline_stage_targets_the_correct_row_when_an_unrelated_header_is_folded() { // The highest-risk CS5 interaction: folding one changeset's header shifts every LATER diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index 353e380d..d42b803f 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -81,6 +81,10 @@ pub enum Command { OutlineDiscard, OutlineHscrollLeft, OutlineHscrollRight, + OutlineNextChangeset, + OutlinePrevChangeset, + OutlineCollapseAll, + OutlineExpandAll, } /// One row of the action registry: a [`Command`] with its stable config identity (`view` + @@ -387,6 +391,34 @@ pub static REGISTRY: &[Registered] = &[ default_keys: ">", description: "Pan the outline right", }, + Registered { + command: Command::OutlineNextChangeset, + view: View::Outline, + name: "outline-next-changeset", + default_keys: "n", + description: "Jump to the next changeset", + }, + Registered { + command: Command::OutlinePrevChangeset, + view: View::Outline, + name: "outline-prev-changeset", + default_keys: "p", + description: "Jump to the previous changeset", + }, + Registered { + command: Command::OutlineCollapseAll, + view: View::Outline, + name: "outline-collapse-all", + default_keys: "zM", + description: "Collapse every changeset/directory in the outline", + }, + Registered { + command: Command::OutlineExpandAll, + view: View::Outline, + name: "outline-expand-all", + default_keys: "zR", + description: "Expand every changeset/directory in the outline", + }, ]; /// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is @@ -847,10 +879,15 @@ const DIFF_HINTS: &[HintItem] = &[ const OUTLINE_HINTS: &[HintItem] = &[ HintItem::Pair(Command::OutlineDown, Command::OutlineUp, "move"), HintItem::One(Command::OutlineConfirm, "open"), + HintItem::Pair( + Command::OutlineNextChangeset, + Command::OutlinePrevChangeset, + "changeset", + ), HintItem::One(Command::OutlineCycleMode, "mode"), - HintItem::One(Command::ToggleOutline, "outline"), + // No `o outline` / `q quit` here: with the changeset pair the full set no longer fits 80 + // cols, and both stay discoverable in the diff footer and the help overlay. HintItem::One(Command::ToggleHelp, "help"), - HintItem::One(Command::Quit, "quit"), ]; /// Build the persistent, always-visible footer hint string for `focused` ([`View::Diff`] or @@ -1089,6 +1126,50 @@ mod tests { ); } + #[test] + fn n_and_p_dispatch_outline_changeset_nav_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "n/p defaults must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('n'))]), + Dispatch::Command(Command::OutlineNextChangeset) + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('p'))]), + Dispatch::Command(Command::OutlinePrevChangeset) + ); + } + + #[test] + fn z_m_and_z_r_dispatch_outline_fold_all_with_no_collisions() { + let km = Keymap::defaults(); + assert!( + km.warnings().is_empty(), + "zM/zR defaults must not collide with anything: {:?}", + km.warnings() + ); + assert_eq!( + feed( + &km, + true, + &[key(KeyCode::Char('z')), key(KeyCode::Char('M'))] + ), + Dispatch::Command(Command::OutlineCollapseAll) + ); + assert_eq!( + feed( + &km, + true, + &[key(KeyCode::Char('z')), key(KeyCode::Char('R'))] + ), + Dispatch::Command(Command::OutlineExpandAll) + ); + } + #[test] fn a_config_rebind_overrides_the_default() { let km = Keymap::from_bindings(&[RawBinding { @@ -1314,10 +1395,18 @@ mod tests { let hint = footer_hint(&km, View::Outline, OutlineMode::Stack); assert!(hint.contains("j/k move"), "got: {hint:?}"); assert!(hint.contains("enter open"), "got: {hint:?}"); + assert!(hint.contains("n/p changeset"), "got: {hint:?}"); assert!( hint.contains("i \u{2192}stack-tree"), "cycling from Stack must show the next mode, StackTree; got: {hint:?}" ); + assert!(hint.contains("? help"), "got: {hint:?}"); + assert!( + hint.chars().count() <= 80, + "the curated outline hint must fit an 80-col footer even with the longest \ + next-mode label (stack-tree); got {} chars: {hint:?}", + hint.chars().count() + ); } #[test] diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 80056b9b..5fd518b2 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -461,6 +461,10 @@ enum Action { OutlineDiscard, OutlineHscrollLeft, OutlineHscrollRight, + OutlineNextChangeset, + OutlinePrevChangeset, + OutlineCollapseAll, + OutlineExpandAll, None, } @@ -511,6 +515,10 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { Command::OutlineDiscard => Action::OutlineDiscard, Command::OutlineHscrollLeft => Action::OutlineHscrollLeft, Command::OutlineHscrollRight => Action::OutlineHscrollRight, + Command::OutlineNextChangeset => Action::OutlineNextChangeset, + Command::OutlinePrevChangeset => Action::OutlinePrevChangeset, + Command::OutlineCollapseAll => Action::OutlineCollapseAll, + Command::OutlineExpandAll => Action::OutlineExpandAll, } } @@ -648,6 +656,10 @@ fn apply_action(app: &mut App, action: Action) -> bool { Action::OutlineDiscard => app.outline_discard(), Action::OutlineHscrollLeft => app.outline_hscroll_left(), Action::OutlineHscrollRight => app.outline_hscroll_right(), + Action::OutlineNextChangeset => app.outline_next_changeset(), + Action::OutlinePrevChangeset => app.outline_prev_changeset(), + Action::OutlineCollapseAll => app.outline_collapse_all(), + Action::OutlineExpandAll => app.outline_expand_all(), Action::None => {} } false From 735d70474b14906d2970efee5fdc3dcc774f12f4 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Fri, 17 Jul 2026 18:30:58 -0400 Subject: [PATCH 19/19] fix(review): key changeset identity by name plus span kind --- git-workon-review/src/app.rs | 156 +++++++++++++++++++++++++++++++---- 1 file changed, 140 insertions(+), 16 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index af3b9665..c54ab7e4 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -786,6 +786,35 @@ impl OutlineRowIdentity { } } +/// What "the same changeset" means once a refresh has re-resolved the world: branch name plus +/// span KIND. Name alone is ambiguous — [`workon::assemble_changesets`]'s uncommitted layer is +/// named after the current branch, so that branch's committed node and the uncommitted layer +/// share a name, and a name-only re-find silently lands on the committed node (the "staging +/// teleports the diff viewer" / "discard does nothing" dogfood bugs). Deliberately NOT the full +/// [`workon::ChangesetSpan`]: a staging op rewrites the index, and a future stack op rewrites +/// base/head OIDs, yet the result is still "the same changeset" to the reviewer — identity must +/// survive exactly the operations that change the span's contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangesetIdentity { + name: String, + uncommitted: bool, +} + +impl ChangesetIdentity { + /// Capture `cs`'s identity ahead of an operation that rebuilds [`App::changesets`]. + fn of(cs: &Changeset) -> Self { + Self { + name: cs.name.clone(), + uncommitted: cs.span == ChangesetSpan::Uncommitted, + } + } + + /// Whether `cs` is the changeset this identity was captured from, across a rebuild. + fn matches(&self, cs: &Changeset) -> bool { + cs.name == self.name && (cs.span == ChangesetSpan::Uncommitted) == self.uncommitted + } +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -1300,17 +1329,18 @@ pub enum PendingOp { file_idx: usize, selections: Vec<(usize, LineSelection)>, }, - /// CS7: discard every file in `files` — `(changeset name, file path)` pairs — from the + /// CS7: discard every file in `files` — `(changeset identity, file path)` pairs — from the /// worktree: an outline File row's single target, or a Dir row's every file under its path. - /// Stored by NAME + PATH rather than raw `(cs_idx, file_idx)` indices because the confirm - /// modal doesn't stop the tick beat: an external index change (e.g. `git add` from another - /// terminal) can run a full refresh between `d` and `y`, rebuilding the per-changeset file - /// lists and shifting positions — [`App::resolve_confirm`] re-resolves each pair against the - /// LIVE changesets at answer time (silently skipping any that vanished) so a stale index can - /// never discard the wrong file. `identity` is the acted-on outline row's - /// [`OutlineRowIdentity`], captured at request-time for the post-op outline cursor restore. + /// Stored by [`ChangesetIdentity`] + PATH rather than raw `(cs_idx, file_idx)` indices + /// because the confirm modal doesn't stop the tick beat: an external index change (e.g. + /// `git add` from another terminal) can run a full refresh between `d` and `y`, rebuilding + /// the per-changeset file lists and shifting positions — [`App::resolve_confirm`] + /// re-resolves each pair against the LIVE changesets at answer time (silently skipping any + /// that vanished) so a stale index can never discard the wrong file. `identity` is the + /// acted-on outline row's [`OutlineRowIdentity`], captured at request-time for the post-op + /// outline cursor restore. DiscardOutlineFiles { - files: Vec<(String, String)>, + files: Vec<(ChangesetIdentity, String)>, identity: OutlineRowIdentity, }, } @@ -1686,7 +1716,7 @@ impl App { return; } - let prev_cs_name = self.cur().cs.name.clone(); + let prev_cs_id = ChangesetIdentity::of(&self.cur().cs); let current_path = self .cur() .diff @@ -1733,7 +1763,7 @@ impl App { self.current_cs = new_views .iter() - .position(|v| v.cs.name == prev_cs_name) + .position(|v| prev_cs_id.matches(&v.cs)) .unwrap_or_else(|| current_cs_index(&new_views)); self.base_label = base_label_for(&new_views[self.current_cs].cs); self.changesets = new_views; @@ -3323,12 +3353,12 @@ impl App { targets.len() ), }; - let files: Vec<(String, String)> = targets + let files: Vec<(ChangesetIdentity, String)> = targets .iter() .filter_map(|&(cs_idx, file_idx)| { let view = self.changesets.get(cs_idx)?; let path = view.files().get(file_idx)?.path.clone(); - Some((view.cs.name.clone(), path)) + Some((ChangesetIdentity::of(&view.cs), path)) }) .collect(); self.request_confirm(prompt, PendingOp::DiscardOutlineFiles { files, identity }); @@ -4120,14 +4150,14 @@ impl App { self.run_op(LineSelectionOp::new(file, selections, StageVerb::Discard)); } PendingOp::DiscardOutlineFiles { files, identity } => { - // Re-resolve each (changeset name, path) pair against the LIVE changesets — an + // Re-resolve each (changeset identity, path) pair against the LIVE changesets — an // intervening tick refresh may have shifted every index since `d` was pressed // (see the variant's doc); a pair that no longer resolves is silently skipped // (its file already left the diff, so there's nothing left to discard). let ops: Vec> = files .iter() - .filter_map(|(cs_name, path)| { - let view = self.changesets.iter().find(|v| v.cs.name == *cs_name)?; + .filter_map(|(cs_id, path)| { + let view = self.changesets.iter().find(|v| cs_id.matches(&v.cs))?; let file = view.files().iter().find(|f| f.path == *path)?.clone(); Some(Box::new(FileStagingOp::file(file, StageVerb::Discard)) as Box) @@ -10385,6 +10415,100 @@ mod tests { )); } + /// A Graphite stack whose current branch `b` has BOTH a committed changeset and the + /// uncommitted layer — [`workon::assemble_changesets`]'s `insert_uncommitted_layer` names + /// the layer after the current branch, so two changesets share the name "b". Built through + /// the production resolve path ([`crate::acquire::resolve_changesets`]) so `App::refresh` + /// re-resolves the same shape. + fn graphite_stack_app_on_uncommitted_layer() -> (Fixture, App) { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .graphite_config(&["main"]) + .branch_metadata("a", "main") + .branch_metadata("b", "a") + .untracked_file("scratch.txt", "hi\n") + .build() + .unwrap(); + let repo = fixture.repo().unwrap(); + repo.set_head("refs/heads/b").unwrap(); + repo.checkout_head(None).unwrap(); + + let changesets = crate::acquire::resolve_changesets(repo, "b").expect("resolve"); + assert!( + changesets + .iter() + .any(|cs| cs.name == "b" && cs.span != ChangesetSpan::Uncommitted), + "precondition: a committed changeset named after the current branch" + ); + assert!( + changesets + .iter() + .any(|cs| cs.name == "b" && cs.span == ChangesetSpan::Uncommitted), + "precondition: the uncommitted layer shares that name" + ); + let mut views = Vec::with_capacity(changesets.len()); + for cs in changesets { + let diff = crate::acquire::diff_changeset(repo, &cs).unwrap(); + views.push(ChangesetView::from_changeset_diff(cs, diff)); + } + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, views); + app.open_current(); + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Uncommitted, + "precondition: the review opens on the uncommitted layer" + ); + (fixture, app) + } + + /// Refresh re-finds the current changeset by NAME alone — with the uncommitted layer named + /// after its branch, the first name match is the committed "b" changeset, and the reviewer + /// is silently teleported off the uncommitted layer. Every staging op refreshes, so this is + /// the "stage a file and the diff viewer jumps to another changeset" dogfood bug. + #[test] + fn refresh_stays_on_the_uncommitted_layer_despite_a_same_named_committed_changeset() { + let (_fixture, mut app) = graphite_stack_app_on_uncommitted_layer(); + + app.refresh(); + + assert_eq!( + app.cur().cs.span, + ChangesetSpan::Uncommitted, + "refresh must keep the reviewer on the uncommitted layer, not its same-named \ + committed changeset" + ); + } + + /// The confirm-time re-resolve for an outline discard looks the changeset up by NAME alone + /// (`resolve_confirm`'s `DiscardOutlineFiles` arm) — the first match is the committed "b" + /// changeset, the file isn't in ITS diff, and the pair is silently dropped: `y` does + /// nothing. This is the "discard from the outline has no effect" dogfood bug. + #[test] + fn outline_discard_still_applies_when_a_committed_changeset_shares_the_layers_name() { + let (fixture, mut app) = graphite_stack_app_on_uncommitted_layer(); + // Set mode/order BEFORE the row lookup — the index is only valid in the build it was + // found in. + open_focused_outline(&mut app, OutlineMode::Stack, 0); + app.outline.cursor = outline_file_row(&app, "scratch.txt"); + + app.outline_discard(); + assert!( + app.pending_confirm.is_some(), + "discard must request confirm; notice: {:?}", + app.notice + ); + app.resolve_confirm(true); + + let repo = fixture.repo().unwrap(); + let scratch = repo.workdir().unwrap().join("scratch.txt"); + // No absence predicate exists yet; a direct existence check keeps the assertion honest. + assert!( + !scratch.exists(), + "y must discard the untracked file from the worktree" + ); + } + #[test] fn outline_discard_confirm_n_cancels_and_leaves_the_worktree_unchanged() { let fixture = FixtureBuilder::new()