Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6effa48
feat(review): rework outline focus into home-base model with h/l
lettertwo Jul 10, 2026
baf7adc
fix(review): renumber resolve_key doc to the six-case Esc cascade
lettertwo Jul 11, 2026
e428e97
fix(review): delegate toggle_outline opening arm to focus_outline
lettertwo Jul 11, 2026
51d5c93
feat(review): give the outline a scrolloff viewport and g/G jumps
lettertwo Jul 10, 2026
962b51b
fix(review): pass row count into derive_outline_scroll
lettertwo Jul 11, 2026
274e316
feat(review): order outline head-first with dirs before files
lettertwo Jul 10, 2026
3d1ffac
fix(review): extract shared scan_order for stack display order
lettertwo Jul 11, 2026
c8ee1a9
feat(review): summary panel for outline header and dir rows
lettertwo Jul 10, 2026
877a99b
fix(review): extract shared push_summary_body from summary builders
lettertwo Jul 11, 2026
6247b9d
fix(review): build summaries from borrows, no FileChange clones
lettertwo Jul 11, 2026
f1d8dc4
feat(review): file status letters and opt-in nerd icons in outline
lettertwo Jul 10, 2026
007f7e6
fix(review): name the outline file target struct FileOccurrence
lettertwo Jul 11, 2026
3d4268d
feat(review): preserve diff position across staging operations
lettertwo Jul 10, 2026
ccdd4c6
fix(review): clear clippy lint debt blocking the scoped stop gate
lettertwo Jul 14, 2026
d146cb8
fix(review): search cursor restore in a single lineno frame
lettertwo Jul 11, 2026
2c9b1bd
feat(review): stage, unstage, and discard from outline rows
lettertwo Jul 11, 2026
99fdd9f
fix(review): re-resolve outline discard targets at confirm time
lettertwo Jul 11, 2026
eadc991
fix(review): adopt FileOccurrence and scroll arity from downstack
lettertwo Jul 11, 2026
9000906
feat(review): expand collapsed context gaps progressively
lettertwo Jul 11, 2026
fa7f2c6
fix(review): cancel the line selection when a gap expansion reshapes …
lettertwo Jul 11, 2026
c2ff752
fix(review): gate test-only collapse_gaps_with behind cfg(test)
lettertwo Jul 11, 2026
e94945c
feat(review): reveal gaps to the enclosing tree-sitter scope
lettertwo Jul 11, 2026
13e4707
fix(review): drop unreachable empty-range guard in scope reveal
lettertwo Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
394 changes: 380 additions & 14 deletions git-workon-review/src/align.rs

Large diffs are not rendered by default.

2,731 changes: 2,616 additions & 115 deletions git-workon-review/src/app.rs

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions git-workon-review/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@
//! [workon "review.outline"]
//! 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)
//!
//! Opt-in nerd-font file/dir icons in the outline pane. 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
//! [`crate::icons`] for the glyph table.

use git2::Repository;

Expand Down Expand Up @@ -101,6 +111,8 @@ pub struct RawBinding {
pub struct RawViewConfig {
pub outline_width: Option<i64>,
pub outline_mode: Option<String>,
pub outline_order: Option<String>,
pub outline_icons: Option<String>,
pub diff_layout: Option<String>,
pub diff_zoom: Option<String>,
}
Expand Down Expand Up @@ -203,6 +215,17 @@ impl<'repo> ReviewConfig<'repo> {
self.get_view_string(View::Outline, "mode")
}

/// Get `workon.review.outline.order`, raw. `None` if unset.
pub fn outline_order(&self) -> Result<Option<String>, git2::Error> {
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<Option<String>, git2::Error> {
self.get_view_string(View::Outline, "icons")
}

/// Get `workon.review.diff.layout`, raw. `None` if unset.
pub fn diff_layout(&self) -> Result<Option<String>, git2::Error> {
self.get_view_string(View::Diff, "layout")
Expand All @@ -224,6 +247,8 @@ impl<'repo> ReviewConfig<'repo> {
RawViewConfig {
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(),
diff_layout: self.diff_layout().ok().flatten(),
diff_zoom: self.diff_zoom().ok().flatten(),
}
Expand Down Expand Up @@ -407,6 +432,8 @@ mod tests {
let fixture = FixtureBuilder::new()
.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.diff.layout", "split")
.config("workon.review.diff.zoom", "staged")
.build()
Expand All @@ -419,6 +446,14 @@ mod tests {
config.outline_mode().expect("mode"),
Some("tree".to_string())
);
assert_eq!(
config.outline_order().expect("order"),
Some("base-first".to_string())
);
assert_eq!(
config.outline_icons().expect("icons"),
Some("nerd".to_string())
);
assert_eq!(
config.diff_layout().expect("layout"),
Some("split".to_string())
Expand All @@ -437,6 +472,8 @@ 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.diff_layout().expect("layout"), None);
assert_eq!(config.diff_zoom().expect("zoom"), None);
}
Expand Down
25 changes: 24 additions & 1 deletion git-workon-review/src/highlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ pub fn capture_index(name: &str) -> Option<usize> {
HIGHLIGHT_NAMES.iter().position(|n| *n == name)
}

fn lang_key_for_ext(ext: &str) -> Option<&'static str> {
/// Maps a file extension to the [`build_config`]/[`language_for_key`] key for its grammar, or
/// `None` when no bundled grammar covers it. `pub(crate)` so [`crate::app`] can resolve a gap's
/// anchor file to a scope-lookup language (CS9) without duplicating this table.
pub(crate) fn lang_key_for_ext(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("rust"),
"lua" => Some("lua"),
Expand All @@ -81,6 +84,26 @@ fn lang_key_for_ext(ext: &str) -> Option<&'static str> {
}
}

/// The raw `tree_sitter::Language` for a [`lang_key_for_ext`] key, with no highlight query
/// configuration attached — [`build_config`] below wraps the same grammar constructors together
/// with a language's highlight/injection/locals queries for `TsHighlighter`; [`crate::scope`]
/// needs only the grammar (it parses to walk node kinds, not to highlight), so it shares this
/// smaller constructor instead of duplicating the `LANGUAGE.into()` calls.
pub(crate) fn language_for_key(key: &str) -> Option<tree_sitter::Language> {
let language = match key {
"rust" => tree_sitter_rust::LANGUAGE.into(),
"lua" => tree_sitter_lua::LANGUAGE.into(),
"json" => tree_sitter_json::LANGUAGE.into(),
"toml" => tree_sitter_toml_ng::LANGUAGE.into(),
"javascript" => tree_sitter_javascript::LANGUAGE.into(),
"typescript" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
"tsx" => tree_sitter_typescript::LANGUAGE_TSX.into(),
"markdown" => tree_sitter_md::LANGUAGE.into(),
_ => return None,
};
Some(language)
}

fn build_config(key: &'static str) -> Option<HighlightConfiguration> {
let result = match key {
"rust" => HighlightConfiguration::new(
Expand Down
91 changes: 91 additions & 0 deletions git-workon-review/src/icons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! CS5's opt-in nerd-font file-type icon table — a pure module, no [`crate::app::App`]/
//! [`crate::outline`] dependency, mirroring [`crate::summary`]'s pure-module posture.
//!
//! 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
//! 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`.

/// 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).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutlineIcons {
/// No icon glyph — today's plain `[glyph][letter] path` row (CS5's unconditional part only).
#[default]
None,
/// A nerd-font private-use glyph per file extension (falling back to
/// [`DEFAULT_ICON`]/[`DIR_ICON`]), inserted before the path/name.
Nerd,
}

/// The directory-row icon (nerd-font `nf-fa-folder`, U+F07B) — used for every
/// [`crate::outline::OutlineItem::Dir`] row when [`OutlineIcons::Nerd`] is active.
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).
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
}
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,
}
}

#[cfg(test)]
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}');
}

#[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}');
}

#[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);
}
}
74 changes: 70 additions & 4 deletions git-workon-review/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
//! action names and same-view key collisions are collected as [`Keymap::warnings`].
//!
//! **Not handled here** (stays hardcoded in `tui.rs`): the confirm modal (`y`/`n`/`Esc`) and the
//! whole `Esc`-precedence cascade (confirm > outline-unfocus > selection-cancel > quit). Per
//! ADR-034 those are conventional and safety-sensitive; they are never routed through the
//! registry, so `Esc` is not a registry token.
//! whole `Esc`-precedence cascade (confirm > help > selection-cancel > outline-focused-quit >
//! focus-outline > quit — see `tui::update`'s doc comment). Per ADR-034 those are conventional
//! and safety-sensitive; they are never routed through the registry, so `Esc` is not a registry
//! token.

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

Expand Down Expand Up @@ -61,11 +62,20 @@ pub enum Command {
PrevHunk,
NextChangeset,
PrevChangeset,
ExpandGap,
ExpandGapAll,
// Diff view.
FocusOutline,
// Outline view.
OutlineDown,
OutlineUp,
OutlineConfirm,
OutlineCycleMode,
FocusDiff,
OutlineTop,
OutlineBottom,
OutlineStage,
OutlineDiscard,
}

/// One row of the action registry: a [`Command`] with its stable config identity (`view` +
Expand Down Expand Up @@ -101,7 +111,7 @@ pub static REGISTRY: &[Registered] = &[
view: View::Global,
name: "toggle-outline",
default_keys: "o",
description: "Toggle the outline pane / focus",
description: "Show or hide the outline pane",
},
Registered {
command: Command::ToggleHelp,
Expand Down Expand Up @@ -258,6 +268,27 @@ pub static REGISTRY: &[Registered] = &[
default_keys: "[c",
description: "Go to the previous changeset",
},
Registered {
command: Command::FocusOutline,
view: View::Diff,
name: "focus-outline",
default_keys: "h left",
description: "Focus the outline",
},
Registered {
command: Command::ExpandGap,
view: View::Diff,
name: "expand-gap",
default_keys: "enter",
description: "Reveal more of the collapsed gap under the cursor",
},
Registered {
command: Command::ExpandGapAll,
view: View::Diff,
name: "expand-gap-all",
default_keys: "E",
description: "Reveal the whole collapsed gap under the cursor",
},
// ── Outline view ─────────────────────────────────────────────────────────
Registered {
command: Command::OutlineDown,
Expand Down Expand Up @@ -287,6 +318,41 @@ pub static REGISTRY: &[Registered] = &[
default_keys: "i",
description: "Cycle the outline mode",
},
Registered {
command: Command::FocusDiff,
view: View::Outline,
name: "focus-diff",
default_keys: "l right",
description: "Focus the diff view",
},
Registered {
command: Command::OutlineTop,
view: View::Outline,
name: "scroll-top",
default_keys: "g",
description: "Jump to the top of the outline",
},
Registered {
command: Command::OutlineBottom,
view: View::Outline,
name: "scroll-bottom",
default_keys: "G",
description: "Jump to the bottom of the outline",
},
Registered {
command: Command::OutlineStage,
view: View::Outline,
name: "stage",
default_keys: "s",
description: "Stage or unstage the file/directory under the cursor",
},
Registered {
command: Command::OutlineDiscard,
view: View::Outline,
name: "discard",
default_keys: "d",
description: "Discard the file/directory under the cursor",
},
];

/// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is
Expand Down
3 changes: 3 additions & 0 deletions git-workon-review/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod config;
pub mod error;
pub mod file_ops;
pub mod highlight;
pub mod icons;
pub mod keymap;
pub mod model;
pub mod ops;
Expand All @@ -29,8 +30,10 @@ pub mod probe_cache;
pub mod queue;
pub mod refresh;
pub mod render;
pub mod scope;
pub mod source;
pub mod stage_op;
pub mod summary;
pub mod synthesis;
pub mod terminal_query;
pub mod theme;
Expand Down
20 changes: 20 additions & 0 deletions git-workon-review/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ pub enum FileStatus {
Unmerged,
}

impl FileStatus {
/// The single-character letter the outline's file rows render for this status (CS5):
/// `M`/`A`/`D`/`R`/`C`/`?`/`U`, mirroring `git status --short`'s XY letters where they exist
/// (`?` for untracked, `U` for unmerged/conflicted — git's own convention, not this crate's
/// invention). No mapping like this existed elsewhere in the crate before CS5 (checked the
/// winbar/header, which only special-cases `Renamed`/`Copied` for the `old -> new` label,
/// never prints a letter) — this is the canonical one going forward.
pub fn letter(self) -> char {
match self {
FileStatus::Modified => 'M',
FileStatus::Added => 'A',
FileStatus::Deleted => 'D',
FileStatus::Renamed => 'R',
FileStatus::Copied => 'C',
FileStatus::Untracked => '?',
FileStatus::Unmerged => 'U',
}
}
}

impl From<git2::Delta> for FileStatus {
fn from(delta: git2::Delta) -> Self {
match delta {
Expand Down
Loading