Magit-style git log plugin + buffer-group/rendering fixes#1565
Merged
Magit-style git log plugin + buffer-group/rendering fixes#1565
Conversation
Motivation ---------- The git_log plugin swapped a single virtual buffer between log / detail views, rebuilt colouring via imperative overlay passes, used hard-coded RGB triples, and had no live preview. Tests (and screenshots) showed misaligned columns and colours that drifted from the active theme. This switches it to the modern plugin primitives exercised by audit_mode and theme_editor: one `createBufferGroup` tab with log + detail panels side-by-side, `setPanelContent` with `TextPropertyEntry[]` + `inlineOverlays` for colour, and a `cursor_moved` subscription that live-updates the right panel as the user scrolls through the log. Shared rendering ---------------- Commit-list and commit-detail rendering move into a new `plugins/lib/git_history.ts` module so the same helpers can be reused by audit_mode's new "Review PR Branch" view. Every colour is a theme key (`syntax.number`, `editor.selection_bg`, `editor.diff_add_bg`, …) so the panels follow theme changes automatically. Column widths are computed per render, producing properly aligned hash / date / author columns. audit_mode: Review PR Branch ---------------------------- `start_review_branch` opens a matching group (commits on the left, `git show` of the selected commit on the right) so a reviewer can step through every commit on a PR branch without leaving the editor. It reuses the same `buildCommitLogEntries` / `buildCommitDetailEntries` helpers, so both plugins stay visually consistent. Tests ----- `test_git_log_open_different_commits_sequentially` previously asserted that the newly-selected commit's message *replaced* the prior commit on screen — the new layout keeps the full log visible on the left, so it now asserts against the detail panel's file set instead (file2.txt present, file3.txt absent).
Adds `blog_showcase_productivity_git_log` alongside the other blog showcases. It builds a hermetic 6-commit repo (two authors, a v0.1.0 tag) and scripts the user walking through the modernised git-log panel: open via the command palette, navigate with j/k and watch the right panel live-update, Tab into the detail panel, q back to the log, q to close the group. The test is `#[ignore]` like every other blog showcase — run it with `--ignored` to emit SVG frames into `docs/blog/productivity/git-log/` and then `scripts/frames-to-gif.sh docs/blog/productivity/git-log` to assemble the final animated GIF for the blog.
The live-preview panel re-ran the full log render and a fresh `git show` on every cursor_moved event, so held j/k or PageDown could pile up hundreds of synchronous renders and spawn hundreds of git subprocesses on the main thread; a 40–60 ms debounce in `on_git_log_cursor_moved` collapses bursts to a single render for the final row. `fetchCommitShow` now does a cheap `git show --numstat` pre-pass and excludes any file with >2000 lines changed from the subsequent `--stat --patch` via `:(exclude,top)` pathspecs, with a footer listing the skipped paths. Stops generated SVGs / lockfiles from turning the detail panel into a 19 MB, ~500k-line buffer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`OverlayManager::add` re-sorts the full overlay vector on every insertion, so rebuilding a virtual buffer from N overlays was O(N² log N). A big `git show` can easily collect ~500k overlays (one per line + inline highlights), which stalls the main thread for minutes. Add `OverlayManager::extend` that appends all overlays and sorts exactly once, and switch `set_virtual_buffer_content` to build the full vec first and call `extend`. The per-overlay marker creation still runs N times (unavoidable until the marker list learns a bulk insert), but the sort cost drops from O(N² log N) to O(N log N). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a plugin rebuilt a group-panel's contents via setPanelContent, the log cursor snapped back to row 0 on every render. set_virtual_buffer_content looked up the old cursor position via has_buffer (which checks open_buffers), but group-panel buffers are intentionally stripped from every split's open_buffers list when the group is created — so the lookup always missed, old_cursor_pos defaulted to 0, and the restore step overwrote every keyed_states entry with 0. Look up the prior cursor via keyed_states directly so panel buffers are found. Reproduced by a new e2e: pressing Down repeatedly in the git-log panel now progresses through commits; previously the second Down stuck on the same row because the first render had reset the cursor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Git diffs frequently have lines longer than the 40% right-split width, so horizontal scrolling was the only way to read them. Turn on line wrap for the detail buffer when the group opens.
Git stores commit messages with their author-chosen hard wraps (typically 72 cols). In a narrow detail panel the built-in soft-wrap had to wrap those already-short lines again, producing a staircase of half-width lines. Rejoin each paragraph into a single logical line before emitting entries so soft-wrap has room to break naturally at the panel edge. Diff lines, Author/Date/Commit header, and merge/trailer blocks are untouched — only the indented body region between the header and the first `diff --git` line is rejoined.
Previous fetchCommitShow ran `git show --stat --patch` and tried to reflow
the commit message by walking the unified output. That conflated the
diffstat block with the message body (stat lines sit between them and
were inadvertently joined into a paragraph), and had no reliable way to
detect paragraph boundaries in merge commits or trailers.
Split into three calls:
1. `git show --numstat --format=` — spot oversized files.
2. `git log -n1 --format=%H%x00%P%x00%an%x00%ae%x00%aD%x00%B` —
structured metadata + raw message.
3. `git show --format= --stat --patch` (with pathspec exclusions for
oversized files) — stat + patch only, no metadata.
Compose the final output ourselves: reconstruct the commit/Merge/Author/
Date header, reflow just the message paragraphs (blank lines preserved
as paragraph separators), then append the untouched stat + patch. The
stat block is never reflowed.
Match the review-diff layout style: a 1-row fixed toolbar panel above the log + detail split, replacing the per-panel footer lines that cluttered the bottom of the log and detail buffers and shifted as the buffers re-rendered. Keys render bold; labels dim; vertical-bar separators between groups. The layout now is: ┌──── toolbar (1 row) ────────────────────────┐ ├──── log (60%) ───────┬──── detail (40%) ────┤ └──────────────────────┴──────────────────────┘
spawnProcess can't send a raw NUL byte as a process argument — it fails CString conversion with "nul byte found in provided data" and the commit detail pane errors out. Git's format language accepts the text `%x00` and emits a literal NUL on its output side, which is what we actually want for field parsing.
Reflow-as-paragraphs destroys intentional formatting in commit messages that use lists, code blocks, or explicit short lines for emphasis — the very kind of message where layout matters. Soft-wrap alone still handles overly-long lines in narrow panels; preserving the author's line breaks is the less-surprising default. Go back to a single `git show --stat --patch` call (plus the existing numstat pass for large-file exclusion). No message post-processing; no header reconstruction.
set_virtual_buffer_content had a weird save-one/write-all shape: it read the cursor position out of one view state (picked by a non-deterministic `find`), and then copied that single value into every keyed_states entry for the buffer. That clobbered the inner panel's live cursor with the outer split's stale entry, so every re-render of the group panel yanked the log cursor back to wherever the outer split happened to remember. The right thing is simpler: each split tracks its own cursor, so on a content swap we only need to clamp any position that fell past the new buffer end and snap to a char boundary. No cross-split copying.
The sticky toolbar already labels the panel implicitly, and the sticky tab title says "Git Log", so the header row was pure clutter — and pushed the first commit off row 0, forcing a fiddly +1/-1 in the cursor-byte-to-index mapping. Pass `header: null` to `buildCommitLogEntries` (new supported value), remove the now-unused byteOffsetOfFirstCommit helper, and simplify indexFromCursorByte to identity.
Two problems in the git-log mode bindings:
* `PageUp` / `PageDown` were bound to `page_up` / `page_down`, which
aren't valid action names — the built-in actions are called
`move_page_up` / `move_page_down`. The keys silently did nothing.
* No Shift+arrow bindings, so selecting text for Copy was impossible
in either panel.
Plugin modes don't fall through to Normal bindings (only
`CompositeBuffer` does, per `allows_normal_fallthrough`), so every key
has to be re-declared inside the mode. Add the missing motions and
every Shift+motion variant.
Plugin modes registered with defineMode() previously had to redeclare
every motion, selection, and copy binding because the resolver only
falls through to Normal for the CompositeBuffer context. That made even
read-only viewer modes (git_log, audit log views) carry a wall of
Up/Down/Page*/Home/End/Shift+arrow boilerplate that just re-pointed to
the built-ins.
Add an `inheritNormalBindings` flag to defineMode():
* BufferMode gains an `inherit_normal_bindings` field + builder.
* PluginCommand::DefineMode carries the flag through the host boundary.
* KeybindingResolver tracks inheriting modes in a HashSet and treats
`Mode(name)` as a full-fallthrough context when the name is in it.
* QuickJS `defineMode` accepts a 5th optional `inherit_normal_bindings`
argument; fresh.d.ts regenerated.
Use it in git_log: drop the redeclared motion/selection bindings,
keeping only j/k vi aliases and the six plugin-specific action keys
(Return/Tab/q/Escape/r/y). PgUp/PgDn, Home/End, Shift+arrows, Ctrl+C
now work because they fall through to Normal.
…uter split When a buffer-group panel had focus and you made a selection there with Shift+arrows, Ctrl+C copied the current line instead of the selection and showed "copied line". The selection lived in the panel's cursors, but `active_cursors()` returned the outer split's cursors (which had no selection), so `copy_selection` took the no-selection branch and copied the whole line. `active_buffer()` / `active_state()` already route through `effective_active_pair()` to pick the focused panel; cursor access should be consistent. Switch `active_cursors`/`active_cursors_mut` to `effective_active_split` so they read from the panel's view state too.
When a buffer-group tab lost focus and the user switched back to it, the focused panel jumped back to whichever leaf the SplitNode::Grouped node was originally created with — losing the user's last-focused panel. focus_panel() updated `vs.focused_group_leaf` but not `SplitNode::Grouped.active_inner_leaf`, so on the next `activate_group_tab` the stored value clobbered the live one. Update active_inner_leaf inside focus_panel as well, so the persisted preference matches the current focus and tab-away/back is a no-op.
The plugin snapshot built on every render captures each buffer's cursor position by iterating split_view_states and taking the first one whose keyed_states has the buffer. HashMap iteration is non-deterministic, and buffer-group panel buffers linger in both the outer split's keyed_states (never updated — stuck at 0) and the inner panel's (live). Half the time the snapshot recorded the stale 0 instead of the real cursor. That manifested as flaky behavior in git_log's detail panel: pressing Enter on a diff line sometimes worked and sometimes said "Move cursor to a diff line with file context", because getTextPropertiesAtCursor was reading the snapshot's cursor and hitting the wrong byte. Match the fix we already applied to set_virtual_buffer_content: prefer the view state where `active_buffer == buffer_id` (where motion actions actually write), then fall back to any keyed_states entry.
Files marked read-only via mark_buffer_read_only lost the flag on restart. The warning log (opened from the status-bar indicator) would come back editable. Capture read-only file paths on save and re-apply mark_buffer_read_only to matching restored buffers. Stored relative to working_dir when possible, absolute otherwise, to match how external_files and open_tabs paths are handled. Field is additive with serde(default), so older session files still load.
Move the detail cursor back to byte 0 after every renderDetailForCommit so the view shows the top of the new commit's diff instead of wherever the cursor happened to land in the previous commit's content.
handle_horizontal_scroll clamped rightward scroll to `max_line_length_seen - visible_width`, but max_line_length_seen is only updated during the render loop and starts at 0. In any buffer where the currently-visible lines fit the viewport, that stored value equals visible_width, making the clamp pin left_column at 0 — shift+wheel right did nothing even when long lines existed further down. Drop the clamp; the render pass already clips what's drawn, so mild overshoot is harmless, and the common case (visible content fits but user wants to scroll) now works.
The virtual-buffer name was "<path> @ <hash>", so the host's
from_virtual_name detector ran from_path_builtin on "<hash>" — no
extension match, no highlighter. Switch to "*<hash>:<path>*" which
matches the documented convention; rfind(':') grabs the path and
the extension picks the right grammar.
Pressing Enter on a diff line showed the file at that commit but left the cursor at byte 0 — the user still had to find the line themselves. Resolve the target line's byte offset via getLineStartPosition and setBufferCursor to it before the status message renders.
Diagnostic logs at two places:
* snapshot writer — records `buffer_id -> (cursor_pos, source split)`
at trace level for every snapshot refresh.
* plugin runtime's getTextPropertiesAtCursor — debug log with the
snapshot cursor, fallback cursor, active buffer, and match counts.
To reproduce the flaky "Move cursor to a diff line with file context"
path: run with RUST_LOG=fresh=trace,fresh_plugin_runtime=debug and
compare a working run with a failing one.
The trace from the flaky-open-file repro showed two splits advertising `active_buffer == BufferId(4)` for the git-log detail panel — the panel leaf (SplitId(3), live cursor=1312) and the outer host split (SplitId(0), stale cursor=0). HashMap iteration picked whichever came first, and the order flipped after closing a file-view buffer, so the plugin snapshot suddenly recorded byte 0 and `getTextPropertiesAtCursor` returned the wrong row, status: "Move cursor to a diff line with file context". Prefer the view state with `suppress_chrome == true` (panel splits get that flag when the group is created) before falling back to any split that has the buffer active or keyed.
…yed_states Panel buffers were added to the active (outer) split's keyed_states when create_virtual_buffer ran, then createBufferGroup only scrubbed them from open_buffers — not keyed_states. The outer split kept a stale cursor entry for the panel buffer forever, which collided with the panel's own view state in any HashMap scan keyed by buffer id. Symptom: flaky "Move cursor to a diff line with file context" after toggling in/out of a file-view, and the snapshot trace showing the cursor source flip between the panel split and the outer split for the same buffer. Clean panel-buffer entries out of every non-panel split's keyed_states at group-creation time, matching what we already do for open_buffers. With no duplicate entries left, the snapshot's lookup is deterministic: exactly one split has the panel buffer in keyed_states — the panel's own. Drop the now-unnecessary suppress_chrome priority layering.
Cover the sequence that was flaky before the panel-buffer keyed_states cleanup: open git-log, Enter on a diff line to open the file-view, close it with q, move in the detail panel, Enter again. Pre-fix, the second Enter reported "Move cursor to a diff line with file context" because the outer split still carried a stale cursor entry for the panel buffer. The test asserts that status line is absent. Also drop the stale wait-for-"Commits:" assertion in the existing down-arrow progression test — the header row was removed in an earlier commit, so the test would hang forever on that substring.
The "Commits:" header row was dropped in 268d1d0 and the detail panel became a live-preview on cursor move, but the tests still waited for "Commits:" and drove Enter+q to open/close commit detail. Switch the sentinel to a toolbar hint ("switch pane") that's absent from the status bar, and rewrite the sequential-open test as a pure Down-key navigation check mirroring test_git_log_down_arrow_progresses_through_commits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8d1a255 to
ce00ab7
Compare
Fixed panels (toolbars, headers, footers) previously took mouse wheel events, drew a scrollbar, and could steal keyboard focus via clicks — so hovering a toolbar and scrolling would shift its pinned content by a line, and clicking it routed arrow keys to an invisible cursor. Separate "scrollable" from "fixed-height" as its own layout property (default true for Scrollable, false for Fixed; either can override). Non-scrollable buffers now ignore vertical and horizontal mouse scroll, skip scrollbar rendering entirely, and buffers with hidden cursors reject focus from left/double/triple clicks and focus_split — plugins can still observe clicks via the mouse_click hook to build buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Render each toolbar hint as a discrete button with its own background and capture per-button column ranges so mouse_click on the toolbar dispatches the matching handler (Tab, RET, y, r, q). Keyboard-only cursor motions (j/k, PgUp/PgDn) are dropped from the toolbar entirely since clicking them is meaningless. Also drop the Escape → close binding and rename "yank hash" to "copy hash" to match the rest of the editor's vocabulary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The BufferClosed hook variant was declared in fresh-core but never emitted by the editor, so plugins subscribing to "buffer_closed" never heard about closures driven by the tab close button or the "close buffer" command. Emit it at the end of close_buffer_internal. Git-log now subscribes to buffer_closed and runs a shared cleanup path whenever any of its panel buffers disappears, so a later "git log" invocation doesn't see a stale isOpen=true. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Running "git log" while the group is already open now pulls its tab to the front instead of flashing an "already open" status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The inactive split's active tab paired `tab_active_fg` with `tab_inactive_bg`. That combo happens to be black-on-black on the high-contrast theme (active_fg = inactive_bg = [0,0,0]), making the tab label disappear until the split regained focus. Switch to `tab_inactive_fg + tab_inactive_bg + BOLD` for that case — bold still signals which tab is active in the inactive split, and both colors come from the inactive palette so contrast is guaranteed across every built-in theme. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rammars TypeScript ships as a tree-sitter grammar only — syntect has nothing for it. The path-based highlighter already fell back to tree-sitter in that case, but the name-based lookup used by the language palette and the CLI grammar listing did not, so: * opening foo.ts → highlighted * set language: TypeScript on a new buffer → no highlighting * fresh --cmd grammar list → no TypeScript entry Extend HighlightEngine::for_syntax_name with the same tree-sitter fallback that for_file has, stop DetectedLanguage::from_syntax_name from bailing when only tree-sitter knows the name, and add an available_grammar_info_with_languages variant that merges tree-sitter languages from the user config (using LanguageConfig.extensions rather than hardcoded tables). The CLI grammar-list command now loads config and calls the new method. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user closed a tab in the host split and the LRU fallback was a
buffer group, close_buffer_internal reached into the group's active
inner panel leaf and picked that panel buffer as the host split's
replacement active_buffer. set_active_buffer → SplitView::switch_buffer
then auto-inserted a fresh default BufferViewState into the host split's
keyed_states for that panel buffer — a shadow entry frozen at cursor=0
that never gets updated because motion goes to the panel split via
effective_active_split.
The shadow collided with the panel split's authoritative entry any time
update_plugin_state_snapshot iterated split_view_states to look up the
buffer's cursor_pos. HashMap order decided which entry won, so plugin
getTextPropertiesAtCursor reads were non-deterministic — usually fine,
sometimes hitting the shadow's cursor=0 and returning properties for
the detail panel's header row (no file context), at which point
git_log's Enter handler reported "Move cursor to a diff line with file
context" even though the visible cursor was on a valid diff line.
Pick a replacement buffer that's already in the host split's
keyed_states, so switch_buffer's "if !contains_key { insert default }"
path doesn't fire and no shadow is created. Panel buffers now appear in
exactly one split's keyed_states — the panel split — making the
snapshot lookup deterministic without needing the open_buffers /
focus_history scrub that the previous approach layered on top.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously q on the detail panel stepped back to the log panel and only q on the log panel closed the group. The two-step close was surprising — users pressed q on the detail panel expecting the group to close. Always close on q. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The guards that make fixed toolbars/headers inert keyed off `!show_cursors`, but every buffer-group panel (scrollable and fixed alike) has `show_cursors` set to false. That meant clicks on interactive scrollable panels were also swallowed — focus never moved, so mouse-driven panel switching inside a group was a no-op. Switch the three guards (single/double/triple click and `focus_split`) to the `scrollable` property via the existing `is_non_scrollable_buffer` helper. Fixed panels stay inert; scrollable panels with hidden cursors again accept focus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- `test_git_log_back_from_commit_detail` predated the "q closes group from any panel" change and looped forever waiting for the toolbar it had just closed. Renamed + rewritten as `test_git_log_q_from_detail_closes_group` to cover the new contract. - `test_git_log_open_different_commits_sequentially` waited on the toolbar's "switch pane" hint and then asserted commit rows immediately; on slow runs the log panel was still "Loading git log..." when the assertion fired. Wait on the commit rows themselves. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mediately Previously every `cursor_moved` event delayed all downstream work by the debounce window — the log-panel selection highlight, the detail placeholder, and the status line all waited 60 ms before updating. Held j/k felt sluggish even though the expensive bit is only the `git show` process spawn. Split the detail refresh into a synchronous "render cache or placeholder" phase and an async "spawn + render" phase. The cursor handler now runs the synchronous phase on every event (so highlight + "loading…" flip instantly) and only debounces the spawn. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Name] When the LRU replacement target was a Group tab and the host split's `keyed_states` contained no suitable buffer — the common case after closing the last file tab alongside an audit/git-log group — the close path fell through to `new_buffer()` and the synthesized `[No Name]` showed up next to the activated group tab. `created_empty_buffer` then also routed focus to the file explorer. Pick any remaining buffer (including hidden panel buffers) to fill the host split's `active_buffer` housekeeping slot before resorting to `new_buffer`. A hidden panel buffer in that slot leaves a harmless shadow entry in the host's `keyed_states` (required by the `active_buffer ∈ keyed_states` invariant), so teach the plugin-state snapshot lookup to skip group-host splits when resolving a hidden buffer's cursor position — the panel's inner split is the authoritative home. The tab entry and focus-history entry are still scrubbed so the panel buffer never surfaces as a tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rewrites the
git_logplugin around the buffer-group API, extracts its commit-rendering code into a reusable library, and fixes a pile of engine-level bugs that surfaced while building it (panel scrolling, cursor routing across splits, tab coloring, TypeScript highlighting, …).Git log plugin
toolbar | (log | detail)in asingle tab, with live preview: scrolling through the commit list
re-renders the detail panel (debounced).
fetchCommitShowcaches perhash so rapid j/k doesn't respawn
git show.button; clicking dispatches the handler. Resize re-renders.
renderLogcaches per-row byte offsets sothe
cursor_movedhandler can map the log cursor back to a commitindex even for virtual buffers (where
getCursorLineis unimplemented).Enteron a diff line in the detail panelspawns
git show <hash>:<path>into a named virtual buffer so syntaxhighlighting kicks in, then jumps to the target line.
plugins/lib/git_history.tsextracts the commitlog + commit detail entry builders so
audit_modecan reuse them forits branch-review mode.
open pulls the existing tab to the front instead of erroring.
whether the close came from the plugin, the close button, or the
"close buffer" command.
Engine fixes (dependencies of the plugin, reused elsewhere)
buffer_closedhook was never emitted. The variant existed in`fresh-core` but no editor code fired it, so every plugin that
subscribed silently never heard. Now emitted from
`close_buffer_internal`.
fixed-height in the layout API (optional `scrollable` field,
defaults true for `Scrollable`, false for `Fixed`). Non-scrollable
buffers ignore mouse wheel + shift+wheel, draw no scrollbar, and
hidden-cursor panels reject focus from single/double/triple clicks
and `focus_split` — plugins can still build button panels via
`mouse_click`.
panel instead of the outer split.
buffer is actually active.
is drawn.
`inheritNormalBindings: true`, so `git_log` gets arrow keys,
page-up/down, Shift+motion selection, Ctrl+C copy etc. for free
without redeclaring them.
so the detail panel doesn't rebuild overlays one-by-one when
switching commits.
Unrelated fixes that travelled with this branch
high-contrast theme (`tab_active_fg` == `tab_inactive_bg` = black).
Uses `tab_inactive_fg + tab_inactive_bg + BOLD` instead.
buffers unhighlighted because syntect has no TypeScript grammar and
the name-based lookup had no tree-sitter fallback (only the
path-based lookup did). Added the fallback to
`HighlightEngine::for_syntax_name`, stopped
`DetectedLanguage::from_syntax_name` from bailing, and added
`available_grammar_info_with_languages` so `fresh --cmd grammar
list` shows TypeScript (extensions come from `LanguageConfig`,
not hardcoded).
Showcase
Blog demo at `docs/blog/productivity/git-log/` with frame-by-frame
SVG animation of the plugin, plus an e2e harness in
`tests/e2e/blog_showcases.rs`.
Test plan
line opens the file at the correct hash+line
all reset state (re-open works)
legible
🤖 Generated with Claude Code