From 584e09abc3dda4e3eb5e3abe4730aee4220c9a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 06:20:31 +0000 Subject: [PATCH 01/42] feat(git_log): rewrite around buffer-group + live-preview right panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../fresh-editor/plugins/audit_mode.i18n.json | 11 + crates/fresh-editor/plugins/audit_mode.ts | 314 ++++ crates/fresh-editor/plugins/git_log.ts | 1468 +++++------------ .../fresh-editor/plugins/lib/git_history.ts | 546 ++++++ crates/fresh-editor/tests/e2e/plugins/git.rs | 16 +- 5 files changed, 1309 insertions(+), 1046 deletions(-) create mode 100644 crates/fresh-editor/plugins/lib/git_history.ts diff --git a/crates/fresh-editor/plugins/audit_mode.i18n.json b/crates/fresh-editor/plugins/audit_mode.i18n.json index 12e334eb8..572d23d7e 100644 --- a/crates/fresh-editor/plugins/audit_mode.i18n.json +++ b/crates/fresh-editor/plugins/audit_mode.i18n.json @@ -6,6 +6,17 @@ "cmd.stop_review_diff_desc": "Stop the review session", "cmd.refresh_review_diff": "Refresh Review Diff", "cmd.refresh_review_diff_desc": "Refresh the list of changes", + "cmd.review_branch": "Review PR Branch", + "cmd.review_branch_desc": "Review all commits on the current branch against a base ref", + "cmd.stop_review_branch": "Stop Review Branch", + "cmd.stop_review_branch_desc": "Close the PR-branch review panel", + "cmd.refresh_review_branch": "Refresh Review Branch", + "cmd.refresh_review_branch_desc": "Re-fetch the commit list for the current base ref", + "prompt.branch_base": "Base ref to compare against (default: main):", + "status.review_branch_ready": "Reviewing %{count} commits in %{base}..HEAD", + "status.review_branch_empty": "No commits in %{base}..HEAD — nothing to review.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navigate | Enter: focus detail | r: refresh | q: close", "cmd.side_by_side_diff": "Side-by-Side Diff", "cmd.side_by_side_diff_desc": "Show side-by-side diff for current file", "cmd.add_comment": "Review: Add Comment", diff --git a/crates/fresh-editor/plugins/audit_mode.ts b/crates/fresh-editor/plugins/audit_mode.ts index 81d835116..7683753f6 100644 --- a/crates/fresh-editor/plugins/audit_mode.ts +++ b/crates/fresh-editor/plugins/audit_mode.ts @@ -9,6 +9,14 @@ const editor = getEditor(); import { createVirtualBufferFactory } from "./lib/virtual-buffer-factory.ts"; +import { + type GitCommit, + buildCommitDetailEntries, + buildCommitLogEntries, + buildDetailPlaceholderEntries, + fetchCommitShow, + fetchGitLog, +} from "./lib/git_history.ts"; const VirtualBufferFactory = createVirtualBufferFactory(editor); @@ -3874,8 +3882,314 @@ async function side_by_side_diff_current_file() { } registerHandler("side_by_side_diff_current_file", side_by_side_diff_current_file); +// ============================================================================= +// Review PR Branch +// +// A companion view to `start_review_diff` for reviewing the full set of +// commits on a PR branch (rather than just the working-tree changes). It +// opens a buffer group with the commit history on the left (rendered by +// the shared `lib/git_history.ts` helpers the git_log plugin uses) and a +// live-updating `git show` of the selected commit on the right. This reuses +// the same rendering pipeline so both plugins stay visually consistent and +// respect theme keys in one place. +// ============================================================================= + +interface ReviewBranchState { + isOpen: boolean; + groupId: number | null; + logBufferId: number | null; + detailBufferId: number | null; + commits: GitCommit[]; + selectedIndex: number; + baseRef: string; + detailCache: { hash: string; output: string } | null; + pendingDetailId: number; + /** Byte offset of each row in the log panel; final entry = buffer length. */ + logRowByteOffsets: number[]; +} + +const branchState: ReviewBranchState = { + isOpen: false, + groupId: null, + logBufferId: null, + detailBufferId: null, + commits: [], + selectedIndex: 0, + baseRef: "main", + detailCache: null, + pendingDetailId: 0, + logRowByteOffsets: [], +}; + +// UTF-8 byte length helper, local copy so audit_mode doesn't pull in the one +// from git_history (keeps the import list tiny). +function branchUtf8Len(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c <= 0x7f) b += 1; + else if (c <= 0x7ff) b += 2; + else if (c >= 0xd800 && c <= 0xdfff) { b += 4; i++; } + else b += 3; + } + return b; +} + +function branchRowFromByte(bytePos: number): number { + const offs = branchState.logRowByteOffsets; + if (offs.length === 0) return 0; + let lo = 0; + let hi = offs.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (offs[mid] <= bytePos) lo = mid; + else hi = mid - 1; + } + return lo; +} + +function branchIndexFromCursor(bytePos: number): number { + const row = branchRowFromByte(bytePos); + const idx = row - 1; // row 0 is the header + if (idx < 0) return 0; + if (idx >= branchState.commits.length) return branchState.commits.length - 1; + return idx; +} + +function branchRenderLog(): void { + if (branchState.groupId === null) return; + const rawHeader = editor.t("panel.review_branch_header", { base: branchState.baseRef }); + const header = (rawHeader && !rawHeader.startsWith("panel.")) ? rawHeader : `Commits (${branchState.baseRef}..HEAD)`; + const rawFooter = editor.t("panel.review_branch_footer"); + const footer = (rawFooter && !rawFooter.startsWith("panel.")) ? rawFooter : "j/k: navigate · Enter: focus detail · r: refresh · q: close"; + const entries = buildCommitLogEntries(branchState.commits, { + selectedIndex: branchState.selectedIndex, + header, + footer, + propertyType: "branch-commit", + }); + const offsets: number[] = []; + let running = 0; + for (const e of entries) { + offsets.push(running); + running += branchUtf8Len(e.text); + } + offsets.push(running); + branchState.logRowByteOffsets = offsets; + editor.setPanelContent(branchState.groupId, "log", entries); +} + +function branchByteOffsetOfFirstCommit(): number { + return branchState.logRowByteOffsets.length > 1 ? branchState.logRowByteOffsets[1] : 0; +} + +async function branchRefreshDetail(): Promise { + if (branchState.groupId === null) return; + if (branchState.commits.length === 0) { + const msg = editor.t("status.review_branch_empty") || "No commits in the selected range."; + editor.setPanelContent( + branchState.groupId, + "detail", + buildDetailPlaceholderEntries(msg), + ); + return; + } + const idx = Math.max(0, Math.min(branchState.selectedIndex, branchState.commits.length - 1)); + const commit = branchState.commits[idx]; + if (!commit) return; + + if (branchState.detailCache && branchState.detailCache.hash === commit.hash) { + const entries = buildCommitDetailEntries(commit, branchState.detailCache.output, {}); + editor.setPanelContent(branchState.groupId, "detail", entries); + return; + } + const myId = ++branchState.pendingDetailId; + editor.setPanelContent( + branchState.groupId, + "detail", + buildDetailPlaceholderEntries( + editor.t("status.loading_commit", { hash: commit.shortHash }) || `Loading ${commit.shortHash}…`, + ), + ); + const output = await fetchCommitShow(editor, commit.hash); + if (myId !== branchState.pendingDetailId) return; + if (branchState.groupId === null) return; + branchState.detailCache = { hash: commit.hash, output }; + editor.setPanelContent( + branchState.groupId, + "detail", + buildCommitDetailEntries(commit, output, {}), + ); +} + +async function start_review_branch(): Promise { + if (branchState.isOpen) { + editor.setStatus(editor.t("status.already_open") || "Review branch already open"); + return; + } + // Prompt for the base ref so the user can review any PR, not just + // one branched off main. + const input = await editor.prompt( + editor.t("prompt.branch_base") || "Base ref (default: main):", + branchState.baseRef, + ); + if (input === null) { + editor.setStatus(editor.t("status.cancelled") || "Cancelled"); + return; + } + const base = input.trim() || "main"; + branchState.baseRef = base; + + editor.setStatus(editor.t("status.loading") || "Loading commits…"); + branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 }); + if (branchState.commits.length === 0) { + editor.setStatus( + editor.t("status.review_branch_empty", { base }) || + `No commits in ${base}..HEAD — nothing to review.`, + ); + return; + } + + const layout = JSON.stringify({ + type: "split", + direction: "h", + ratio: 0.4, + first: { type: "scrollable", id: "log" }, + second: { type: "scrollable", id: "detail" }, + }); + // `createBufferGroup` is a runtime-only binding (not in the generated + // EditorAPI type); cast to `any` so the type-checker doesn't complain. + const group = await (editor as any).createBufferGroup( + `*Review Branch ${base}..HEAD*`, + "review-branch", + layout, + ); + branchState.groupId = group.groupId as number; + branchState.logBufferId = (group.panels["log"] as number | undefined) ?? null; + branchState.detailBufferId = (group.panels["detail"] as number | undefined) ?? null; + branchState.selectedIndex = 0; + branchState.detailCache = null; + branchState.isOpen = true; + + if (branchState.logBufferId !== null) { + editor.setBufferShowCursors(branchState.logBufferId, true); + } + if (branchState.detailBufferId !== null) { + editor.setBufferShowCursors(branchState.detailBufferId, true); + } + + branchRenderLog(); + if (branchState.logBufferId !== null && branchState.commits.length > 0) { + editor.setBufferCursor(branchState.logBufferId, branchByteOffsetOfFirstCommit()); + } + await branchRefreshDetail(); + + if (branchState.groupId !== null) { + editor.focusBufferGroupPanel(branchState.groupId, "log"); + } + editor.on("cursor_moved", "on_review_branch_cursor_moved"); + + editor.setStatus( + editor.t("status.review_branch_ready", { + count: String(branchState.commits.length), + base, + }) || `Reviewing ${branchState.commits.length} commits in ${base}..HEAD`, + ); +} +registerHandler("start_review_branch", start_review_branch); + +function stop_review_branch(): void { + if (!branchState.isOpen) return; + if (branchState.groupId !== null) editor.closeBufferGroup(branchState.groupId); + editor.off("cursor_moved", "on_review_branch_cursor_moved"); + branchState.isOpen = false; + branchState.groupId = null; + branchState.logBufferId = null; + branchState.detailBufferId = null; + branchState.commits = []; + branchState.selectedIndex = 0; + branchState.detailCache = null; + editor.setStatus(editor.t("status.closed") || "Review branch closed"); +} +registerHandler("stop_review_branch", stop_review_branch); + +async function review_branch_refresh(): Promise { + if (!branchState.isOpen) return; + const base = branchState.baseRef; + branchState.commits = await fetchGitLog(editor, { range: `${base}..HEAD`, maxCommits: 500 }); + branchState.detailCache = null; + if (branchState.selectedIndex >= branchState.commits.length) { + branchState.selectedIndex = Math.max(0, branchState.commits.length - 1); + } + branchRenderLog(); + await branchRefreshDetail(); +} +registerHandler("review_branch_refresh", review_branch_refresh); + +/** Enter: focus the detail panel (so the user can scroll/click within it). */ +function review_branch_enter(): void { + if (branchState.groupId === null) return; + editor.focusBufferGroupPanel(branchState.groupId, "detail"); +} +registerHandler("review_branch_enter", review_branch_enter); + +/** q/Escape: focus-back from detail, or close when already on log. */ +function review_branch_close_or_back(): void { + if (branchState.groupId === null) return; + const active = editor.getActiveBufferId(); + if (branchState.detailBufferId !== null && active === branchState.detailBufferId) { + editor.focusBufferGroupPanel(branchState.groupId, "log"); + return; + } + stop_review_branch(); +} +registerHandler("review_branch_close_or_back", review_branch_close_or_back); + +function on_review_branch_cursor_moved(data: { + buffer_id: number; + cursor_id: number; + old_position: number; + new_position: number; +}): void { + if (!branchState.isOpen) return; + if (data.buffer_id !== branchState.logBufferId) return; + const idx = branchIndexFromCursor(data.new_position); + if (idx === branchState.selectedIndex) return; + branchState.selectedIndex = idx; + branchRenderLog(); + branchRefreshDetail(); +} +registerHandler("on_review_branch_cursor_moved", on_review_branch_cursor_moved); + +editor.defineMode( + "review-branch", + [ + // Mode bindings replace globals, so we re-bind the editor's built-in + // motion actions here explicitly — without this, j/k and Up/Down + // do nothing in the commit list. + ["Up", "move_up"], + ["Down", "move_down"], + ["k", "move_up"], + ["j", "move_down"], + ["PageUp", "page_up"], + ["PageDown", "page_down"], + ["Home", "move_line_start"], + ["End", "move_line_end"], + // Enter: focus the right-hand detail panel. + ["Return", "review_branch_enter"], + ["Tab", "review_branch_enter"], + ["r", "review_branch_refresh"], + ["q", "review_branch_close_or_back"], + ["Escape", "review_branch_close_or_back"], + ], + true, +); + // Register Modes and Commands editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", null); +editor.registerCommand("%cmd.review_branch", "%cmd.review_branch_desc", "start_review_branch", null); +editor.registerCommand("%cmd.stop_review_branch", "%cmd.stop_review_branch_desc", "stop_review_branch", "review-branch"); +editor.registerCommand("%cmd.refresh_review_branch", "%cmd.refresh_review_branch_desc", "review_branch_refresh", "review-branch"); editor.registerCommand("%cmd.stop_review_diff", "%cmd.stop_review_diff_desc", "stop_review_diff", "review-mode"); editor.registerCommand("%cmd.refresh_review_diff", "%cmd.refresh_review_diff_desc", "review_refresh", "review-mode"); editor.registerCommand("%cmd.side_by_side_diff", "%cmd.side_by_side_diff_desc", "side_by_side_diff_current_file", null); diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index ccc018247..0a2b26091 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -1,1163 +1,553 @@ /// -const editor = getEditor(); +import { + type GitCommit, + buildCommitDetailEntries, + buildCommitLogEntries, + buildDetailPlaceholderEntries, + fetchCommitShow, + fetchGitLog, +} from "./lib/git_history.ts"; + +const editor = getEditor(); /** - * Git Log Plugin - Magit-style Git Log Interface + * Git Log Plugin — Magit-style git history interface built on top of the + * modern plugin API primitives: * - * Provides an interactive git log view with: - * - Syntax highlighting for hash, author, date, subject - * - Cursor navigation between commits - * - Enter to open commit details in a virtual buffer + * * `createBufferGroup` for a side-by-side "log | detail" layout that + * appears as a single tab with its own inner scroll state. + * * `setPanelContent` with `TextPropertyEntry[]` + `inlineOverlays` for + * aligned columns and per-theme colouring (every colour is a theme key, + * so the panel follows theme changes). + * * `cursor_moved` subscription to live-update the right-hand detail panel + * as the user scrolls through the commit list. * - * Architecture designed for future magit-style features. + * The rendering helpers live in `lib/git_history.ts` so the same commit-list + * view can be reused by `audit_mode`'s PR-branch review mode. */ // ============================================================================= -// Types and Interfaces +// State // ============================================================================= -interface GitCommit { - hash: string; - shortHash: string; - author: string; - authorEmail: string; - date: string; - relativeDate: string; - subject: string; - body: string; - refs: string; // Branch/tag refs - graph: string; // Graph characters -} - -interface GitLogOptions { - showGraph: boolean; - showRefs: boolean; - maxCommits: number; -} - interface GitLogState { isOpen: boolean; - bufferId: number | null; - splitId: number | null; // The split where git log is displayed - sourceBufferId: number | null; // The buffer that was open before git log (to restore on close) + groupId: number | null; + logBufferId: number | null; + detailBufferId: number | null; commits: GitCommit[]; - options: GitLogOptions; - cachedContent: string; // Store content for highlighting (getBufferText doesn't work for virtual buffers) + selectedIndex: number; + /** Cached `git show` output for the currently-displayed detail commit. */ + detailCache: { hash: string; output: string } | null; + /** + * In-flight detail request id. Used to ignore stale responses when the + * user scrolls through the log faster than `git show` can return. + */ + pendingDetailId: number; + /** + * Byte offset at the start of each row in the rendered log panel, plus + * the total buffer length at the end. Populated by `renderLog` so the + * cursor_moved handler can map byte positions to commit indices without + * relying on `getCursorLine` (which is not implemented for virtual + * buffers). + */ + logRowByteOffsets: number[]; } -interface GitCommitDetailState { - isOpen: boolean; - bufferId: number | null; - splitId: number | null; - commit: GitCommit | null; - cachedContent: string; // Store content for highlighting -} - -interface GitFileViewState { - isOpen: boolean; - bufferId: number | null; - splitId: number | null; - filePath: string | null; - commitHash: string | null; -} - -// ============================================================================= -// State Management -// ============================================================================= - -const gitLogState: GitLogState = { +const state: GitLogState = { isOpen: false, - bufferId: null, - splitId: null, - sourceBufferId: null, + groupId: null, + logBufferId: null, + detailBufferId: null, commits: [], - options: { - showGraph: false, // Disabled by default - graph interferes with format parsing - showRefs: true, - maxCommits: 100, - }, - cachedContent: "", + selectedIndex: 0, + detailCache: null, + pendingDetailId: 0, + logRowByteOffsets: [], }; -const commitDetailState: GitCommitDetailState = { - isOpen: false, - bufferId: null, - splitId: null, - commit: null, - cachedContent: "", -}; - -const fileViewState: GitFileViewState = { - isOpen: false, - bufferId: null, - splitId: null, - filePath: null, - commitHash: null, -}; - -// ============================================================================= -// Color Definitions (for syntax highlighting) -// ============================================================================= +// UTF-8 byte length — the overlay API expects byte offsets; JS strings are +// UTF-16. Matches the helper used by `lib/git_history.ts`. +function utf8Len(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c <= 0x7f) b += 1; + else if (c <= 0x7ff) b += 2; + else if (c >= 0xd800 && c <= 0xdfff) { + b += 4; + i++; + } else b += 3; + } + return b; +} -const colors = { - hash: [255, 180, 50] as [number, number, number], // Yellow/Orange - author: [100, 200, 255] as [number, number, number], // Cyan - date: [150, 255, 150] as [number, number, number], // Green - subject: [255, 255, 255] as [number, number, number], // White - header: [255, 200, 100] as [number, number, number], // Gold - separator: [100, 100, 100] as [number, number, number], // Gray - selected: [80, 80, 120] as [number, number, number], // Selection background - diffAdd: [100, 255, 100] as [number, number, number], // Green for additions - diffDel: [255, 100, 100] as [number, number, number], // Red for deletions - diffHunk: [150, 150, 255] as [number, number, number], // Blue for hunk headers - branch: [255, 150, 255] as [number, number, number], // Magenta for branches - tag: [255, 255, 100] as [number, number, number], // Yellow for tags - remote: [255, 130, 100] as [number, number, number], // Orange for remotes - graph: [150, 150, 150] as [number, number, number], // Gray for graph - // Syntax highlighting colors - syntaxKeyword: [200, 120, 220] as [number, number, number], // Purple for keywords - syntaxString: [180, 220, 140] as [number, number, number], // Light green for strings - syntaxComment: [120, 120, 120] as [number, number, number], // Gray for comments - syntaxNumber: [220, 180, 120] as [number, number, number], // Orange for numbers - syntaxFunction: [100, 180, 255] as [number, number, number], // Blue for functions - syntaxType: [80, 200, 180] as [number, number, number], // Teal for types -}; +/** + * Binary search `logRowByteOffsets` for the 0-indexed row whose byte + * offset is the largest one ≤ `bytePos`. Returns 0 on an empty table. + */ +function rowFromByte(bytePos: number): number { + const offs = state.logRowByteOffsets; + if (offs.length === 0) return 0; + let lo = 0; + let hi = offs.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (offs[mid] <= bytePos) lo = mid; + else hi = mid - 1; + } + return lo; +} // ============================================================================= -// Mode Definitions +// Modes +// +// A buffer group has a single mode shared by all of its panels, so the +// handlers below branch on which panel currently has focus to do the +// right thing (`Return` jumps into the detail panel when pressed in +// the log, and opens the file at the cursor when pressed in the detail). // ============================================================================= -// Define git-log mode with minimal keybindings -// Navigation uses normal cursor movement (arrows, j/k work naturally via parent mode) editor.defineMode( "git-log", [ - ["Return", "git_log_show_commit"], - ["Tab", "git_log_show_commit"], - ["q", "git_log_close"], - ["Escape", "git_log_close"], + // Arrow / vi motion — mode bindings replace globals, so we re-bind the + // editor's built-in move actions here explicitly. Without this, j/k + // and Up/Down do nothing in the log panel. + ["Up", "move_up"], + ["Down", "move_down"], + ["k", "move_up"], + ["j", "move_down"], + ["PageUp", "page_up"], + ["PageDown", "page_down"], + ["Home", "move_line_start"], + ["End", "move_line_end"], + // Plugin actions. + ["Return", "git_log_enter"], + ["Tab", "git_log_tab"], + ["q", "git_log_q"], + ["Escape", "git_log_q"], ["r", "git_log_refresh"], ["y", "git_log_copy_hash"], ], true // read-only ); -// Define git-commit-detail mode for viewing commit details -// Inherits from normal mode for natural cursor movement -editor.defineMode( - "git-commit-detail", - [ - ["Return", "git_commit_detail_open_file"], - ["q", "git_commit_detail_close"], - ["Escape", "git_commit_detail_close"], - ], - true // read-only -); - -// Define git-file-view mode for viewing files at a specific commit -editor.defineMode( - "git-file-view", - [ - ["q", "git_file_view_close"], - ["Escape", "git_file_view_close"], - ], - true // read-only -); - // ============================================================================= -// Git Command Execution +// Panel layout // ============================================================================= -async function fetchGitLog(): Promise { - // Use record separator to reliably split commits - // Format: hash, short hash, author, email, date, relative date, refs, subject, body - const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%d%x00%s%x00%b%x1e"; - - const args = [ - "log", - `--format=${format}`, - `-n${gitLogState.options.maxCommits}`, - ]; - - const cwd = editor.getCwd(); - const result = await editor.spawnProcess("git", args, cwd); - - if (result.exit_code !== 0) { - editor.setStatus(editor.t("status.git_error", { error: result.stderr })); - return []; - } - - const commits: GitCommit[] = []; - // Split by record separator (0x1e) - const records = result.stdout.split("\x1e"); - - for (const record of records) { - if (!record.trim()) continue; - - const parts = record.split("\x00"); - if (parts.length >= 8) { - commits.push({ - hash: parts[0].trim(), - shortHash: parts[1].trim(), - author: parts[2].trim(), - authorEmail: parts[3].trim(), - date: parts[4].trim(), - relativeDate: parts[5].trim(), - refs: parts[6].trim(), - subject: parts[7].trim(), - body: parts[8] ? parts[8].trim() : "", - graph: "", // Graph is handled separately if needed - }); - } - } - - return commits; -} - -async function fetchCommitDiff(hash: string): Promise { - const cwd = editor.getCwd(); - const result = await editor.spawnProcess("git", [ - "show", - "--stat", - "--patch", - hash, - ], cwd); - - if (result.exit_code !== 0) { - return editor.t("status.error_fetching_diff", { error: result.stderr }); - } - - return result.stdout; -} +/** + * Group buffer layout — a vertical split: commit log on the left (60%), + * detail on the right (40%). Uses the runtime's JSON layout schema. + */ +const GROUP_LAYOUT = JSON.stringify({ + type: "split", + direction: "h", // horizontal split = side by side + ratio: 0.6, + first: { type: "scrollable", id: "log" }, + second: { type: "scrollable", id: "detail" }, +}); // ============================================================================= -// Git Log View +// Rendering // ============================================================================= -function formatCommitRow(commit: GitCommit): string { - // Build a structured line for consistent parsing and highlighting - // Format: shortHash (author, relativeDate) subject [refs] - let line = commit.shortHash; - - // Add author in parentheses - line += " (" + commit.author + ", " + commit.relativeDate + ")"; - - // Add subject - line += " " + commit.subject; - - // Add refs at the end if present and enabled - if (gitLogState.options.showRefs && commit.refs) { - line += " " + commit.refs; - } - - return line + "\n"; +function logFooter(count: number): string { + return editor.t("panel.log_footer", { count: String(count) }); } -// Helper to extract content string from entries (for highlighting) -function entriesToContent(entries: TextPropertyEntry[]): string { - return entries.map(e => e.text).join(""); +function detailFooter(hash: string): string { + return editor.t("status.commit_ready", { hash }); } -function buildGitLogEntries(): TextPropertyEntry[] { - const entries: TextPropertyEntry[] = []; - - // Magit-style header - entries.push({ - text: editor.t("panel.commits_header") + "\n", - properties: { type: "section-header" }, +function renderLog(): void { + if (state.groupId === null) return; + const entries = buildCommitLogEntries(state.commits, { + selectedIndex: state.selectedIndex, + header: editor.t("panel.commits_header"), + footer: logFooter(state.commits.length), }); - - if (gitLogState.commits.length === 0) { - entries.push({ - text: editor.t("panel.no_commits") + "\n", - properties: { type: "empty" }, - }); - } else { - // Add each commit - for (let i = 0; i < gitLogState.commits.length; i++) { - const commit = gitLogState.commits[i]; - entries.push({ - text: formatCommitRow(commit), - properties: { - type: "commit", - index: i, - hash: commit.hash, - shortHash: commit.shortHash, - author: commit.author, - date: commit.relativeDate, - subject: commit.subject, - refs: commit.refs, - graph: commit.graph, - }, - }); - } - } - - // Footer with help - entries.push({ - text: "\n", - properties: { type: "blank" }, - }); - entries.push({ - text: editor.t("panel.log_footer", { count: String(gitLogState.commits.length) }) + "\n", - properties: { type: "footer" }, - }); - - return entries; + // Rebuild the byte-offset table used by cursor_moved to map positions + // to commit indices. `offsets[i]` is the byte offset of row i; the + // final entry is the total buffer length, so row lookups clamp + // correctly on the last row. + const offsets: number[] = []; + let running = 0; + for (const e of entries) { + offsets.push(running); + running += utf8Len(e.text); + } + offsets.push(running); + state.logRowByteOffsets = offsets; + editor.setPanelContent(state.groupId, "log", entries); } -function applyGitLogHighlighting(): void { - if (gitLogState.bufferId === null) return; - - const bufferId = gitLogState.bufferId; - - // Clear existing overlays - editor.clearNamespace(bufferId, "gitlog"); - - // Use cached content (getBufferText doesn't work for virtual buffers) - const content = gitLogState.cachedContent; - if (!content) return; - const lines = content.split("\n"); - - // Get cursor line to highlight current row (1-indexed from API) - const cursorLine = editor.getCursorLine(); - const headerLines = 1; // Just "Commits:" header - - let byteOffset = 0; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; - const lineEnd = byteOffset + line.length; - - // Highlight section header - if (line === editor.t("panel.commits_header")) { - editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, { - fg: colors.header, - underline: true, - bold: true, - }); - byteOffset += line.length + 1; - continue; - } - - const commitIndex = lineIdx - headerLines; - if (commitIndex < 0 || commitIndex >= gitLogState.commits.length) { - byteOffset += line.length + 1; - continue; - } - - const commit = gitLogState.commits[commitIndex]; - // cursorLine is 1-indexed, lineIdx is 0-indexed - const isCurrentLine = (lineIdx + 1) === cursorLine; - - // Highlight entire line if cursor is on it (using selected color with underline) - if (isCurrentLine) { - editor.addOverlay(bufferId, "gitlog", lineStart, lineEnd, { - fg: colors.selected, - underline: true, - bold: true, - }); - } - - // Parse the line format: "shortHash (author, relativeDate) subject [refs]" - // Highlight hash (first 7+ chars until space) - const hashEnd = commit.shortHash.length; - editor.addOverlay(bufferId, "gitlog", lineStart, lineStart + hashEnd, { - fg: colors.hash, - }); - - // Highlight author name (inside parentheses) - const authorPattern = "(" + commit.author + ","; - const authorStartInLine = line.indexOf(authorPattern); - if (authorStartInLine >= 0) { - const authorStart = lineStart + authorStartInLine + 1; // skip "(" - const authorEnd = authorStart + commit.author.length; - editor.addOverlay(bufferId, "gitlog", authorStart, authorEnd, { - fg: colors.author, - }); - } - - // Highlight relative date - const datePattern = ", " + commit.relativeDate + ")"; - const dateStartInLine = line.indexOf(datePattern); - if (dateStartInLine >= 0) { - const dateStart = lineStart + dateStartInLine + 2; // skip ", " - const dateEnd = dateStart + commit.relativeDate.length; - editor.addOverlay(bufferId, "gitlog", dateStart, dateEnd, { - fg: colors.date, - }); - } - - // Highlight refs (branches/tags) at end of line if present - if (gitLogState.options.showRefs && commit.refs) { - const refsStartInLine = line.lastIndexOf(commit.refs); - if (refsStartInLine >= 0) { - const refsStart = lineStart + refsStartInLine; - const refsEnd = refsStart + commit.refs.length; - - // Determine color based on ref type - let refColor = colors.branch; - if (commit.refs.includes("tag:")) { - refColor = colors.tag; - } else if (commit.refs.includes("origin/") || commit.refs.includes("remote")) { - refColor = colors.remote; - } - - editor.addOverlay(bufferId, "gitlog", refsStart, refsEnd, { - fg: refColor, - bold: true, - }); - } - } - - byteOffset += line.length + 1; - } +/** Byte offset of the first commit row (i.e. row 1 — row 0 is the header). */ +function byteOffsetOfFirstCommit(): number { + return state.logRowByteOffsets.length > 1 ? state.logRowByteOffsets[1] : 0; } -function updateGitLogView(): void { - if (gitLogState.bufferId !== null) { - const entries = buildGitLogEntries(); - gitLogState.cachedContent = entriesToContent(entries); - editor.setVirtualBufferContent(gitLogState.bufferId, entries); - applyGitLogHighlighting(); - } +function renderDetailPlaceholder(message: string): void { + if (state.groupId === null) return; + editor.setPanelContent( + state.groupId, + "detail", + buildDetailPlaceholderEntries(message) + ); } -// ============================================================================= -// Commit Detail View -// ============================================================================= - -// Parse diff line to extract file and line information -interface DiffContext { - currentFile: string | null; - currentHunkNewStart: number; - currentHunkNewLine: number; // Current line within the new file +function renderDetailForCommit(commit: GitCommit, showOutput: string): void { + if (state.groupId === null) return; + const entries = buildCommitDetailEntries(commit, showOutput, { + footer: editor.t("panel.detail_footer"), + }); + editor.setPanelContent(state.groupId, "detail", entries); } -function buildCommitDetailEntries(commit: GitCommit, showOutput: string): TextPropertyEntry[] { - const entries: TextPropertyEntry[] = []; - const lines = showOutput.split("\n"); - - // Track diff context for file/line navigation - const diffContext: DiffContext = { - currentFile: null, - currentHunkNewStart: 0, - currentHunkNewLine: 0, - }; - - for (const line of lines) { - let lineType = "text"; - const properties: Record = { type: lineType }; - - // Detect diff file header: diff --git a/path b/path - const diffHeaderMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); - if (diffHeaderMatch) { - diffContext.currentFile = diffHeaderMatch[2]; // Use the 'b' (new) file path - diffContext.currentHunkNewStart = 0; - diffContext.currentHunkNewLine = 0; - lineType = "diff-header"; - properties.type = lineType; - properties.file = diffContext.currentFile; - } - // Detect +++ line (new file path) - else if (line.startsWith("+++ b/")) { - diffContext.currentFile = line.slice(6); - lineType = "diff-header"; - properties.type = lineType; - properties.file = diffContext.currentFile; - } - // Detect hunk header: @@ -old,count +new,count @@ - else if (line.startsWith("@@")) { - lineType = "diff-hunk"; - const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (hunkMatch) { - diffContext.currentHunkNewStart = parseInt(hunkMatch[1], 10); - diffContext.currentHunkNewLine = diffContext.currentHunkNewStart; - } - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewStart; - } - // Addition line - else if (line.startsWith("+") && !line.startsWith("+++")) { - lineType = "diff-add"; - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewLine; - diffContext.currentHunkNewLine++; - } - // Deletion line - else if (line.startsWith("-") && !line.startsWith("---")) { - lineType = "diff-del"; - properties.type = lineType; - properties.file = diffContext.currentFile; - // Deletion lines don't advance the new file line counter - } - // Context line (unchanged) - else if (line.startsWith(" ") && diffContext.currentFile && diffContext.currentHunkNewLine > 0) { - lineType = "diff-context"; - properties.type = lineType; - properties.file = diffContext.currentFile; - properties.line = diffContext.currentHunkNewLine; - diffContext.currentHunkNewLine++; - } - // Other diff header lines - else if (line.startsWith("index ") || line.startsWith("--- ")) { - lineType = "diff-header"; - properties.type = lineType; - } - // Commit header lines - else if (line.startsWith("commit ")) { - lineType = "header"; - properties.type = lineType; - const hashMatch = line.match(/^commit ([a-f0-9]+)/); - if (hashMatch) { - properties.hash = hashMatch[1]; - } - } - else if (line.startsWith("Author:")) { - lineType = "meta"; - properties.type = lineType; - properties.field = "author"; - } - else if (line.startsWith("Date:")) { - lineType = "meta"; - properties.type = lineType; - properties.field = "date"; - } - - entries.push({ - text: `${line}\n`, - properties: properties, - }); +/** + * Fetch + render the detail panel for the selected commit. Multiple rapid + * calls can overlap; we tag each call with an id and only render the most + * recent one so the user's final selection always wins. + */ +async function refreshDetail(): Promise { + if (state.groupId === null) return; + if (state.commits.length === 0) { + renderDetailPlaceholder(editor.t("status.no_commits")); + return; } + const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1)); + const commit = state.commits[idx]; + if (!commit) return; - // Footer with help - entries.push({ - text: "\n", - properties: { type: "blank" }, - }); - entries.push({ - text: editor.t("panel.detail_footer") + "\n", - properties: { type: "footer" }, - }); + // Cache hit — render immediately, no git invocation. + if (state.detailCache && state.detailCache.hash === commit.hash) { + renderDetailForCommit(commit, state.detailCache.output); + return; + } - return entries; + const myId = ++state.pendingDetailId; + renderDetailPlaceholder( + editor.t("status.loading_commit", { hash: commit.shortHash }) + ); + const output = await fetchCommitShow(editor, commit.hash); + // Discard stale result if the user moved on. + if (myId !== state.pendingDetailId) return; + if (state.groupId === null) return; + state.detailCache = { hash: commit.hash, output }; + renderDetailForCommit(commit, output); } -function applyCommitDetailHighlighting(): void { - if (commitDetailState.bufferId === null) return; - - const bufferId = commitDetailState.bufferId; - - // Clear existing overlays - editor.clearNamespace(bufferId, "gitdetail"); - - // Use cached content (getBufferText doesn't work for virtual buffers) - const content = commitDetailState.cachedContent; - if (!content) return; - const lines = content.split("\n"); - - let byteOffset = 0; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; - const lineEnd = byteOffset + line.length; +// ============================================================================= +// Selection tracking — keeps `state.selectedIndex` in sync with the log +// panel's native cursor so the highlight and detail stay consistent. +// ============================================================================= - // Highlight diff additions (green) - if (line.startsWith("+") && !line.startsWith("+++")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffAdd, - }); - } - // Highlight diff deletions (red) - else if (line.startsWith("-") && !line.startsWith("---")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffDel, - }); - } - // Highlight hunk headers (cyan/blue) - else if (line.startsWith("@@")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.diffHunk, - bold: true, - }); - } - // Highlight commit hash in "commit " line (git show format) - else if (line.startsWith("commit ")) { - const hashMatch = line.match(/^commit ([a-f0-9]+)/); - if (hashMatch) { - const hashStart = lineStart + 7; // "commit " is 7 chars - editor.addOverlay(bufferId, "gitdetail", hashStart, hashStart + hashMatch[1].length, { - fg: colors.hash, - bold: true, - }); - } - } - // Highlight author line - else if (line.startsWith("Author:")) { - editor.addOverlay(bufferId, "gitdetail", lineStart + 8, lineEnd, { - fg: colors.author, - }); - } - // Highlight date line - else if (line.startsWith("Date:")) { - editor.addOverlay(bufferId, "gitdetail", lineStart + 6, lineEnd, { - fg: colors.date, - }); - } - // Highlight diff file headers - else if (line.startsWith("diff --git")) { - editor.addOverlay(bufferId, "gitdetail", lineStart, lineEnd, { - fg: colors.header, - bold: true, - }); - } +function selectedCommit(): GitCommit | null { + if (state.commits.length === 0) return null; + const i = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1)); + return state.commits[i] ?? null; +} - byteOffset += line.length + 1; - } +function indexFromCursorByte(bytePos: number): number { + // Row 0 is the header; commits live at rows 1..N. + const row0 = rowFromByte(bytePos); + const idx = row0 - 1; + if (idx < 0) return 0; + if (idx >= state.commits.length) return state.commits.length - 1; + return idx; } // ============================================================================= -// Public Commands - Git Log +// Commands // ============================================================================= -async function show_git_log() : Promise { - if (gitLogState.isOpen) { +async function show_git_log(): Promise { + if (state.isOpen) { editor.setStatus(editor.t("status.already_open")); return; } - editor.setStatus(editor.t("status.loading")); - // Store the current split ID and buffer ID before opening git log - gitLogState.splitId = editor.getActiveSplitId(); - gitLogState.sourceBufferId = editor.getActiveBufferId(); - - // Fetch commits - gitLogState.commits = await fetchGitLog(); - - if (gitLogState.commits.length === 0) { + state.commits = await fetchGitLog(editor); + if (state.commits.length === 0) { editor.setStatus(editor.t("status.no_commits")); - gitLogState.splitId = null; return; } - // Build entries and cache content for highlighting - const entries = buildGitLogEntries(); - gitLogState.cachedContent = entriesToContent(entries); - - // Create virtual buffer in the current split (replacing current buffer) - const result = await editor.createVirtualBufferInExistingSplit({ - name: "*Git Log*", - mode: "git-log", - readOnly: true, - entries: entries, - splitId: gitLogState.splitId!, - showLineNumbers: false, - showCursors: true, - editingDisabled: true, - }); - - if (result !== null) { - gitLogState.isOpen = true; - gitLogState.bufferId = result.bufferId; - - // Apply syntax highlighting - applyGitLogHighlighting(); - - editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) })); - editor.debug("Git log panel opened"); - } else { - gitLogState.splitId = null; - editor.setStatus(editor.t("status.failed_open")); - } + // `createBufferGroup` is not currently included in the generated + // `EditorAPI` type (it's a runtime-only binding, same as in audit_mode), + // so we cast to `any` to keep the type checker happy. + const group = await (editor as any).createBufferGroup( + "*Git Log*", + "git-log", + GROUP_LAYOUT + ); + state.groupId = group.groupId as number; + state.logBufferId = (group.panels["log"] as number | undefined) ?? null; + state.detailBufferId = (group.panels["detail"] as number | undefined) ?? null; + state.selectedIndex = 0; + state.detailCache = null; + state.isOpen = true; + + // The log panel owns a native cursor so j/k/Up/Down navigate commits, + // and the detail panel also gets a cursor so diff lines can be clicked + // / traversed before pressing Enter to open a file. + if (state.logBufferId !== null) { + editor.setBufferShowCursors(state.logBufferId, true); + } + if (state.detailBufferId !== null) { + editor.setBufferShowCursors(state.detailBufferId, true); + // Per-panel mode: the group was created with "git-log" which applies + // to the initially-focused panel (log). The detail panel's mode is + // set when we focus into it. + } + + renderLog(); + // Position the cursor on the first commit row (row index 1 — row 0 is + // the "Commits:" header). + if (state.logBufferId !== null && state.commits.length > 0) { + editor.setBufferCursor(state.logBufferId, byteOffsetOfFirstCommit()); + } + await refreshDetail(); + + if (state.groupId !== null) { + editor.focusBufferGroupPanel(state.groupId, "log"); + } + editor.on("cursor_moved", "on_git_log_cursor_moved"); + + editor.setStatus( + editor.t("status.log_ready", { count: String(state.commits.length) }) + ); } registerHandler("show_git_log", show_git_log); -function git_log_close() : void { - if (!gitLogState.isOpen) { - return; - } - - // Restore the original buffer in the split - if (gitLogState.splitId !== null && gitLogState.sourceBufferId !== null) { - editor.setSplitBuffer(gitLogState.splitId, gitLogState.sourceBufferId); - } - - // Close the git log buffer (it's no longer displayed) - if (gitLogState.bufferId !== null) { - editor.closeBuffer(gitLogState.bufferId); - } - - gitLogState.isOpen = false; - gitLogState.bufferId = null; - gitLogState.splitId = null; - gitLogState.sourceBufferId = null; - gitLogState.commits = []; +function git_log_close(): void { + if (!state.isOpen) return; + if (state.groupId !== null) { + editor.closeBufferGroup(state.groupId); + } + editor.off("cursor_moved", "on_git_log_cursor_moved"); + state.isOpen = false; + state.groupId = null; + state.logBufferId = null; + state.detailBufferId = null; + state.commits = []; + state.selectedIndex = 0; + state.detailCache = null; editor.setStatus(editor.t("status.closed")); } registerHandler("git_log_close", git_log_close); -// Cursor moved handler for git log - update highlighting and status -function on_git_log_cursor_moved(data: { - buffer_id: number; - cursor_id: number; - old_position: number; - new_position: number; -}): void { - // Only handle cursor movement in our git log buffer - if (gitLogState.bufferId === null || data.buffer_id !== gitLogState.bufferId) { - return; - } - - // Re-apply highlighting to update cursor line highlight - applyGitLogHighlighting(); - - // Get cursor line to show status - const cursorLine = editor.getCursorLine(); - const headerLines = 1; - const commitIndex = cursorLine - headerLines; - - if (commitIndex >= 0 && commitIndex < gitLogState.commits.length) { - editor.setStatus(editor.t("status.commit_position", { current: String(commitIndex + 1), total: String(gitLogState.commits.length) })); - } -} -registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved); - -// Register cursor movement handler -editor.on("cursor_moved", "on_git_log_cursor_moved"); - -async function git_log_refresh() : Promise { - if (!gitLogState.isOpen) return; - +async function git_log_refresh(): Promise { + if (!state.isOpen) return; editor.setStatus(editor.t("status.refreshing")); - gitLogState.commits = await fetchGitLog(); - updateGitLogView(); - editor.setStatus(editor.t("status.refreshed", { count: String(gitLogState.commits.length) })); + state.commits = await fetchGitLog(editor); + state.detailCache = null; + if (state.selectedIndex >= state.commits.length) { + state.selectedIndex = Math.max(0, state.commits.length - 1); + } + renderLog(); + await refreshDetail(); + editor.setStatus( + editor.t("status.refreshed", { count: String(state.commits.length) }) + ); } registerHandler("git_log_refresh", git_log_refresh); -// Helper function to get commit at current cursor position -function getCommitAtCursor(): GitCommit | null { - if (gitLogState.bufferId === null) return null; - - // Use text properties to find which commit the cursor is on - // This is more reliable than line number calculation - const props = editor.getTextPropertiesAtCursor(gitLogState.bufferId); - - if (props.length > 0) { - const prop = props[0]; - // Check if cursor is on a commit line (has type "commit" and index) - if (prop.type === "commit" && typeof prop.index === "number") { - const index = prop.index as number; - if (index >= 0 && index < gitLogState.commits.length) { - return gitLogState.commits[index]; - } - } - // Also support finding commit by hash (alternative lookup) - if (prop.hash && typeof prop.hash === "string") { - return gitLogState.commits.find(c => c.hash === prop.hash) || null; - } - } - - return null; -} - -async function git_log_show_commit() : Promise { - if (!gitLogState.isOpen || gitLogState.commits.length === 0) return; - if (gitLogState.splitId === null) return; - - const commit = getCommitAtCursor(); +function git_log_copy_hash(): void { + const commit = selectedCommit(); if (!commit) { editor.setStatus(editor.t("status.move_to_commit")); return; } + editor.copyToClipboard(commit.hash); + editor.setStatus( + editor.t("status.hash_copied", { + short: commit.shortHash, + full: commit.hash, + }) + ); +} +registerHandler("git_log_copy_hash", git_log_copy_hash); - editor.setStatus(editor.t("status.loading_commit", { hash: commit.shortHash })); - - // Fetch full commit info using git show (includes header and diff) - const showOutput = await fetchCommitDiff(commit.hash); - - // Build entries using raw git show output - const entries = buildCommitDetailEntries(commit, showOutput); - - // Cache content for highlighting (getBufferText doesn't work for virtual buffers) - commitDetailState.cachedContent = entriesToContent(entries); - - // Create virtual buffer in the current split (replacing git log view) - const result = await editor.createVirtualBufferInExistingSplit({ - name: `*Commit: ${commit.shortHash}*`, - mode: "git-commit-detail", - readOnly: true, - entries: entries, - splitId: gitLogState.splitId!, - showLineNumbers: false, // Disable line numbers for cleaner diff view - showCursors: true, - editingDisabled: true, - }); - - if (result !== null) { - commitDetailState.isOpen = true; - commitDetailState.bufferId = result.bufferId; - commitDetailState.splitId = gitLogState.splitId; - commitDetailState.commit = commit; - - // Apply syntax highlighting - applyCommitDetailHighlighting(); +/** Is the detail panel the currently-focused buffer? */ +function isDetailFocused(): boolean { + return ( + state.detailBufferId !== null && + editor.getActiveBufferId() === state.detailBufferId + ); +} - editor.setStatus(editor.t("status.commit_ready", { hash: commit.shortHash })); +function git_log_tab(): void { + if (state.groupId === null) return; + if (isDetailFocused()) { + editor.focusBufferGroupPanel(state.groupId, "log"); } else { - editor.setStatus(editor.t("status.failed_open_details")); + editor.focusBufferGroupPanel(state.groupId, "detail"); + const commit = selectedCommit(); + if (commit) editor.setStatus(detailFooter(commit.shortHash)); } } -registerHandler("git_log_show_commit", git_log_show_commit); +registerHandler("git_log_tab", git_log_tab); -function git_log_copy_hash() : void { - if (!gitLogState.isOpen || gitLogState.commits.length === 0) return; - - const commit = getCommitAtCursor(); - if (!commit) { - editor.setStatus(editor.t("status.move_to_commit")); +/** + * Enter: on the log panel jumps focus into the detail panel; on the detail + * panel opens the file at the cursor position (if any). + */ +function git_log_enter(): void { + if (state.groupId === null) return; + if (isDetailFocused()) { + git_log_detail_open_file(); return; } - - // Copy hash to clipboard - editor.copyToClipboard(commit.hash); - editor.setStatus(editor.t("status.hash_copied", { short: commit.shortHash, full: commit.hash })); + editor.focusBufferGroupPanel(state.groupId, "detail"); + const commit = selectedCommit(); + if (commit) editor.setStatus(detailFooter(commit.shortHash)); } -registerHandler("git_log_copy_hash", git_log_copy_hash); +registerHandler("git_log_enter", git_log_enter); -// ============================================================================= -// Public Commands - Commit Detail -// ============================================================================= - -function git_commit_detail_close() : void { - if (!commitDetailState.isOpen) { +/** + * q/Escape: closes the entire log group when the log panel is focused, + * otherwise steps back into the log panel (so the user's mental model + * matches the previous "detail is a stacked view on top of the log"). + */ +function git_log_q(): void { + if (state.groupId === null) return; + if (isDetailFocused()) { + editor.focusBufferGroupPanel(state.groupId, "log"); + editor.setStatus( + editor.t("status.log_ready", { count: String(state.commits.length) }) + ); return; } - - // Go back to the git log view by restoring the git log buffer - if (commitDetailState.splitId !== null && gitLogState.bufferId !== null) { - editor.setSplitBuffer(commitDetailState.splitId, gitLogState.bufferId); - // Re-apply highlighting since we're switching back - applyGitLogHighlighting(); - } - - // Close the commit detail buffer (it's no longer displayed) - if (commitDetailState.bufferId !== null) { - editor.closeBuffer(commitDetailState.bufferId); - } - - commitDetailState.isOpen = false; - commitDetailState.bufferId = null; - commitDetailState.splitId = null; - commitDetailState.commit = null; - - editor.setStatus(editor.t("status.log_ready", { count: String(gitLogState.commits.length) })); + git_log_close(); } -registerHandler("git_commit_detail_close", git_commit_detail_close); +registerHandler("git_log_q", git_log_q); -// Close file view and go back to commit detail -function git_file_view_close() : void { - if (!fileViewState.isOpen) { - return; - } +// ============================================================================= +// Detail panel — open file at commit +// ============================================================================= - // Go back to the commit detail view by restoring the commit detail buffer - if (fileViewState.splitId !== null && commitDetailState.bufferId !== null) { - editor.setSplitBuffer(fileViewState.splitId, commitDetailState.bufferId); - // Re-apply highlighting since we're switching back - applyCommitDetailHighlighting(); - } +async function git_log_detail_open_file(): Promise { + if (state.detailBufferId === null) return; + const commit = selectedCommit(); + if (!commit) return; - // Close the file view buffer (it's no longer displayed) - if (fileViewState.bufferId !== null) { - editor.closeBuffer(fileViewState.bufferId); + const props = editor.getTextPropertiesAtCursor(state.detailBufferId); + if (props.length === 0) { + editor.setStatus(editor.t("status.move_to_diff")); + return; } - - fileViewState.isOpen = false; - fileViewState.bufferId = null; - fileViewState.splitId = null; - fileViewState.filePath = null; - fileViewState.commitHash = null; - - if (commitDetailState.commit) { - editor.setStatus(editor.t("status.commit_ready", { hash: commitDetailState.commit.shortHash })); + const file = props[0].file as string | undefined; + const line = (props[0].line as number | undefined) ?? 1; + if (!file) { + editor.setStatus(editor.t("status.move_to_diff_with_context")); + return; } -} -registerHandler("git_file_view_close", git_file_view_close); -// Fetch file content at a specific commit -async function fetchFileAtCommit(commitHash: string, filePath: string): Promise { - const cwd = editor.getCwd(); + editor.setStatus( + editor.t("status.file_loading", { file, hash: commit.shortHash }) + ); const result = await editor.spawnProcess("git", [ "show", - `${commitHash}:${filePath}`, - ], cwd); - + `${commit.hash}:${file}`, + ]); if (result.exit_code !== 0) { - return null; + editor.setStatus( + editor.t("status.file_not_found", { file, hash: commit.shortHash }) + ); + return; } - return result.stdout; -} - -// Get language type from file extension -function getLanguageFromPath(filePath: string): string { - const ext = editor.pathExtname(filePath).toLowerCase(); - const extMap: Record = { - ".rs": "rust", - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".py": "python", - ".go": "go", - ".c": "c", - ".cpp": "cpp", - ".h": "c", - ".hpp": "cpp", - ".java": "java", - ".rb": "ruby", - ".sh": "shell", - ".bash": "shell", - ".zsh": "shell", - ".toml": "toml", - ".yaml": "yaml", - ".yml": "yaml", - ".json": "json", - ".md": "markdown", - ".css": "css", - ".html": "html", - ".xml": "xml", - }; - return extMap[ext] || "text"; -} - -// Keywords for different languages -const languageKeywords: Record = { - rust: ["fn", "let", "mut", "const", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for", "while", "loop", "if", "else", "match", "return", "async", "await", "move", "self", "Self", "super", "crate", "where", "type", "static", "unsafe", "extern", "ref", "dyn", "as", "in", "true", "false"], - typescript: ["function", "const", "let", "var", "class", "interface", "type", "extends", "implements", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static", "readonly", "private", "public", "protected", "abstract", "enum"], - javascript: ["function", "const", "let", "var", "class", "extends", "import", "export", "from", "async", "await", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "this", "super", "null", "undefined", "true", "false", "try", "catch", "finally", "throw", "typeof", "instanceof", "void", "delete", "in", "of", "static"], - python: ["def", "class", "if", "elif", "else", "for", "while", "try", "except", "finally", "with", "as", "import", "from", "return", "yield", "raise", "pass", "break", "continue", "and", "or", "not", "in", "is", "lambda", "None", "True", "False", "global", "nonlocal", "async", "await", "self"], - go: ["func", "var", "const", "type", "struct", "interface", "map", "chan", "if", "else", "for", "range", "switch", "case", "default", "break", "continue", "return", "go", "defer", "select", "import", "package", "nil", "true", "false", "make", "new", "len", "cap", "append", "copy", "delete", "panic", "recover"], -}; - -// Apply basic syntax highlighting to file view -function applyFileViewHighlighting(bufferId: number, content: string, filePath: string): void { - const language = getLanguageFromPath(filePath); - const keywords = languageKeywords[language] || []; - const lines = content.split("\n"); - - // Clear existing overlays - editor.clearNamespace(bufferId, "syntax"); - - let byteOffset = 0; - let inMultilineComment = false; - let inMultilineString = false; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const lineStart = byteOffset; + const lines = result.stdout.split("\n"); + const entries: TextPropertyEntry[] = lines.map((l, i) => ({ + text: l + (i < lines.length - 1 ? "\n" : ""), + properties: { type: "content", line: i + 1 }, + })); - // Skip empty lines - if (line.trim() === "") { - byteOffset += line.length + 1; - continue; - } - - // Check for multiline comment start/end - if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") { - if (line.includes("/*") && !line.includes("*/")) { - inMultilineComment = true; - } - if (inMultilineComment) { - editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, { - fg: colors.syntaxComment, - italic: true, - }); - if (line.includes("*/")) { - inMultilineComment = false; - } - byteOffset += line.length + 1; - continue; - } - } - - // Python multiline strings - if (language === "python" && (line.includes('"""') || line.includes("'''"))) { - const tripleQuote = line.includes('"""') ? '"""' : "'''"; - const firstIdx = line.indexOf(tripleQuote); - const secondIdx = line.indexOf(tripleQuote, firstIdx + 3); - if (firstIdx >= 0 && secondIdx < 0) { - inMultilineString = !inMultilineString; - } - } - if (inMultilineString) { - editor.addOverlay(bufferId, "syntax", lineStart, lineStart + line.length, { - fg: colors.syntaxString, - }); - byteOffset += line.length + 1; - continue; - } - - // Single-line comment detection - let commentStart = -1; - if (language === "rust" || language === "c" || language === "cpp" || language === "java" || language === "javascript" || language === "typescript" || language === "go") { - commentStart = line.indexOf("//"); - } else if (language === "python" || language === "shell" || language === "ruby" || language === "yaml" || language === "toml") { - commentStart = line.indexOf("#"); - } - - if (commentStart >= 0) { - editor.addOverlay(bufferId, "syntax", lineStart + commentStart, lineStart + line.length, { - fg: colors.syntaxComment, - italic: true, - }); - } - - // String highlighting (simple: find "..." and '...') - let i = 0; - while (i < line.length) { - const ch = line[i]; - if (ch === '"' || ch === "'") { - const quote = ch; - const start = i; - i++; - while (i < line.length && line[i] !== quote) { - if (line[i] === '\\') i++; // Skip escaped chars - i++; - } - if (i < line.length) i++; // Include closing quote - const end = i; - if (commentStart < 0 || start < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + start, lineStart + end, { - fg: colors.syntaxString, - }); - } - } else { - i++; - } - } - - // Keyword highlighting - for (const keyword of keywords) { - const regex = new RegExp(`\\b${keyword}\\b`, "g"); - let match: RegExpExecArray | null; - while ((match = regex.exec(line)) !== null) { - const kwStart = match.index; - const kwEnd = kwStart + keyword.length; - // Don't highlight if inside comment - if (commentStart < 0 || kwStart < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + kwStart, lineStart + kwEnd, { - fg: colors.syntaxKeyword, - bold: true, - }); - } - } - } - - // Number highlighting - const numberRegex = /\b\d+(\.\d+)?\b/g; - let numMatch: RegExpExecArray | null; - while ((numMatch = numberRegex.exec(line)) !== null) { - const numStart = numMatch.index; - const numEnd = numStart + numMatch[0].length; - if (commentStart < 0 || numStart < commentStart) { - editor.addOverlay(bufferId, "syntax", lineStart + numStart, lineStart + numEnd, { - fg: colors.syntaxNumber, - }); - } - } - - byteOffset += line.length + 1; + const name = `${file} @ ${commit.shortHash}`; + const view = await editor.createVirtualBuffer({ + name, + mode: "git-log-file-view", + readOnly: true, + editingDisabled: true, + showLineNumbers: true, + entries, + }); + if (view) { + // Position cursor near target line — best-effort; the host may not + // have a byte offset for virtual buffer lines until layout runs. + editor.setStatus( + editor.t("status.file_view_ready", { + file, + hash: commit.shortHash, + line: String(line), + }) + ); + } else { + editor.setStatus(editor.t("status.failed_open_file", { file })); } } +registerHandler("git_log_detail_open_file", git_log_detail_open_file); -// Open file at the current diff line position - shows file as it was at that commit -async function git_commit_detail_open_file() : Promise { - if (!commitDetailState.isOpen || commitDetailState.bufferId === null) { - return; - } - - const commit = commitDetailState.commit; - if (!commit) { - editor.setStatus(editor.t("status.move_to_commit")); - return; - } - - // Get text properties at cursor position to find file/line info - const props = editor.getTextPropertiesAtCursor(commitDetailState.bufferId); - - if (props.length > 0) { - const file = props[0].file as string | undefined; - const line = props[0].line as number | undefined; - - if (file) { - editor.setStatus(editor.t("status.file_loading", { file, hash: commit.shortHash })); - - // Fetch file content at this commit - const content = await fetchFileAtCommit(commit.hash, file); - - if (content === null) { - editor.setStatus(editor.t("status.file_not_found", { file, hash: commit.shortHash })); - return; - } - - // Build entries for the virtual buffer - one entry per line for proper line tracking - const lines = content.split("\n"); - const entries: TextPropertyEntry[] = []; - - for (let i = 0; i < lines.length; i++) { - entries.push({ - text: lines[i] + (i < lines.length - 1 ? "\n" : ""), - properties: { type: "content", line: i + 1 }, - }); - } - - // Create a read-only virtual buffer with the file content - const result = await editor.createVirtualBufferInExistingSplit({ - name: `${file} @ ${commit.shortHash}`, - mode: "git-file-view", - readOnly: true, - entries: entries, - splitId: commitDetailState.splitId!, - showLineNumbers: true, - showCursors: true, - editingDisabled: true, - }); +// File-view mode so `q` closes the tab and returns to the group. +editor.defineMode( + "git-log-file-view", + [ + ["q", "git_log_file_view_close"], + ["Escape", "git_log_file_view_close"], + ], + true +); - if (result !== null) { - // Track file view state so we can navigate back - fileViewState.isOpen = true; - fileViewState.bufferId = result.bufferId; - fileViewState.splitId = commitDetailState.splitId; - fileViewState.filePath = file; - fileViewState.commitHash = commit.hash; +function git_log_file_view_close(): void { + const id = editor.getActiveBufferId(); + if (id) editor.closeBuffer(id); +} +registerHandler("git_log_file_view_close", git_log_file_view_close); - // Apply syntax highlighting based on file type - applyFileViewHighlighting(result.bufferId, content, file); +// ============================================================================= +// Cursor tracking — live-update the detail panel as the user scrolls through +// the commit list. +// ============================================================================= - const targetLine = line || 1; - editor.setStatus(editor.t("status.file_view_ready", { file, hash: commit.shortHash, line: String(targetLine) })); - } else { - editor.setStatus(editor.t("status.failed_open_file", { file })); - } - } else { - editor.setStatus(editor.t("status.move_to_diff_with_context")); - } - } else { - editor.setStatus(editor.t("status.move_to_diff")); +function on_git_log_cursor_moved(data: { + buffer_id: number; + cursor_id: number; + old_position: number; + new_position: number; +}): void { + if (!state.isOpen) return; + // Only react to movement inside the log panel. + if (data.buffer_id !== state.logBufferId) return; + + // Map the cursor's byte offset to a commit index via the row-offset + // table built in `renderLog`. This avoids relying on `getCursorLine` + // which is not implemented for virtual buffers. + const idx = indexFromCursorByte(data.new_position); + if (idx === state.selectedIndex) return; + state.selectedIndex = idx; + renderLog(); + // Kick off the detail refresh — it's async and tagged so a rapid + // stream of movements collapses to a single render for the final row. + refreshDetail(); + + const commit = state.commits[idx]; + if (commit) { + editor.setStatus( + editor.t("status.commit_position", { + current: String(idx + 1), + total: String(state.commits.length), + }) + ); } } -registerHandler("git_commit_detail_open_file", git_commit_detail_open_file); +registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved); // ============================================================================= -// Command Registration +// Command registration // ============================================================================= editor.registerCommand( @@ -1166,14 +556,12 @@ editor.registerCommand( "show_git_log", null ); - editor.registerCommand( "%cmd.git_log_close", "%cmd.git_log_close_desc", "git_log_close", null ); - editor.registerCommand( "%cmd.git_log_refresh", "%cmd.git_log_refresh_desc", @@ -1181,8 +569,4 @@ editor.registerCommand( null ); -// ============================================================================= -// Plugin Initialization -// ============================================================================= - -editor.debug("Git Log plugin initialized - Use 'Git Log' command to open"); +editor.debug("Git Log plugin initialized (modern buffer-group layout)"); diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts new file mode 100644 index 000000000..7091959f9 --- /dev/null +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -0,0 +1,546 @@ +/// + +/** + * Shared git history rendering helpers used by the git log plugin and the + * review-diff plugin's branch review mode. + * + * All rendering uses theme-keyed colours (`syntax.keyword`, `editor.fg`, etc.) + * so the panels stay consistent with the editor's current theme. The entry + * builders produce `TextPropertyEntry[]` lists whose sub-ranges are styled + * via `inlineOverlays` — no separate imperative overlay pass is required. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export interface GitCommit { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + relativeDate: string; + subject: string; + body: string; + refs: string; +} + +export interface FetchGitLogOptions { + /** Max commits to fetch (default: 200). */ + maxCommits?: number; + /** Optional revision range (e.g. "main..HEAD"). Defaults to HEAD. */ + range?: string; + /** Working directory. Defaults to `editor.getCwd()`. */ + cwd?: string; +} + +export interface BuildCommitLogEntriesOptions { + /** Index of the "selected" row — rendered with the selected-bg highlight. */ + selectedIndex?: number; + /** Optional header string (e.g. "Commits:"). Default: "Commits:". */ + header?: string; + /** Footer line (status hint). Omitted when null/undefined. */ + footer?: string | null; + /** Target width for padding column alignment (default 0 = no padding). */ + width?: number; + /** "log" property-type prefix for entries (default "log-commit"). */ + propertyType?: string; +} + +// ============================================================================= +// Theme keys +// ============================================================================= + +export const GIT_THEME = { + header: "syntax.keyword" as OverlayColorSpec, + separator: "ui.split_separator_fg" as OverlayColorSpec, + hash: "syntax.number" as OverlayColorSpec, + author: "syntax.function" as OverlayColorSpec, + date: "syntax.string" as OverlayColorSpec, + subject: "editor.fg" as OverlayColorSpec, + subjectMuted: "editor.line_number_fg" as OverlayColorSpec, + refBranch: "syntax.type" as OverlayColorSpec, + refRemote: "syntax.function" as OverlayColorSpec, + refTag: "syntax.number" as OverlayColorSpec, + refHead: "syntax.keyword" as OverlayColorSpec, + diffAdd: "editor.diff_add_bg" as OverlayColorSpec, + diffRemove: "editor.diff_remove_bg" as OverlayColorSpec, + diffAddFg: "diagnostic.info_fg" as OverlayColorSpec, + diffRemoveFg: "diagnostic.error_fg" as OverlayColorSpec, + diffHunk: "syntax.type" as OverlayColorSpec, + metaLabel: "editor.line_number_fg" as OverlayColorSpec, + selectionBg: "editor.selection_bg" as OverlayColorSpec, + sectionBg: "editor.current_line_bg" as OverlayColorSpec, + footer: "editor.line_number_fg" as OverlayColorSpec, +}; + +// ============================================================================= +// Author initials helper — compact "(AL)" / "(JD)" style label used in the +// aligned log view. Falls back to the raw author when no initials can be +// extracted. +// ============================================================================= + +export function authorInitials(author: string): string { + const cleaned = author.replace(/[<>].*/g, "").trim(); + const parts = cleaned.split(/\s+/).filter(p => p.length > 0); + if (parts.length === 0) return "??"; + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + const first = parts[0][0] || "?"; + const last = parts[parts.length - 1][0] || "?"; + return (first + last).toUpperCase(); +} + +// ============================================================================= +// Commit fetching +// ============================================================================= + +export async function fetchGitLog( + editor: EditorAPI, + opts: FetchGitLogOptions = {} +): Promise { + const maxCommits = opts.maxCommits ?? 200; + const cwd = opts.cwd ?? editor.getCwd(); + const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%D%x00%s%x00%b%x1e"; + const args = ["log", `--format=${format}`, `-n${maxCommits}`]; + if (opts.range) args.push(opts.range); + + const result = await editor.spawnProcess("git", args, cwd); + if (result.exit_code !== 0) return []; + + const commits: GitCommit[] = []; + const records = result.stdout.split("\x1e"); + for (const record of records) { + if (!record.trim()) continue; + const parts = record.split("\x00"); + if (parts.length < 8) continue; + commits.push({ + hash: parts[0].trim(), + shortHash: parts[1].trim(), + author: parts[2].trim(), + authorEmail: parts[3].trim(), + date: parts[4].trim(), + relativeDate: parts[5].trim(), + refs: parts[6].trim(), + subject: parts[7].trim(), + body: parts[8] ? parts[8].trim() : "", + }); + } + return commits; +} + +export async function fetchCommitShow( + editor: EditorAPI, + hash: string, + cwd?: string +): Promise { + const result = await editor.spawnProcess( + "git", + ["show", "--stat", "--patch", hash], + cwd ?? editor.getCwd() + ); + if (result.exit_code !== 0) return result.stderr || "(no output)"; + return result.stdout; +} + +// ============================================================================= +// UTF-8 byte-length helper — the runtime's overlay offsets are in bytes, but +// JS strings are UTF-16. Colocated here so consumers don't have to redefine it. +// ============================================================================= + +export function byteLength(s: string): number { + let b = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code <= 0x7f) b += 1; + else if (code <= 0x7ff) b += 2; + else if (code >= 0xd800 && code <= 0xdfff) { + b += 4; + i++; + } else b += 3; + } + return b; +} + +// ============================================================================= +// Commit log entry building +// ============================================================================= + +/** + * Compute column widths for the aligned commit-log table. Returns widths for + * (hash, date, initials) columns. Subject and refs fill the remainder. + */ +function commitLogColumnWidths(commits: GitCommit[]): { + hashW: number; + dateW: number; + authorW: number; +} { + let hashW = 7; + let dateW = 10; + let authorW = 2; + for (const c of commits) { + if (c.shortHash.length > hashW) hashW = c.shortHash.length; + if (c.relativeDate.length > dateW) dateW = c.relativeDate.length; + const ini = authorInitials(c.author); + if (ini.length > authorW) authorW = ini.length; + } + // Clamp so a pathological author/date doesn't swallow the subject column. + if (hashW > 12) hashW = 12; + if (dateW > 16) dateW = 16; + if (authorW > 4) authorW = 4; + return { hashW, dateW, authorW }; +} + +/** + * Classify a git ref decoration tag so it can be coloured appropriately. + * Matches a single comma-separated entry from `%D` output, e.g. + * "HEAD -> main", "origin/main", "tag: v1.0". + */ +function refTokenColor(token: string): OverlayColorSpec { + const t = token.trim(); + if (t.startsWith("tag:")) return GIT_THEME.refTag; + if (t.startsWith("HEAD")) return GIT_THEME.refHead; + if (t.includes("/")) return GIT_THEME.refRemote; + return GIT_THEME.refBranch; +} + +/** + * Build a styled commit-log entry row with aligned columns. All styling uses + * `inlineOverlays` with theme keys — no imperative overlay pass needed. + */ +function buildCommitRowEntry( + commit: GitCommit, + index: number, + isSelected: boolean, + widths: { hashW: number; dateW: number; authorW: number }, + propertyType: string +): TextPropertyEntry { + const shortHash = commit.shortHash.padEnd(widths.hashW); + const date = commit.relativeDate.padEnd(widths.dateW); + const ini = authorInitials(commit.author).padEnd(widths.authorW); + + const prefix = " "; + let byte = byteLength(prefix); + let text = prefix; + const overlays: InlineOverlay[] = []; + + // Hash column + overlays.push({ + start: byte, + end: byte + byteLength(shortHash), + style: { fg: GIT_THEME.hash, bold: true }, + }); + text += shortHash; + byte += byteLength(shortHash); + + // Space + text += " "; + byte += 2; + + // Date column + overlays.push({ + start: byte, + end: byte + byteLength(date), + style: { fg: GIT_THEME.date }, + }); + text += date; + byte += byteLength(date); + + // Space + text += " "; + byte += 2; + + // Author initials in parentheses + const authorOpen = "("; + const authorClose = ")"; + text += authorOpen; + byte += byteLength(authorOpen); + overlays.push({ + start: byte, + end: byte + byteLength(ini), + style: { fg: GIT_THEME.author, bold: true }, + }); + text += ini; + byte += byteLength(ini); + text += authorClose; + byte += byteLength(authorClose); + + // Space + text += " "; + byte += 1; + + // Subject + overlays.push({ + start: byte, + end: byte + byteLength(commit.subject), + style: { fg: GIT_THEME.subject }, + }); + text += commit.subject; + byte += byteLength(commit.subject); + + // Refs (if any) — tokenise and colour each separately. %D returns a + // comma-separated list like "HEAD -> main, origin/main, tag: v1". + if (commit.refs) { + text += " "; + byte += 2; + const tokens = commit.refs.split(",").map(t => t.trim()).filter(t => t.length > 0); + for (let i = 0; i < tokens.length; i++) { + if (i > 0) { + text += " "; + byte += 1; + } + // "HEAD -> main" renders as two logical tokens inside one entry; + // treat the whole token as one coloured chunk for simplicity. + const t = tokens[i]; + const bracket = `[${t}]`; + overlays.push({ + start: byte, + end: byte + byteLength(bracket), + style: { fg: refTokenColor(t), bold: true }, + }); + text += bracket; + byte += byteLength(bracket); + } + } + + const finalText = text + "\n"; + + const style: Partial = isSelected + ? { bg: GIT_THEME.selectionBg, extendToLineEnd: true, bold: true } + : {}; + + return { + text: finalText, + properties: { + type: propertyType, + index, + hash: commit.hash, + shortHash: commit.shortHash, + author: commit.author, + date: commit.relativeDate, + subject: commit.subject, + refs: commit.refs, + }, + style, + inlineOverlays: overlays, + }; +} + +export function buildCommitLogEntries( + commits: GitCommit[], + opts: BuildCommitLogEntriesOptions = {} +): TextPropertyEntry[] { + const header = opts.header ?? "Commits:"; + const footer = opts.footer; + const selectedIndex = opts.selectedIndex ?? -1; + const propertyType = opts.propertyType ?? "log-commit"; + + const entries: TextPropertyEntry[] = []; + + entries.push({ + text: header + "\n", + properties: { type: "log-header" }, + style: { fg: GIT_THEME.header, bold: true, underline: true }, + }); + + if (commits.length === 0) { + entries.push({ + text: " (no commits)\n", + properties: { type: "log-empty" }, + style: { fg: GIT_THEME.metaLabel, italic: true }, + }); + } else { + const widths = commitLogColumnWidths(commits); + for (let i = 0; i < commits.length; i++) { + entries.push( + buildCommitRowEntry(commits[i], i, i === selectedIndex, widths, propertyType) + ); + } + } + + if (footer) { + entries.push({ + text: "\n", + properties: { type: "log-blank" }, + }); + entries.push({ + text: footer + "\n", + properties: { type: "log-footer" }, + style: { fg: GIT_THEME.footer, italic: true }, + }); + } + + return entries; +} + +// ============================================================================= +// Commit detail (git show) entry building +// ============================================================================= + +interface DetailBuildContext { + currentFile: string | null; + currentNewLine: number; +} + +/** + * Style a single line from `git show --stat --patch` output as a styled + * TextPropertyEntry with inlineOverlays. Tracks file/line context for click + * navigation. + */ +function buildDetailLineEntry( + line: string, + ctx: DetailBuildContext +): TextPropertyEntry { + const props: Record = { type: "detail-line" }; + const overlays: InlineOverlay[] = []; + let lineStyle: Partial = {}; + + // "diff --git a/... b/..." + const diffHeader = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (diffHeader) { + ctx.currentFile = diffHeader[2]; + ctx.currentNewLine = 0; + props.type = "detail-diff-header"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.header, bold: true }; + } else if (line.startsWith("+++ b/")) { + ctx.currentFile = line.slice(6); + props.type = "detail-diff-header"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.header, bold: true }; + } else if (line.startsWith("+++ ") || line.startsWith("--- ") || line.startsWith("index ")) { + props.type = "detail-diff-header"; + lineStyle = { fg: GIT_THEME.subjectMuted }; + } else if (line.startsWith("@@")) { + const hunkMatch = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunkMatch) ctx.currentNewLine = parseInt(hunkMatch[1], 10); + props.type = "detail-hunk-header"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + lineStyle = { fg: GIT_THEME.diffHunk, bold: true, extendToLineEnd: true }; + } else if (line.startsWith("+") && !line.startsWith("+++")) { + props.type = "detail-add"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + ctx.currentNewLine++; + lineStyle = { fg: GIT_THEME.diffAddFg, bg: GIT_THEME.diffAdd, extendToLineEnd: true }; + } else if (line.startsWith("-") && !line.startsWith("---")) { + props.type = "detail-remove"; + props.file = ctx.currentFile; + lineStyle = { fg: GIT_THEME.diffRemoveFg, bg: GIT_THEME.diffRemove, extendToLineEnd: true }; + } else if (line.startsWith(" ") && ctx.currentFile && ctx.currentNewLine > 0) { + props.type = "detail-context"; + props.file = ctx.currentFile; + props.line = ctx.currentNewLine; + ctx.currentNewLine++; + } else if (line.startsWith("commit ")) { + props.type = "detail-commit-line"; + const hashMatch = line.match(/^commit ([a-f0-9]+)/); + if (hashMatch) { + props.hash = hashMatch[1]; + // Colour just "commit" and the hash chunk separately. + const commitWord = "commit "; + overlays.push({ + start: 0, + end: byteLength(commitWord), + style: { fg: GIT_THEME.metaLabel, bold: true }, + }); + overlays.push({ + start: byteLength(commitWord), + end: byteLength(commitWord) + byteLength(hashMatch[1]), + style: { fg: GIT_THEME.hash, bold: true }, + }); + } + } else if (/^(Author|Date|Commit|Merge|AuthorDate|CommitDate):/.test(line)) { + const colonIdx = line.indexOf(":"); + props.type = "detail-meta"; + overlays.push({ + start: 0, + end: byteLength(line.slice(0, colonIdx + 1)), + style: { fg: GIT_THEME.metaLabel, bold: true }, + }); + const fieldKey = line.slice(0, colonIdx).toLowerCase(); + if (fieldKey === "author") { + overlays.push({ + start: byteLength(line.slice(0, colonIdx + 1)), + end: byteLength(line), + style: { fg: GIT_THEME.author }, + }); + } else if (fieldKey.includes("date")) { + overlays.push({ + start: byteLength(line.slice(0, colonIdx + 1)), + end: byteLength(line), + style: { fg: GIT_THEME.date }, + }); + } + } + + return { + text: line + "\n", + properties: props, + style: lineStyle, + inlineOverlays: overlays, + }; +} + +/** + * Build the entries for a commit detail view — a colourful replay of + * `git show --stat --patch`. + */ +export function buildCommitDetailEntries( + commit: GitCommit | null, + showOutput: string, + opts: { footer?: string | null } = {} +): TextPropertyEntry[] { + const entries: TextPropertyEntry[] = []; + + if (commit) { + entries.push({ + text: `${commit.shortHash} ${commit.subject}\n`, + properties: { type: "detail-title", hash: commit.hash }, + style: { fg: GIT_THEME.header, bold: true, underline: true }, + }); + } + + const ctx: DetailBuildContext = { currentFile: null, currentNewLine: 0 }; + const lines = showOutput.split("\n"); + for (const line of lines) { + entries.push(buildDetailLineEntry(line, ctx)); + } + + const footer = opts.footer; + if (footer) { + entries.push({ + text: "\n", + properties: { type: "detail-blank" }, + }); + entries.push({ + text: footer + "\n", + properties: { type: "detail-footer" }, + style: { fg: GIT_THEME.footer, italic: true }, + }); + } + + return entries; +} + +// ============================================================================= +// Placeholder entries shown in the detail panel while no commit has been +// loaded yet (e.g. during initial render or when the log is empty). +// ============================================================================= + +export function buildDetailPlaceholderEntries(message: string): TextPropertyEntry[] { + return [ + { + text: "\n", + properties: { type: "detail-blank" }, + }, + { + text: " " + message + "\n", + properties: { type: "detail-placeholder" }, + style: { fg: GIT_THEME.metaLabel, italic: true }, + }, + ]; +} diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index d87ae0b1d..db1b42145 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -1377,15 +1377,23 @@ fn test_git_log_open_different_commits_sequentially() { let screen_second_detail = harness.screen_to_string(); println!("Second commit detail (should be SECOND):\n{screen_second_detail}"); - // CRITICAL ASSERTION: The bug is that it opens the first commit again instead of the second - // This should show SECOND_UNIQUE_COMMIT_BBB, NOT THIRD_UNIQUE_COMMIT_CCC + // CRITICAL ASSERTION: The bug is that it opens the first commit again instead of the second. + // The modern buffer-group layout keeps the commit log visible on the left and shows the + // selected commit's detail on the right, so we can't assert that THIRD is absent from the + // screen (it's still listed in the left panel). Instead, assert that the detail panel has + // moved to SECOND by looking for `file2.txt` (added in SECOND) — the file added in THIRD + // (`file3.txt`) must not appear in the right-hand detail body. assert!( screen_second_detail.contains("SECOND_UNIQUE_COMMIT_BBB"), "BUG: After navigating to a different commit and pressing Enter, it should open SECOND_UNIQUE_COMMIT_BBB, but got:\n{screen_second_detail}" ); assert!( - !screen_second_detail.contains("THIRD_UNIQUE_COMMIT_CCC"), - "BUG: Should NOT show THIRD commit when SECOND was selected:\n{screen_second_detail}" + screen_second_detail.contains("file2.txt"), + "BUG: The detail panel should reference file2.txt (added in SECOND commit) but got:\n{screen_second_detail}" + ); + assert!( + !screen_second_detail.contains("file3.txt"), + "BUG: The detail panel should NOT reference file3.txt (THIRD commit's file) when SECOND is selected:\n{screen_second_detail}" ); } From 45cb9a57b8e26d0c6eb4fbe1082477ff39d01c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 06:56:26 +0000 Subject: [PATCH 02/42] test(blog-showcase): add git log demo for docs/blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fresh-editor/tests/e2e/blog_showcases.rs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/crates/fresh-editor/tests/e2e/blog_showcases.rs b/crates/fresh-editor/tests/e2e/blog_showcases.rs index fa46e45d6..6a4bcb72e 100644 --- a/crates/fresh-editor/tests/e2e/blog_showcases.rs +++ b/crates/fresh-editor/tests/e2e/blog_showcases.rs @@ -12,6 +12,7 @@ use crate::common::blog_showcase::BlogShowcase; use crate::common::fixtures::TestFixture; +use crate::common::git_test_helper::GitTestRepo; use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness, HarnessOptions}; use crossterm::event::{KeyCode, KeyModifiers}; use lsp_types::FoldingRange; @@ -2261,3 +2262,208 @@ fn blog_showcase_fresh_0_2_18_hot_exit() { s.finalize().unwrap(); } + +// ========================================================================= +// Blog Post 6: Modernized Git Log Panel (buffer group + live preview) +// ========================================================================= + +/// Build a hermetic repo with several commits by distinct authors so the +/// aligned-column log and right-panel detail have something meaningful +/// to display in the showcase. +fn build_git_log_demo_repo() -> GitTestRepo { + let repo = GitTestRepo::new(); + + // Commit 1 — initial scaffold (Alice). + repo.create_file( + "src/main.rs", + "fn main() {\n println!(\"Hello, world!\");\n}\n", + ); + repo.create_file("README.md", "# Fresh demo\n\nA tiny sample project.\n"); + repo.git_add_all(); + repo.git_commit("feat: initial scaffold"); + + // Commit 2 — add add() (Alice). + repo.create_file( + "src/lib.rs", + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat: add add() helper"); + + // Commit 3 — add sub() from a different author (John Doe). + std::process::Command::new("git") + .args(["config", "user.name", "John Doe"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "john@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "src/lib.rs", + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\n\ + pub fn sub(a: i32, b: i32) -> i32 {\n a - b\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat: add sub() helper"); + + // Commit 4 — docs (Alice again). + std::process::Command::new("git") + .args(["config", "user.name", "Alice Liddell"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "alice@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "README.md", + "# Fresh demo\n\nA tiny sample project.\n\n\ + Provides basic arithmetic helpers.\n", + ); + repo.git_add_all(); + repo.git_commit("docs: describe the helpers"); + + // Commit 5 — cli args TODO (Alice). Tag v0.1 on this one so the log + // panel shows a tag ref as well as the HEAD branch ref. + repo.create_file( + "src/main.rs", + "fn main() {\n println!(\"Hello, world!\");\n}\n\ + // TODO: support CLI args\n", + ); + repo.git_add_all(); + repo.git_commit("chore(main): note CLI args TODO"); + std::process::Command::new("git") + .args(["tag", "v0.1.0"]) + .current_dir(&repo.path) + .output() + .unwrap(); + + // Commit 6 — CLI parser (John again). + std::process::Command::new("git") + .args(["config", "user.name", "John Doe"]) + .current_dir(&repo.path) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "john@example.com"]) + .current_dir(&repo.path) + .output() + .unwrap(); + repo.create_file( + "src/args.rs", + "pub fn parse_args(args: &[String]) -> Vec {\n\ + \u{0020} args.iter().skip(1).cloned().collect()\n}\n", + ); + repo.git_add_all(); + repo.git_commit("feat(cli): add args parser"); + + repo +} + +/// Git Log: the modern buffer-group layout with a live-preview right panel. +/// Walk through the commit list with j/k and show the right-hand detail +/// update in step, then focus the detail panel with Tab and close with q. +#[test] +#[ignore] +fn blog_showcase_productivity_git_log() { + let repo = build_git_log_demo_repo(); + repo.setup_git_log_plugin(); + + // 140x34 is comfortable — the log panel's aligned columns + the detail + // panel's diff read well side-by-side at that width. + let mut h = EditorTestHarness::with_config_and_working_dir( + 140, + 34, + Default::default(), + repo.path.clone(), + ) + .unwrap(); + h.render().unwrap(); + + let mut s = BlogShowcase::new( + "productivity/git-log", + "Git Log", + "Magit-style git log with live-preview: a buffer-group tab pairs the aligned commit list with the selected commit's detail, updated as you navigate.", + ); + + // Opening hold on the blank editor — a moment to read the key badge. + hold(&mut h, &mut s, 5, 120); + + // Open the command palette and type "Git Log". + h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("Ctrl+P"), 150); + + for ch in "Git Log".chars() { + h.send_key(KeyCode::Char(ch), KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some(&ch.to_string()), 60); + } + hold(&mut h, &mut s, 2, 120); + + // Confirm — this runs `show_git_log` which creates the buffer group. + h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap(); + // The group buffer + `git show` dispatch is async; wait for the log + // header *and* the detail-panel "Author:" line so both panels are + // fully rendered before we start snapping. + h.wait_until(|h| { + let screen = h.screen_to_string(); + screen.contains("Commits:") && screen.contains("Author:") + }) + .unwrap(); + snap(&mut h, &mut s, Some("Enter"), 300); + hold(&mut h, &mut s, 5, 120); + + // Walk down the commit list — each `j` fires `cursor_moved`, which + // re-renders the right panel with the newly-selected commit's diff. + for _ in 0..3 { + h.send_key(KeyCode::Char('j'), KeyModifiers::NONE).unwrap(); + // Let the async `git show` land before we snapshot so the right + // panel matches the highlighted row. + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("j"), 180); + hold(&mut h, &mut s, 2, 120); + } + // One more for effect. + h.send_key(KeyCode::Char('j'), KeyModifiers::NONE).unwrap(); + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("j"), 220); + hold(&mut h, &mut s, 3, 150); + + // Climb back up to highlight live-update going both directions. + for _ in 0..2 { + h.send_key(KeyCode::Char('k'), KeyModifiers::NONE).unwrap(); + h.process_async_and_render().unwrap(); + snap(&mut h, &mut s, Some("k"), 180); + } + hold(&mut h, &mut s, 3, 120); + + // Tab jumps focus into the detail panel so the user can scroll the + // diff without losing the log's cursor. + h.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("Tab"), 200); + hold(&mut h, &mut s, 4, 150); + + // q in the detail panel hops back to the log panel (it doesn't close + // the group until q is pressed from the log panel). + h.send_key(KeyCode::Char('q'), KeyModifiers::NONE).unwrap(); + h.render().unwrap(); + snap(&mut h, &mut s, Some("q"), 180); + hold(&mut h, &mut s, 3, 120); + + // Final close — q in the log panel tears the group down. + h.send_key(KeyCode::Char('q'), KeyModifiers::NONE).unwrap(); + h.wait_until(|h| !h.screen_to_string().contains("Commits:")) + .unwrap(); + snap(&mut h, &mut s, Some("q"), 220); + hold(&mut h, &mut s, 4, 150); + + s.finalize().unwrap(); +} From d2406205ef641aa3f9c89e6f03767ac867129485 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 20:37:15 +0300 Subject: [PATCH 03/42] perf(git_log): debounce cursor_moved + drop huge files from diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/plugins/git_log.ts | 34 +++++++++-- .../fresh-editor/plugins/lib/git_history.ts | 56 +++++++++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 0a2b26091..df8a6039b 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -45,6 +45,13 @@ interface GitLogState { * user scrolls through the log faster than `git show` can return. */ pendingDetailId: number; + /** + * Debounce token for `cursor_moved`. Rapid cursor motion (PageDown, held + * j/k) would otherwise trigger a full log re-render + `git show` per + * intermediate row; we bump this id on every event and only do the work + * after a short delay if no newer event has arrived. + */ + pendingCursorMoveId: number; /** * Byte offset at the start of each row in the rendered log panel, plus * the total buffer length at the end. Populated by `renderLog` so the @@ -64,9 +71,17 @@ const state: GitLogState = { selectedIndex: 0, detailCache: null, pendingDetailId: 0, + pendingCursorMoveId: 0, logRowByteOffsets: [], }; +/** + * Delay before reacting to `cursor_moved`. Long enough to collapse a burst + * of events from held j/k or PageDown into a single render, short enough + * that the detail panel still feels live. + */ +const CURSOR_DEBOUNCE_MS = 60; + // UTF-8 byte length — the overlay API expects byte offsets; JS strings are // UTF-16. Matches the helper used by `lib/git_history.ts`. function utf8Len(s: string): number { @@ -513,12 +528,12 @@ registerHandler("git_log_file_view_close", git_log_file_view_close); // the commit list. // ============================================================================= -function on_git_log_cursor_moved(data: { +async function on_git_log_cursor_moved(data: { buffer_id: number; cursor_id: number; old_position: number; new_position: number; -}): void { +}): Promise { if (!state.isOpen) return; // Only react to movement inside the log panel. if (data.buffer_id !== state.logBufferId) return; @@ -529,16 +544,23 @@ function on_git_log_cursor_moved(data: { const idx = indexFromCursorByte(data.new_position); if (idx === state.selectedIndex) return; state.selectedIndex = idx; + + // Debounce: bump the token, wait a beat, bail if a newer event has + // arrived. The log re-render and `git show` are both expensive; a burst + // of cursor events (held j/k, PageDown) must collapse to one render. + const myId = ++state.pendingCursorMoveId; + await editor.delay(CURSOR_DEBOUNCE_MS); + if (myId !== state.pendingCursorMoveId) return; + if (!state.isOpen) return; + renderLog(); - // Kick off the detail refresh — it's async and tagged so a rapid - // stream of movements collapses to a single render for the final row. refreshDetail(); - const commit = state.commits[idx]; + const commit = state.commits[state.selectedIndex]; if (commit) { editor.setStatus( editor.t("status.commit_position", { - current: String(idx + 1), + current: String(state.selectedIndex + 1), total: String(state.commits.length), }) ); diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index 7091959f9..fa8553c0b 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -131,18 +131,66 @@ export async function fetchGitLog( return commits; } +/** + * A single file's diff exceeding this line count is omitted from the + * rendered `git show` output. Generated files (lockfiles, bundled SVGs, + * minified JS) can produce megabyte-scale diffs that balloon the detail + * panel into hundreds of thousands of entries — slow to render and not + * useful to read. The stat header still lists the file so the user knows + * it changed; a footer tells them which ones were skipped. + */ +const MAX_DIFF_LINES_PER_FILE = 2000; + export async function fetchCommitShow( editor: EditorAPI, hash: string, cwd?: string ): Promise { - const result = await editor.spawnProcess( + const workdir = cwd ?? editor.getCwd(); + + // First pass: numstat only. Small output (one line per changed file), + // lets us spot oversized files before asking git for the full patch. + const numstatResult = await editor.spawnProcess( "git", - ["show", "--stat", "--patch", hash], - cwd ?? editor.getCwd() + ["show", "--numstat", "--format=", hash], + workdir ); + const oversized: string[] = []; + if (numstatResult.exit_code === 0) { + for (const line of numstatResult.stdout.split("\n")) { + if (!line) continue; + // numstat format: "\t\t"; "-" for binary files. + const tab1 = line.indexOf("\t"); + const tab2 = tab1 >= 0 ? line.indexOf("\t", tab1 + 1) : -1; + if (tab1 < 0 || tab2 < 0) continue; + const addedStr = line.slice(0, tab1); + const removedStr = line.slice(tab1 + 1, tab2); + const path = line.slice(tab2 + 1); + const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) || 0; + const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) || 0; + if (added + removed > MAX_DIFF_LINES_PER_FILE) { + oversized.push(path); + } + } + } + + // Second pass: stat + patch, excluding oversized paths. `:(exclude,top)` + // is rooted at the repo root so it matches regardless of git's cwd. + const showArgs = ["show", "--stat", "--patch", hash]; + if (oversized.length > 0) { + showArgs.push("--", "."); + for (const p of oversized) showArgs.push(`:(exclude,top)${p}`); + } + const result = await editor.spawnProcess("git", showArgs, workdir); if (result.exit_code !== 0) return result.stderr || "(no output)"; - return result.stdout; + + if (oversized.length === 0) return result.stdout; + + const plural = oversized.length === 1 ? "" : "s"; + let footer = `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; + for (const p of oversized) footer += ` ${p}\n`; + footer += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; + return result.stdout + footer; } // ============================================================================= From d03df5a1873da6d001f78c0788b434ba491108d6 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 20:41:09 +0300 Subject: [PATCH 04/42] perf(overlay): bulk-add overlays in set_virtual_buffer_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- crates/fresh-editor/src/app/buffer_management.rs | 9 +++++++-- crates/fresh-editor/src/view/overlay.rs | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 4eabee538..2156e400b 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -1637,12 +1637,16 @@ impl Editor { // Set text properties state.text_properties = properties; - // Create inline overlays for the new content + // Create inline overlays for the new content. Build the full vec + // first and bulk-add it so the OverlayManager sorts exactly once; + // a per-overlay `add` re-sorts every time and is O(n² log n) for + // N entries (a big git-show diff can be ~500k overlays). { use crate::view::overlay::{Overlay, OverlayFace}; use fresh_core::overlay::OverlayNamespace; let inline_ns = OverlayNamespace::from_string("_inline".to_string()); + let mut new_overlays = Vec::with_capacity(collected_overlays.len()); for co in collected_overlays { let face = OverlayFace::from_options(&co.options); @@ -1656,8 +1660,9 @@ impl Editor { if let Some(url) = co.options.url { overlay.url = Some(url); } - state.overlays.add(overlay); + new_overlays.push(overlay); } + state.overlays.extend(new_overlays); } // Preserve cursor position (clamped to new content length and snapped to char boundary) diff --git a/crates/fresh-editor/src/view/overlay.rs b/crates/fresh-editor/src/view/overlay.rs index a7a7ea88e..2ee7cb84e 100644 --- a/crates/fresh-editor/src/view/overlay.rs +++ b/crates/fresh-editor/src/view/overlay.rs @@ -288,6 +288,17 @@ impl OverlayManager { handle } + /// Append many overlays at once, sorting a single time at the end. + /// + /// `add` re-sorts the whole vector on every insertion, which is O(n² log n) + /// when a caller has N overlays to add. Use this instead when rebuilding an + /// overlay set from scratch (e.g. `set_virtual_buffer_content`), where the + /// caller already owns the full list up front. + pub fn extend>(&mut self, overlays: I) { + self.overlays.extend(overlays); + self.overlays.sort_by_key(|o| o.priority); + } + /// Remove an overlay by its handle pub fn remove_by_handle( &mut self, From b33b419003202ca645494249f27d784321a2a96a Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 20:51:06 +0300 Subject: [PATCH 05/42] fix(buffer_groups): preserve cursor across set_virtual_buffer_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fresh-editor/src/app/buffer_management.rs | 6 +- crates/fresh-editor/tests/e2e/plugins/git.rs | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 2156e400b..620634f43 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -1601,12 +1601,12 @@ impl Editor { buffer_id: BufferId, entries: Vec, ) -> Result<(), String> { - // Save current cursor position from split view state to preserve it after content update + // Preserve cursor position across the rebuild. Group-panel buffers + // aren't in any split's `open_buffers`, so look in `keyed_states`. let old_cursor_pos = self .split_view_states .values() - .find(|vs| vs.has_buffer(buffer_id)) - .and_then(|vs| vs.keyed_states.get(&buffer_id)) + .find_map(|vs| vs.keyed_states.get(&buffer_id)) .map(|bs| bs.cursors.primary().position) .unwrap_or(0); diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index db1b42145..7c2dd369b 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -1397,6 +1397,75 @@ fn test_git_log_open_different_commits_sequentially() { ); } +/// Pressing Down repeatedly in the log panel should progressively deepen the +/// selection: each press advances to the next-older commit, and the right-hand +/// detail panel updates to show that commit's diff. Regression test for a bug +/// where the log cursor jumped back to the top of the buffer once the detail +/// panel re-rendered, causing subsequent Down presses to stick on commit #2. +#[test] +fn test_git_log_down_arrow_progresses_through_commits() { + init_tracing_from_env(); + let repo = GitTestRepo::new(); + + // Four commits, each introduces a distinctively-named file so we can + // identify which commit's diff the detail panel is currently rendering. + repo.create_file("f1_alpha.txt", "one"); + repo.git_add(&["f1_alpha.txt"]); + repo.git_commit("Alpha commit"); + + repo.create_file("f2_beta.txt", "two"); + repo.git_add(&["f2_beta.txt"]); + repo.git_commit("Beta commit"); + + repo.create_file("f3_gamma.txt", "three"); + repo.git_add(&["f3_gamma.txt"]); + repo.git_commit("Gamma commit"); + + repo.create_file("f4_delta.txt", "four"); + repo.git_add(&["f4_delta.txt"]); + repo.git_commit("Delta commit"); + + repo.setup_git_log_plugin(); + + let mut harness = EditorTestHarness::with_config_and_working_dir( + 180, + 48, + Config::default(), + repo.path.clone(), + ) + .unwrap(); + + trigger_git_log(&mut harness); + + // After open, HEAD (Delta) should be auto-selected; detail panel shows its diff. + harness + .wait_until(|h| { + let s = h.screen_to_string(); + s.contains("Commits:") && s.contains("f4_delta.txt") + }) + .unwrap(); + + // Down once — detail should switch to Gamma's diff (f3_gamma.txt). + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("f3_gamma.txt")) + .unwrap(); + + // Down again — if the log cursor jumps back to row 0 after the Gamma + // detail render, the next Down would only re-select Gamma and we'd + // never reach Beta. Assert that Beta's file shows up in the detail. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("f2_beta.txt")) + .unwrap(); + + // Down once more for good measure — should reach Alpha. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("f1_alpha.txt")) + .unwrap(); +} + // ============================================================================= // Git Blame Tests // ============================================================================= From 92b1e513275c0e50c47e90c64a63c00f0b896582 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 20:51:53 +0300 Subject: [PATCH 06/42] feat(git_log): enable line wrap in the commit detail panel 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. --- crates/fresh-editor/plugins/git_log.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index df8a6039b..f4e7e86d5 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -314,6 +314,9 @@ async function show_git_log(): Promise { } if (state.detailBufferId !== null) { editor.setBufferShowCursors(state.detailBufferId, true); + // Wrap long lines in the detail panel — git diffs often exceed the + // 40% split width, and horizontal scrolling a commit is awkward. + editor.setLineWrap(state.detailBufferId, null, true); // Per-panel mode: the group was created with "git-log" which applies // to the initially-focused panel (log). The detail panel's mode is // set when we focus into it. From dbb42516e5e0de7e8e60ddc30c2452d1c27897c5 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 20:56:06 +0300 Subject: [PATCH 07/42] feat(git_log): reflow commit message paragraphs in the detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fresh-editor/plugins/lib/git_history.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index fa8553c0b..11e97dd0e 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -533,6 +533,58 @@ function buildDetailLineEntry( }; } +/** + * Rejoin the hard-wrapped commit message paragraphs so soft-wrap has a + * single logical line per paragraph to break at panel width. Without this + * step the message keeps its original 72-col hard breaks and soft-wrap + * produces an ugly staircase of half-width lines in a narrow panel. + * + * Only touches the message region between the Author/Date header and the + * first `diff --git` line; diff lines pass through unchanged. + */ +function reflowCommitMessage(lines: string[]): string[] { + const out: string[] = []; + let pastHeader = false; + let inDiff = false; + let paragraph = ""; + + const flush = () => { + if (paragraph !== "") { + out.push(" " + paragraph); + paragraph = ""; + } + }; + + for (const line of lines) { + if (inDiff) { + out.push(line); + continue; + } + if (line.startsWith("diff --git")) { + flush(); + inDiff = true; + out.push(line); + continue; + } + if (!pastHeader) { + out.push(line); + // First blank line closes the commit/Author/Date/Merge header. + if (line === "") pastHeader = true; + continue; + } + if (line === "") { + flush(); + out.push(""); + continue; + } + // Git show indents the message by 4 spaces; strip it before joining. + const content = line.startsWith(" ") ? line.slice(4) : line; + paragraph = paragraph === "" ? content : paragraph + " " + content; + } + flush(); + return out; +} + /** * Build the entries for a commit detail view — a colourful replay of * `git show --stat --patch`. @@ -553,7 +605,7 @@ export function buildCommitDetailEntries( } const ctx: DetailBuildContext = { currentFile: null, currentNewLine: 0 }; - const lines = showOutput.split("\n"); + const lines = reflowCommitMessage(showOutput.split("\n")); for (const line of lines) { entries.push(buildDetailLineEntry(line, ctx)); } From 6aa81107fd990270277ce2a47a89b8145113630d Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:03:35 +0300 Subject: [PATCH 08/42] refactor(git_log): fetch commit metadata, stat, and patch separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fresh-editor/plugins/lib/git_history.ts | 156 ++++++++++-------- 1 file changed, 88 insertions(+), 68 deletions(-) diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index 11e97dd0e..277a0ba05 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -141,6 +141,32 @@ export async function fetchGitLog( */ const MAX_DIFF_LINES_PER_FILE = 2000; +/** + * Rejoin hard-wrapped lines within a paragraph into one logical line. Blank + * lines remain blank and delimit paragraphs. Used only for commit messages + * (author-chosen 72-col hard wraps need to be erased so soft-wrap in a + * narrow panel can re-break at the panel edge). + */ +function reflowParagraphs(text: string): string { + const out: string[] = []; + let paragraph = ""; + for (const line of text.split("\n")) { + if (line === "") { + if (paragraph !== "") { + out.push(paragraph); + paragraph = ""; + } + out.push(""); + continue; + } + paragraph = paragraph === "" ? line : paragraph + " " + line; + } + if (paragraph !== "") out.push(paragraph); + // Drop a single trailing blank that `%B` always adds. + while (out.length > 0 && out[out.length - 1] === "") out.pop(); + return out.join("\n"); +} + export async function fetchCommitShow( editor: EditorAPI, hash: string, @@ -148,8 +174,7 @@ export async function fetchCommitShow( ): Promise { const workdir = cwd ?? editor.getCwd(); - // First pass: numstat only. Small output (one line per changed file), - // lets us spot oversized files before asking git for the full patch. + // 1. numstat — small, lets us spot oversized files before the full diff. const numstatResult = await editor.spawnProcess( "git", ["show", "--numstat", "--format=", hash], @@ -174,23 +199,70 @@ export async function fetchCommitShow( } } - // Second pass: stat + patch, excluding oversized paths. `:(exclude,top)` - // is rooted at the repo root so it matches regardless of git's cwd. - const showArgs = ["show", "--stat", "--patch", hash]; + // 2. Metadata — pull the fields we want with a structured format so we + // can reflow just the message body without having to recognise the + // header in free-form `git show` output. + const META_SEP = "\x00"; + const META_FORMAT = [ + "%H", // full hash + "%P", // parent hashes (space-separated) + "%an", // author name + "%ae", // author email + "%aD", // author date, RFC 2822 — matches `git show` default + "%B", // raw subject + body + ].join(META_SEP); + const metaResult = await editor.spawnProcess( + "git", + ["log", "-n1", `--format=${META_FORMAT}`, hash], + workdir + ); + if (metaResult.exit_code !== 0) { + return metaResult.stderr || "(no output)"; + } + const metaFields = metaResult.stdout.split(META_SEP); + const fullHash = (metaFields[0] ?? hash).trim(); + const parents = (metaFields[1] ?? "").trim().split(" ").filter((p) => p.length > 0); + const author = metaFields[2] ?? ""; + const email = metaFields[3] ?? ""; + const date = metaFields[4] ?? ""; + const rawMessage = metaFields[5] ?? ""; + + // 3. Stat + patch, no metadata, with oversized paths excluded. + const showArgs = ["show", "--format=", "--stat", "--patch", hash]; if (oversized.length > 0) { showArgs.push("--", "."); for (const p of oversized) showArgs.push(`:(exclude,top)${p}`); } - const result = await editor.spawnProcess("git", showArgs, workdir); - if (result.exit_code !== 0) return result.stderr || "(no output)"; + const showResult = await editor.spawnProcess("git", showArgs, workdir); + if (showResult.exit_code !== 0) return showResult.stderr || "(no output)"; + + // Assemble. The shape matches stock `git show` output so + // `buildDetailLineEntry` keeps recognising the header. + let out = `commit ${fullHash}\n`; + if (parents.length > 1) { + out += `Merge: ${parents.map((p) => p.slice(0, 7)).join(" ")}\n`; + } + out += `Author: ${author} <${email}>\n`; + out += `Date: ${date}\n\n`; - if (oversized.length === 0) return result.stdout; + // Reflow only the message. Indent with 4 spaces to match git's own layout. + const reflowed = reflowParagraphs(rawMessage); + for (const line of reflowed.split("\n")) { + out += line === "" ? "\n" : ` ${line}\n`; + } + out += "\n"; - const plural = oversized.length === 1 ? "" : "s"; - let footer = `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; - for (const p of oversized) footer += ` ${p}\n`; - footer += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; - return result.stdout + footer; + // Stat + patch, untouched. + out += showResult.stdout; + + if (oversized.length > 0) { + const plural = oversized.length === 1 ? "" : "s"; + out += `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; + for (const p of oversized) out += ` ${p}\n`; + out += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; + } + + return out; } // ============================================================================= @@ -533,61 +605,10 @@ function buildDetailLineEntry( }; } -/** - * Rejoin the hard-wrapped commit message paragraphs so soft-wrap has a - * single logical line per paragraph to break at panel width. Without this - * step the message keeps its original 72-col hard breaks and soft-wrap - * produces an ugly staircase of half-width lines in a narrow panel. - * - * Only touches the message region between the Author/Date header and the - * first `diff --git` line; diff lines pass through unchanged. - */ -function reflowCommitMessage(lines: string[]): string[] { - const out: string[] = []; - let pastHeader = false; - let inDiff = false; - let paragraph = ""; - - const flush = () => { - if (paragraph !== "") { - out.push(" " + paragraph); - paragraph = ""; - } - }; - - for (const line of lines) { - if (inDiff) { - out.push(line); - continue; - } - if (line.startsWith("diff --git")) { - flush(); - inDiff = true; - out.push(line); - continue; - } - if (!pastHeader) { - out.push(line); - // First blank line closes the commit/Author/Date/Merge header. - if (line === "") pastHeader = true; - continue; - } - if (line === "") { - flush(); - out.push(""); - continue; - } - // Git show indents the message by 4 spaces; strip it before joining. - const content = line.startsWith(" ") ? line.slice(4) : line; - paragraph = paragraph === "" ? content : paragraph + " " + content; - } - flush(); - return out; -} - /** * Build the entries for a commit detail view — a colourful replay of - * `git show --stat --patch`. + * `git show --stat --patch`. The commit message body is already reflowed + * by `fetchCommitShow`; stat lines and diff lines pass through unchanged. */ export function buildCommitDetailEntries( commit: GitCommit | null, @@ -605,8 +626,7 @@ export function buildCommitDetailEntries( } const ctx: DetailBuildContext = { currentFile: null, currentNewLine: 0 }; - const lines = reflowCommitMessage(showOutput.split("\n")); - for (const line of lines) { + for (const line of showOutput.split("\n")) { entries.push(buildDetailLineEntry(line, ctx)); } From 6ff20dd7263cc782352c3189fe274ac4e897a245 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:03:43 +0300 Subject: [PATCH 09/42] feat(git_log): move shortcut hints to a sticky top toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%) ────┤ └──────────────────────┴──────────────────────┘ --- crates/fresh-editor/plugins/git_log.ts | 105 ++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index f4e7e86d5..0b57e65ad 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -154,35 +154,114 @@ editor.defineMode( // ============================================================================= /** - * Group buffer layout — a vertical split: commit log on the left (60%), - * detail on the right (40%). Uses the runtime's JSON layout schema. + * Group buffer layout — a one-row sticky toolbar on top, then a horizontal + * split below with the commit log on the left (60%) and detail on the + * right (40%). The toolbar mirrors the review-diff style: a fixed-height + * panel above the scrollable content that holds all the keybinding hints + * so they don't shift or scroll with the data. */ const GROUP_LAYOUT = JSON.stringify({ type: "split", - direction: "h", // horizontal split = side by side - ratio: 0.6, - first: { type: "scrollable", id: "log" }, - second: { type: "scrollable", id: "detail" }, + direction: "v", + ratio: 0.05, // ignored when one side is `fixed` + first: { type: "fixed", id: "toolbar", height: 1 }, + second: { + type: "split", + direction: "h", + ratio: 0.6, + first: { type: "scrollable", id: "log" }, + second: { type: "scrollable", id: "detail" }, + }, }); // ============================================================================= -// Rendering +// Toolbar // ============================================================================= -function logFooter(count: number): string { - return editor.t("panel.log_footer", { count: String(count) }); +interface ToolbarHint { + key: string; + label: string; +} + +const TOOLBAR_HINTS: ToolbarHint[] = [ + { key: "j/k", label: "navigate" }, + { key: "PgUp/PgDn", label: "page" }, + { key: "Tab", label: "switch pane" }, + { key: "RET", label: "open file" }, + { key: "y", label: "yank hash" }, + { key: "r", label: "refresh" }, + { key: "q", label: "quit" }, +]; + +/** + * Build a single-row sticky toolbar. Keys render bold; separators between + * hints are dim. No width-aware truncation — the host crops to panel width, + * and the hints are already short enough to fit a typical terminal. + */ +function buildToolbarEntries(): TextPropertyEntry[] { + let text = " "; + const overlays: InlineOverlay[] = []; + + for (let i = 0; i < TOOLBAR_HINTS.length; i++) { + if (i > 0) { + const sep = " │ "; + const sepStart = utf8Len(text); + text += sep; + overlays.push({ + start: sepStart, + end: utf8Len(text), + style: { fg: "ui.split_separator_fg" }, + }); + } + const { key, label } = TOOLBAR_HINTS[i]; + const keyDisplay = `[${key}]`; + const keyStart = utf8Len(text); + text += keyDisplay; + overlays.push({ + start: keyStart, + end: utf8Len(text), + style: { fg: "editor.fg", bold: true }, + }); + const labelText = " " + label; + const labelStart = utf8Len(text); + text += labelText; + overlays.push({ + start: labelStart, + end: utf8Len(text), + style: { fg: "editor.line_number_fg" }, + }); + } + + return [ + { + text: text + "\n", + properties: { type: "git-log-toolbar" }, + style: { bg: "editor.bg", extendToLineEnd: true }, + inlineOverlays: overlays, + }, + ]; } +function renderToolbar(): void { + if (state.groupId === null) return; + editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries()); +} + +// ============================================================================= +// Rendering +// ============================================================================= + function detailFooter(hash: string): string { return editor.t("status.commit_ready", { hash }); } function renderLog(): void { if (state.groupId === null) return; + // No footer: shortcuts now live in the sticky toolbar panel above the + // group, and commit count appears in the status line when the group opens. const entries = buildCommitLogEntries(state.commits, { selectedIndex: state.selectedIndex, header: editor.t("panel.commits_header"), - footer: logFooter(state.commits.length), }); // Rebuild the byte-offset table used by cursor_moved to map positions // to commit indices. `offsets[i]` is the byte offset of row i; the @@ -215,9 +294,8 @@ function renderDetailPlaceholder(message: string): void { function renderDetailForCommit(commit: GitCommit, showOutput: string): void { if (state.groupId === null) return; - const entries = buildCommitDetailEntries(commit, showOutput, { - footer: editor.t("panel.detail_footer"), - }); + // No footer: the sticky toolbar panel carries all the shortcut hints now. + const entries = buildCommitDetailEntries(commit, showOutput); editor.setPanelContent(state.groupId, "detail", entries); } @@ -322,6 +400,7 @@ async function show_git_log(): Promise { // set when we focus into it. } + renderToolbar(); renderLog(); // Position the cursor on the first commit row (row index 1 — row 0 is // the "Commits:" header). From 0395bf1df8b4c8d57c8cfe337c17893fb875af2b Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:06:20 +0300 Subject: [PATCH 10/42] fix(git_log): use %x00 placeholder, not literal NUL, in format string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/plugins/lib/git_history.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index 277a0ba05..83f8e8784 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -201,16 +201,12 @@ export async function fetchCommitShow( // 2. Metadata — pull the fields we want with a structured format so we // can reflow just the message body without having to recognise the - // header in free-form `git show` output. - const META_SEP = "\x00"; - const META_FORMAT = [ - "%H", // full hash - "%P", // parent hashes (space-separated) - "%an", // author name - "%ae", // author email - "%aD", // author date, RFC 2822 — matches `git show` default - "%B", // raw subject + body - ].join(META_SEP); + // header in free-form `git show` output. Use `%x00` in the format + // string (git expands it into a literal NUL in its output); passing a + // raw NUL byte as a process argument fails because it can't cross the + // CString boundary of spawnProcess. + const META_FORMAT = + "%H%x00%P%x00%an%x00%ae%x00%aD%x00%B"; const metaResult = await editor.spawnProcess( "git", ["log", "-n1", `--format=${META_FORMAT}`, hash], @@ -219,7 +215,7 @@ export async function fetchCommitShow( if (metaResult.exit_code !== 0) { return metaResult.stderr || "(no output)"; } - const metaFields = metaResult.stdout.split(META_SEP); + const metaFields = metaResult.stdout.split("\x00"); const fullHash = (metaFields[0] ?? hash).trim(); const parents = (metaFields[1] ?? "").trim().split(" ").filter((p) => p.length > 0); const author = metaFields[2] ?? ""; From 4fff1c6f820388b7bcd2674fbd1c1a2167a64765 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:08:31 +0300 Subject: [PATCH 11/42] revert(git_log): drop commit-message reflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fresh-editor/plugins/lib/git_history.ts | 94 +++---------------- 1 file changed, 13 insertions(+), 81 deletions(-) diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index 83f8e8784..4a34804d4 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -141,32 +141,6 @@ export async function fetchGitLog( */ const MAX_DIFF_LINES_PER_FILE = 2000; -/** - * Rejoin hard-wrapped lines within a paragraph into one logical line. Blank - * lines remain blank and delimit paragraphs. Used only for commit messages - * (author-chosen 72-col hard wraps need to be erased so soft-wrap in a - * narrow panel can re-break at the panel edge). - */ -function reflowParagraphs(text: string): string { - const out: string[] = []; - let paragraph = ""; - for (const line of text.split("\n")) { - if (line === "") { - if (paragraph !== "") { - out.push(paragraph); - paragraph = ""; - } - out.push(""); - continue; - } - paragraph = paragraph === "" ? line : paragraph + " " + line; - } - if (paragraph !== "") out.push(paragraph); - // Drop a single trailing blank that `%B` always adds. - while (out.length > 0 && out[out.length - 1] === "") out.pop(); - return out.join("\n"); -} - export async function fetchCommitShow( editor: EditorAPI, hash: string, @@ -174,7 +148,8 @@ export async function fetchCommitShow( ): Promise { const workdir = cwd ?? editor.getCwd(); - // 1. numstat — small, lets us spot oversized files before the full diff. + // numstat first — small output, lets us spot oversized files before + // pulling the full diff. const numstatResult = await editor.spawnProcess( "git", ["show", "--numstat", "--format=", hash], @@ -199,66 +174,23 @@ export async function fetchCommitShow( } } - // 2. Metadata — pull the fields we want with a structured format so we - // can reflow just the message body without having to recognise the - // header in free-form `git show` output. Use `%x00` in the format - // string (git expands it into a literal NUL in its output); passing a - // raw NUL byte as a process argument fails because it can't cross the - // CString boundary of spawnProcess. - const META_FORMAT = - "%H%x00%P%x00%an%x00%ae%x00%aD%x00%B"; - const metaResult = await editor.spawnProcess( - "git", - ["log", "-n1", `--format=${META_FORMAT}`, hash], - workdir - ); - if (metaResult.exit_code !== 0) { - return metaResult.stderr || "(no output)"; - } - const metaFields = metaResult.stdout.split("\x00"); - const fullHash = (metaFields[0] ?? hash).trim(); - const parents = (metaFields[1] ?? "").trim().split(" ").filter((p) => p.length > 0); - const author = metaFields[2] ?? ""; - const email = metaFields[3] ?? ""; - const date = metaFields[4] ?? ""; - const rawMessage = metaFields[5] ?? ""; - - // 3. Stat + patch, no metadata, with oversized paths excluded. - const showArgs = ["show", "--format=", "--stat", "--patch", hash]; + // Stat + patch, excluding oversized paths. `:(exclude,top)` is rooted + // at the repo root so it matches regardless of git's cwd. + const showArgs = ["show", "--stat", "--patch", hash]; if (oversized.length > 0) { showArgs.push("--", "."); for (const p of oversized) showArgs.push(`:(exclude,top)${p}`); } - const showResult = await editor.spawnProcess("git", showArgs, workdir); - if (showResult.exit_code !== 0) return showResult.stderr || "(no output)"; - - // Assemble. The shape matches stock `git show` output so - // `buildDetailLineEntry` keeps recognising the header. - let out = `commit ${fullHash}\n`; - if (parents.length > 1) { - out += `Merge: ${parents.map((p) => p.slice(0, 7)).join(" ")}\n`; - } - out += `Author: ${author} <${email}>\n`; - out += `Date: ${date}\n\n`; - - // Reflow only the message. Indent with 4 spaces to match git's own layout. - const reflowed = reflowParagraphs(rawMessage); - for (const line of reflowed.split("\n")) { - out += line === "" ? "\n" : ` ${line}\n`; - } - out += "\n"; - - // Stat + patch, untouched. - out += showResult.stdout; + const result = await editor.spawnProcess("git", showArgs, workdir); + if (result.exit_code !== 0) return result.stderr || "(no output)"; - if (oversized.length > 0) { - const plural = oversized.length === 1 ? "" : "s"; - out += `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; - for (const p of oversized) out += ` ${p}\n`; - out += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; - } + if (oversized.length === 0) return result.stdout; - return out; + const plural = oversized.length === 1 ? "" : "s"; + let footer = `\n[${oversized.length} large file${plural} omitted from diff (>${MAX_DIFF_LINES_PER_FILE} lines changed):\n`; + for (const p of oversized) footer += ` ${p}\n`; + footer += `Run \`git show ${hash.slice(0, 12)} -- \` to view.]\n`; + return result.stdout + footer; } // ============================================================================= From 36ea465a606267ec80def14141cd5c1acce6b2ec Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:20:02 +0300 Subject: [PATCH 12/42] fix(buffer_groups): clamp each split's cursor in place on content swap 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. --- .../fresh-editor/src/app/buffer_management.rs | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 620634f43..4cbeae4b5 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -1601,15 +1601,6 @@ impl Editor { buffer_id: BufferId, entries: Vec, ) -> Result<(), String> { - // Preserve cursor position across the rebuild. Group-panel buffers - // aren't in any split's `open_buffers`, so look in `keyed_states`. - let old_cursor_pos = self - .split_view_states - .values() - .find_map(|vs| vs.keyed_states.get(&buffer_id)) - .map(|bs| bs.cursors.primary().position) - .unwrap_or(0); - let state = self .buffers .get_mut(&buffer_id) @@ -1665,18 +1656,30 @@ impl Editor { state.overlays.extend(new_overlays); } - // Preserve cursor position (clamped to new content length and snapped to char boundary) + // Each split keeps its own cursor; just clamp anything that fell + // past the new buffer end and snap to a char boundary. Don't read + // one split's cursor and write it into the others. let new_len = state.buffer.len(); - let clamped_pos = old_cursor_pos.min(new_len); - // Ensure cursor is at a valid UTF-8 character boundary (without moving if already valid) - let new_cursor_pos = state.buffer.snap_to_char_boundary(clamped_pos); - - // Update cursor in the split view state that has this buffer + // `state` is no longer used past this point — re-borrow `self.buffers` + // immutably for the snap and `self.split_view_states` mutably for the + // write. These are disjoint fields of `self`. + let buffer = &self + .buffers + .get(&buffer_id) + .expect("buffer still present") + .buffer; for view_state in self.split_view_states.values_mut() { - if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) { - buf_state.cursors.primary_mut().position = new_cursor_pos; - buf_state.cursors.primary_mut().anchor = None; - } + let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) else { + continue; + }; + buf_state.cursors.map(|cursor| { + let pos = cursor.position.min(new_len); + cursor.position = buffer.snap_to_char_boundary(pos); + if let Some(anchor) = cursor.anchor { + let clamped = anchor.min(new_len); + cursor.anchor = Some(buffer.snap_to_char_boundary(clamped)); + } + }); } Ok(()) From 95ff964b5a047065ffafada2af6084c956aeb971 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:25:56 +0300 Subject: [PATCH 13/42] feat(git_log): drop the "Commits:" header row in the log panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/plugins/git_log.ts | 25 ++++++++----------- .../fresh-editor/plugins/lib/git_history.ts | 18 +++++++------ 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 0b57e65ad..11816da8a 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -257,14 +257,15 @@ function detailFooter(hash: string): string { function renderLog(): void { if (state.groupId === null) return; - // No footer: shortcuts now live in the sticky toolbar panel above the - // group, and commit count appears in the status line when the group opens. + // No header row and no footer: the sticky toolbar above the group + // carries the shortcut hints, and the commit count goes to the status + // line when the group opens. const entries = buildCommitLogEntries(state.commits, { selectedIndex: state.selectedIndex, - header: editor.t("panel.commits_header"), + header: null, }); // Rebuild the byte-offset table used by cursor_moved to map positions - // to commit indices. `offsets[i]` is the byte offset of row i; the + // to commit indices. `offsets[i]` is the byte offset of commit i; the // final entry is the total buffer length, so row lookups clamp // correctly on the last row. const offsets: number[] = []; @@ -278,11 +279,6 @@ function renderLog(): void { editor.setPanelContent(state.groupId, "log", entries); } -/** Byte offset of the first commit row (i.e. row 1 — row 0 is the header). */ -function byteOffsetOfFirstCommit(): number { - return state.logRowByteOffsets.length > 1 ? state.logRowByteOffsets[1] : 0; -} - function renderDetailPlaceholder(message: string): void { if (state.groupId === null) return; editor.setPanelContent( @@ -344,9 +340,8 @@ function selectedCommit(): GitCommit | null { } function indexFromCursorByte(bytePos: number): number { - // Row 0 is the header; commits live at rows 1..N. - const row0 = rowFromByte(bytePos); - const idx = row0 - 1; + // No header row — row 0 is commit 0. + const idx = rowFromByte(bytePos); if (idx < 0) return 0; if (idx >= state.commits.length) return state.commits.length - 1; return idx; @@ -402,10 +397,10 @@ async function show_git_log(): Promise { renderToolbar(); renderLog(); - // Position the cursor on the first commit row (row index 1 — row 0 is - // the "Commits:" header). + // Position the cursor on the first commit (row 0 now that the header + // row is gone). if (state.logBufferId !== null && state.commits.length > 0) { - editor.setBufferCursor(state.logBufferId, byteOffsetOfFirstCommit()); + editor.setBufferCursor(state.logBufferId, 0); } await refreshDetail(); diff --git a/crates/fresh-editor/plugins/lib/git_history.ts b/crates/fresh-editor/plugins/lib/git_history.ts index 4a34804d4..b59344c5d 100644 --- a/crates/fresh-editor/plugins/lib/git_history.ts +++ b/crates/fresh-editor/plugins/lib/git_history.ts @@ -38,8 +38,8 @@ export interface FetchGitLogOptions { export interface BuildCommitLogEntriesOptions { /** Index of the "selected" row — rendered with the selected-bg highlight. */ selectedIndex?: number; - /** Optional header string (e.g. "Commits:"). Default: "Commits:". */ - header?: string; + /** Optional header string (e.g. "Commits:"). `null` omits the header row. */ + header?: string | null; /** Footer line (status hint). Omitted when null/undefined. */ footer?: string | null; /** Target width for padding column alignment (default 0 = no padding). */ @@ -380,18 +380,20 @@ export function buildCommitLogEntries( commits: GitCommit[], opts: BuildCommitLogEntriesOptions = {} ): TextPropertyEntry[] { - const header = opts.header ?? "Commits:"; + const header = opts.header === undefined ? "Commits:" : opts.header; const footer = opts.footer; const selectedIndex = opts.selectedIndex ?? -1; const propertyType = opts.propertyType ?? "log-commit"; const entries: TextPropertyEntry[] = []; - entries.push({ - text: header + "\n", - properties: { type: "log-header" }, - style: { fg: GIT_THEME.header, bold: true, underline: true }, - }); + if (header !== null) { + entries.push({ + text: header + "\n", + properties: { type: "log-header" }, + style: { fg: GIT_THEME.header, bold: true, underline: true }, + }); + } if (commits.length === 0) { entries.push({ From a9a23042bb2ff95833c7e09d6aeb898b05f170e8 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:36:15 +0300 Subject: [PATCH 14/42] fix(git_log): bind PgUp/PgDn correctly, add Shift+motion for selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/plugins/git_log.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 11816da8a..ecde89891 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -132,12 +132,25 @@ editor.defineMode( // and Up/Down do nothing in the log panel. ["Up", "move_up"], ["Down", "move_down"], + ["Left", "move_left"], + ["Right", "move_right"], ["k", "move_up"], ["j", "move_down"], - ["PageUp", "page_up"], - ["PageDown", "page_down"], + ["PageUp", "move_page_up"], + ["PageDown", "move_page_down"], ["Home", "move_line_start"], ["End", "move_line_end"], + // Shift+arrows extend a selection like in a normal buffer, so the + // built-in Copy (Ctrl+C / Cmd+C) can grab commit text out of either + // panel. + ["Shift+Up", "select_up"], + ["Shift+Down", "select_down"], + ["Shift+Left", "select_left"], + ["Shift+Right", "select_right"], + ["Shift+PageUp", "select_page_up"], + ["Shift+PageDown", "select_page_down"], + ["Shift+Home", "select_line_start"], + ["Shift+End", "select_line_end"], // Plugin actions. ["Return", "git_log_enter"], ["Tab", "git_log_tab"], From be582f2a279a59d5f11ce52097d3e579808bfa6a Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:45:20 +0300 Subject: [PATCH 15/42] feat(plugins): mode option to inherit Normal-context bindings 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. --- crates/fresh-core/src/api.rs | 7 ++++- crates/fresh-editor/plugins/git_log.ts | 31 +++++-------------- crates/fresh-editor/plugins/lib/fresh.d.ts | 2 +- crates/fresh-editor/src/app/mod.rs | 10 +++++- .../fresh-editor/src/app/plugin_commands.rs | 11 ++++--- crates/fresh-editor/src/input/buffer_mode.rs | 20 ++++++++++++ crates/fresh-editor/src/input/keybindings.rs | 20 +++++++++++- crates/fresh-editor/tests/fixtures/large.rs | 10 +++++- .../src/backend/quickjs_backend.rs | 4 +++ 9 files changed, 82 insertions(+), 33 deletions(-) diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs index 5492955c4..8038da377 100644 --- a/crates/fresh-core/src/api.rs +++ b/crates/fresh-core/src/api.rs @@ -1401,6 +1401,9 @@ pub enum PluginCommand { read_only: bool, /// When true, unbound character keys dispatch as `mode_text_input:`. allow_text_input: bool, + /// When true, keys not bound by this mode fall through to the Normal + /// context (motion, selection, copy) instead of being dropped. + inherit_normal_bindings: bool, /// Name of the plugin that defined this mode (for attribution) plugin_name: Option, }, @@ -3031,6 +3034,7 @@ impl PluginApi { bindings, read_only, allow_text_input, + inherit_normal_bindings: false, plugin_name: None, }) } @@ -4082,7 +4086,7 @@ mod tests { false, ), PluginCommand::DefineMode { - name, bindings, read_only, allow_text_input, plugin_name + name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name } if name == "m" && bindings.len() == 1 @@ -4090,6 +4094,7 @@ mod tests { && bindings[0].1 == "move_down" && read_only && !allow_text_input + && !inherit_normal_bindings && plugin_name.is_none() ); diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index ecde89891..0c624b24d 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -124,34 +124,15 @@ function rowFromByte(bytePos: number): number { // the log, and opens the file at the cursor when pressed in the detail). // ============================================================================= +// j/k as vi-style aliases for Up/Down, plus the plugin-specific action +// keys. Everything else (arrows, Page{Up,Down}, Home/End, Shift+motion for +// selection, Ctrl+C copy, …) is inherited from the Normal keymap because +// the mode is registered with `inheritNormalBindings: true`. editor.defineMode( "git-log", [ - // Arrow / vi motion — mode bindings replace globals, so we re-bind the - // editor's built-in move actions here explicitly. Without this, j/k - // and Up/Down do nothing in the log panel. - ["Up", "move_up"], - ["Down", "move_down"], - ["Left", "move_left"], - ["Right", "move_right"], ["k", "move_up"], ["j", "move_down"], - ["PageUp", "move_page_up"], - ["PageDown", "move_page_down"], - ["Home", "move_line_start"], - ["End", "move_line_end"], - // Shift+arrows extend a selection like in a normal buffer, so the - // built-in Copy (Ctrl+C / Cmd+C) can grab commit text out of either - // panel. - ["Shift+Up", "select_up"], - ["Shift+Down", "select_down"], - ["Shift+Left", "select_left"], - ["Shift+Right", "select_right"], - ["Shift+PageUp", "select_page_up"], - ["Shift+PageDown", "select_page_down"], - ["Shift+Home", "select_line_start"], - ["Shift+End", "select_line_end"], - // Plugin actions. ["Return", "git_log_enter"], ["Tab", "git_log_tab"], ["q", "git_log_q"], @@ -159,7 +140,9 @@ editor.defineMode( ["r", "git_log_refresh"], ["y", "git_log_copy_hash"], ], - true // read-only + true, // read-only + false, // allow_text_input + true, // inherit Normal-context bindings for unbound keys ); // ============================================================================= diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index e89342bfa..ca1380afb 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -1413,7 +1413,7 @@ interface EditorAPI { /** * Define a buffer mode (takes bindings as array of [key, command] pairs) */ - defineMode(name: string, bindingsArr: string[][], readOnly?: boolean, allowTextInput?: boolean): boolean; + defineMode(name: string, bindingsArr: string[][], readOnly?: boolean, allowTextInput?: boolean, inheritNormalBindings?: boolean): boolean; /** * Set the global editor mode */ diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index caf437faa..0514bd49b 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -6014,9 +6014,17 @@ impl Editor { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { - self.handle_define_mode(name, bindings, read_only, allow_text_input, plugin_name); + self.handle_define_mode( + name, + bindings, + read_only, + allow_text_input, + inherit_normal_bindings, + plugin_name, + ); } // ==================== File/Navigation Commands ==================== diff --git a/crates/fresh-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index 2edd100aa..d7b65a3de 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -1537,6 +1537,7 @@ impl Editor { bindings: Vec<(String, String)>, read_only: bool, allow_text_input: bool, + inherit_normal_bindings: bool, plugin_name: Option, ) { use super::parse_key_string; @@ -1546,13 +1547,15 @@ impl Editor { let mode = BufferMode::new(name.clone()) .with_read_only(read_only) .with_allow_text_input(allow_text_input) + .with_inherit_normal_bindings(inherit_normal_bindings) .with_plugin_name(plugin_name); // Clear any existing plugin defaults for this mode before re-registering - self.keybindings - .write() - .unwrap() - .clear_plugin_defaults_for_mode(&name); + { + let mut kb = self.keybindings.write().unwrap(); + kb.clear_plugin_defaults_for_mode(&name); + kb.set_mode_inherits_normal_bindings(&name, inherit_normal_bindings); + } let mode_context = KeyContext::Mode(name.clone()); diff --git a/crates/fresh-editor/src/input/buffer_mode.rs b/crates/fresh-editor/src/input/buffer_mode.rs index 2249fccec..f7cd74821 100644 --- a/crates/fresh-editor/src/input/buffer_mode.rs +++ b/crates/fresh-editor/src/input/buffer_mode.rs @@ -20,6 +20,11 @@ pub struct BufferMode { /// without registering individual bindings for every character. pub allow_text_input: bool, + /// When true, keys not bound by this mode fall through to the Normal-context + /// bindings (motion, selection, copy, …) instead of being dropped. Lets + /// viewer-style modes skip re-declaring every built-in cursor action. + pub inherit_normal_bindings: bool, + /// Name of the plugin that registered this mode (for attribution in keybinding editor) pub plugin_name: Option, } @@ -31,6 +36,7 @@ impl BufferMode { name: name.into(), read_only: false, allow_text_input: false, + inherit_normal_bindings: false, plugin_name: None, } } @@ -52,6 +58,12 @@ impl BufferMode { self.allow_text_input = allow; self } + + /// Set whether unbound keys fall through to Normal-context bindings + pub fn with_inherit_normal_bindings(mut self, inherit: bool) -> Self { + self.inherit_normal_bindings = inherit; + self + } } /// Registry for buffer modes — stores metadata only. @@ -97,6 +109,14 @@ impl ModeRegistry { .unwrap_or(false) } + /// Check if a mode inherits Normal-context bindings for unbound keys + pub fn inherits_normal_bindings(&self, mode_name: &str) -> bool { + self.modes + .get(mode_name) + .map(|m| m.inherit_normal_bindings) + .unwrap_or(false) + } + /// List all registered mode names pub fn list_modes(&self) -> Vec { self.modes.keys().cloned().collect() diff --git a/crates/fresh-editor/src/input/keybindings.rs b/crates/fresh-editor/src/input/keybindings.rs index 8b19aed4a..b7fa027ac 100644 --- a/crates/fresh-editor/src/input/keybindings.rs +++ b/crates/fresh-editor/src/input/keybindings.rs @@ -1243,6 +1243,11 @@ pub struct KeybindingResolver { /// Plugin default chord bindings (for mode chord bindings from defineMode) plugin_chord_defaults: HashMap, Action>>, + + /// Plugin modes that want unbound keys to fall through to Normal + /// bindings (motion, selection, copy). Populated by `defineMode` when + /// `inheritNormalBindings: true`. + inheriting_modes: std::collections::HashSet, } impl KeybindingResolver { @@ -1255,6 +1260,7 @@ impl KeybindingResolver { chord_bindings: HashMap::new(), default_chord_bindings: HashMap::new(), plugin_chord_defaults: HashMap::new(), + inheriting_modes: std::collections::HashSet::new(), }; // Load bindings from the active keymap (with inheritance resolution) into default_bindings @@ -1435,6 +1441,17 @@ impl KeybindingResolver { let context = KeyContext::Mode(mode_name.to_string()); self.plugin_defaults.remove(&context); self.plugin_chord_defaults.remove(&context); + self.inheriting_modes.remove(mode_name); + } + + /// Mark (or unmark) a plugin mode as inheriting Normal-context bindings + /// for keys it doesn't bind itself. + pub fn set_mode_inherits_normal_bindings(&mut self, mode_name: &str, inherit: bool) { + if inherit { + self.inheriting_modes.insert(mode_name.to_string()); + } else { + self.inheriting_modes.remove(mode_name); + } } /// Get all plugin default bindings (for keybinding editor display) @@ -1642,7 +1659,8 @@ impl KeybindingResolver { // Contexts with allows_normal_fallthrough (e.g. CompositeBuffer) get ALL // Normal bindings; other contexts only get application-wide actions. if context != KeyContext::Normal { - let full_fallthrough = context.allows_normal_fallthrough(); + let full_fallthrough = context.allows_normal_fallthrough() + || matches!(&context, KeyContext::Mode(name) if self.inheriting_modes.contains(name)); if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) { if let Some(action) = normal_bindings.get(norm) { diff --git a/crates/fresh-editor/tests/fixtures/large.rs b/crates/fresh-editor/tests/fixtures/large.rs index 49b43dc70..9780c520a 100644 --- a/crates/fresh-editor/tests/fixtures/large.rs +++ b/crates/fresh-editor/tests/fixtures/large.rs @@ -5840,9 +5840,17 @@ impl Editor { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { - self.handle_define_mode(name, bindings, read_only, allow_text_input, plugin_name); + self.handle_define_mode( + name, + bindings, + read_only, + allow_text_input, + inherit_normal_bindings, + plugin_name, + ); } // ==================== File/Navigation Commands ==================== diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index a945a9764..0bd82a234 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -2719,6 +2719,7 @@ impl JsEditorApi { bindings_arr: Vec>, read_only: rquickjs::function::Opt, allow_text_input: rquickjs::function::Opt, + inherit_normal_bindings: rquickjs::function::Opt, ) -> bool { let bindings: Vec<(String, String)> = bindings_arr .into_iter() @@ -2766,6 +2767,7 @@ impl JsEditorApi { bindings, read_only: read_only.0.unwrap_or(false), allow_text_input: allow_text, + inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false), plugin_name: Some(self.plugin_name.clone()), }) .is_ok() @@ -5612,6 +5614,7 @@ mod tests { bindings, read_only, allow_text_input, + inherit_normal_bindings, plugin_name, } => { assert_eq!(name, "test-mode"); @@ -5620,6 +5623,7 @@ mod tests { assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string())); assert!(!read_only); assert!(!allow_text_input); + assert!(!inherit_normal_bindings); assert!(plugin_name.is_some()); } _ => panic!("Expected DefineMode, got {:?}", cmd), From 88be58c84bcd9c705cdb0a335059af33f6ec6bae Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:47:25 +0300 Subject: [PATCH 16/42] fix(clipboard): active_cursors routes to the focused panel, not the outer 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. --- crates/fresh-editor/src/app/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 0514bd49b..915f72c20 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -2702,15 +2702,17 @@ impl Editor { self.buffers.get_mut(&self.active_buffer()).unwrap() } - /// Get the cursors for the active buffer in the active split + /// Get the cursors for the active buffer in the active split. + /// Uses `effective_active_split` so focused buffer-group panels return + /// their own cursors (not the outer split's stale ones). pub fn active_cursors(&self) -> &Cursors { - let split_id = self.split_manager.active_split(); + let split_id = self.effective_active_split(); &self.split_view_states.get(&split_id).unwrap().cursors } /// Get the cursors for the active buffer in the active split (mutable) pub fn active_cursors_mut(&mut self) -> &mut Cursors { - let split_id = self.split_manager.active_split(); + let split_id = self.effective_active_split(); &mut self.split_view_states.get_mut(&split_id).unwrap().cursors } From 853185166bf3e1476f126fa16efaf063089e13af Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:50:40 +0300 Subject: [PATCH 17/42] fix(buffer_groups): preserve focused panel across tab switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/buffer_groups.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/fresh-editor/src/app/buffer_groups.rs b/crates/fresh-editor/src/app/buffer_groups.rs index c5ed181a2..a3bcf8317 100644 --- a/crates/fresh-editor/src/app/buffer_groups.rs +++ b/crates/fresh-editor/src/app/buffer_groups.rs @@ -386,6 +386,15 @@ impl super::Editor { vs.active_group_tab = Some(group_leaf_id); vs.focused_group_leaf = Some(inner_leaf); } + // Persist the choice on the SplitNode so a tab-away/back round + // trip restores the same panel — `activate_group_tab` reads + // this field when re-focusing the group. + if let Some(crate::view::split::SplitNode::Grouped { + active_inner_leaf, .. + }) = self.grouped_subtrees.get_mut(&group_leaf_id) + { + *active_inner_leaf = inner_leaf; + } // Transfer focus away from File Explorer (or any other context) // to the editor, since we're explicitly focusing a panel. self.key_context = crate::input::keybindings::KeyContext::Normal; From 9e53c7416ab769af1af08cf9d3735d00f9d23894 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 21:56:11 +0300 Subject: [PATCH 18/42] fix(snapshot): cursor_position prefers the split where buffer is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/mod.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 915f72c20..810251d9a 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -5466,11 +5466,22 @@ impl Editor { }; snapshot.buffer_saved_diffs.insert(*buffer_id, diff); - // Store cursor position for this buffer (from any split that has it) + // Store cursor position for this buffer. Prefer the split where + // this buffer is `active_buffer` — that's where motion actions + // write to. Group-panel buffers also sit in the outer split's + // keyed_states with a stale position, so a plain find_map + // iterates a HashMap non-deterministically and sometimes picks + // the wrong one. let cursor_pos = self .split_view_states .values() - .find_map(|vs| vs.buffer_state(*buffer_id)) + .find(|vs| vs.active_buffer == *buffer_id) + .or_else(|| { + self.split_view_states + .values() + .find(|vs| vs.keyed_states.contains_key(buffer_id)) + }) + .and_then(|vs| vs.buffer_state(*buffer_id)) .map(|bs| bs.cursors.primary().position) .unwrap_or(0); snapshot From 88f973640002e128e82bea47d00c63100dcd0b93 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:02:34 +0300 Subject: [PATCH 19/42] feat(workspace): persist read-only flag across session restore 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. --- crates/fresh-editor/src/app/workspace.rs | 31 ++++++++++++++++++++++++ crates/fresh-editor/src/workspace.rs | 6 +++++ 2 files changed, 37 insertions(+) diff --git a/crates/fresh-editor/src/app/workspace.rs b/crates/fresh-editor/src/app/workspace.rs index a1acb7375..5733c3afc 100644 --- a/crates/fresh-editor/src/app/workspace.rs +++ b/crates/fresh-editor/src/app/workspace.rs @@ -278,6 +278,22 @@ impl Editor { tracing::debug!("Captured {} external files", external_files.len()); } + // Capture read-only file paths. Store relative when inside + // working_dir (matches how open_tabs paths are stored), otherwise + // absolute — mirrors external_files. + let read_only_files: Vec = self + .buffer_metadata + .values() + .filter(|meta| meta.read_only) + .filter_map(|meta| meta.file_path().cloned()) + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| { + p.strip_prefix(&self.working_dir) + .map(|rel| rel.to_path_buf()) + .unwrap_or(p) + }) + .collect(); + // Capture unnamed buffer references (for hot_exit) let unnamed_buffers: Vec = if self.config.editor.hot_exit { self.buffer_metadata @@ -325,6 +341,7 @@ impl Editor { bookmarks, terminals, external_files, + read_only_files, unnamed_buffers, plugin_global_state: self.plugin_global_state.clone(), saved_at: std::time::SystemTime::now() @@ -751,6 +768,20 @@ impl Editor { } } + // Re-apply read-only flag for files that were locked in the saved + // session. Paths in read_only_files are relative (under working_dir) + // or absolute — try both lookups. + for ro_path in &workspace.read_only_files { + let buffer_id = path_to_buffer.get(ro_path).copied().or_else(|| { + path_to_buffer + .get(&self.working_dir.join(ro_path)) + .copied() + }); + if let Some(id) = buffer_id { + self.mark_buffer_read_only(id, true); + } + } + // 5b2. Apply hot exit recovery: restore unsaved changes to file-backed buffers if self.config.editor.hot_exit { let entries = self.recovery_service.list_recoverable().unwrap_or_default(); diff --git a/crates/fresh-editor/src/workspace.rs b/crates/fresh-editor/src/workspace.rs index 759d8b153..f219dc1b3 100644 --- a/crates/fresh-editor/src/workspace.rs +++ b/crates/fresh-editor/src/workspace.rs @@ -83,6 +83,11 @@ pub struct Workspace { #[serde(default)] pub external_files: Vec, + /// Files that were read-only at save time; re-applied on restore. + /// Relative to `working_dir` when possible, otherwise absolute. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_only_files: Vec, + /// Unnamed buffers that should be restored from recovery files #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unnamed_buffers: Vec, @@ -878,6 +883,7 @@ impl Workspace { bookmarks: HashMap::new(), terminals: Vec::new(), external_files: Vec::new(), + read_only_files: Vec::new(), unnamed_buffers: Vec::new(), plugin_global_state: HashMap::new(), saved_at: SystemTime::now() From f1311e48ba9e5e079d4a23a5259c67a23c83eb4e Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:04:59 +0300 Subject: [PATCH 20/42] feat(git_log): reset detail panel cursor on commit switch 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. --- crates/fresh-editor/plugins/git_log.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 0c624b24d..3537fbefd 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -286,9 +286,12 @@ function renderDetailPlaceholder(message: string): void { function renderDetailForCommit(commit: GitCommit, showOutput: string): void { if (state.groupId === null) return; - // No footer: the sticky toolbar panel carries all the shortcut hints now. const entries = buildCommitDetailEntries(commit, showOutput); editor.setPanelContent(state.groupId, "detail", entries); + // Always scroll the detail panel back to the top when the selection changes. + if (state.detailBufferId !== null) { + editor.setBufferCursor(state.detailBufferId, 0); + } } /** From cced994effc7fc0d2d4290ea49c9a85d570f444a Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:08:27 +0300 Subject: [PATCH 21/42] fix(scroll): shift+wheel scrolls horizontally even without scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/input.rs | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/crates/fresh-editor/src/app/input.rs b/crates/fresh-editor/src/app/input.rs index 9cb88f4e1..e38c57e6f 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -1619,33 +1619,22 @@ impl Editor { .unwrap_or_else(|| self.split_manager.active_split()); if let Some(view_state) = self.split_view_states.get_mut(&target_split) { - // Don't scroll horizontally when line wrap is enabled + // Line wrap makes horizontal scroll a no-op. if view_state.viewport.line_wrap_enabled { return Ok(()); } let columns_to_scroll = delta.unsigned_abs() as usize; + let viewport = &mut view_state.viewport; if delta < 0 { - // Scroll left - view_state.viewport.left_column = view_state - .viewport - .left_column - .saturating_sub(columns_to_scroll); + viewport.left_column = viewport.left_column.saturating_sub(columns_to_scroll); } else { - // Scroll right - clamp to max_line_length_seen - let visible_width = view_state.viewport.width as usize; - let max_scroll = view_state - .viewport - .max_line_length_seen - .saturating_sub(visible_width); - let new_left = view_state - .viewport - .left_column - .saturating_add(columns_to_scroll); - view_state.viewport.left_column = new_left.min(max_scroll); + // No max_line_length_seen clamp: that value is stale between + // renders (often 0 before any h-scroll), pinning this at 0 + // even when long lines exist. Overshoot clips at render. + viewport.left_column = viewport.left_column.saturating_add(columns_to_scroll); } - // Skip ensure_visible so the scroll position isn't undone during render - view_state.viewport.set_skip_ensure_visible(); + viewport.set_skip_ensure_visible(); } Ok(()) From e8f42805d448edbd361cc2c9194e24d08e3b3a4d Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:12:52 +0300 Subject: [PATCH 22/42] fix(git_log): name file-view buffer so syntax highlighting kicks in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The virtual-buffer name was " @ ", so the host's from_virtual_name detector ran from_path_builtin on "" — no extension match, no highlighter. Switch to "*:*" which matches the documented convention; rfind(':') grabs the path and the extension picks the right grammar. --- crates/fresh-editor/plugins/git_log.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 3537fbefd..ab98a7eaa 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -558,7 +558,9 @@ async function git_log_detail_open_file(): Promise { properties: { type: "content", line: i + 1 }, })); - const name = `${file} @ ${commit.shortHash}`; + // `*:*` matches the virtual-name convention the host uses + // to detect syntax from the trailing filename's extension. + const name = `*${commit.shortHash}:${file}*`; const view = await editor.createVirtualBuffer({ name, mode: "git-log-file-view", From 4b38b443c87715fca69c8898518eebecfa3eb09e Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:24:22 +0300 Subject: [PATCH 23/42] fix(git_log): jump cursor to target line when opening file from details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/plugins/git_log.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index ab98a7eaa..888f26681 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -570,8 +570,8 @@ async function git_log_detail_open_file(): Promise { entries, }); if (view) { - // Position cursor near target line — best-effort; the host may not - // have a byte offset for virtual buffer lines until layout runs. + const byte = await editor.getLineStartPosition(Math.max(0, line - 1)); + if (byte !== null) editor.setBufferCursor(view.bufferId, byte); editor.setStatus( editor.t("status.file_view_ready", { file, From 99c477f1b338a16eabbdee33363130c8b48a87bd Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:30:15 +0300 Subject: [PATCH 24/42] fmt --- crates/fresh-editor/src/app/workspace.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/fresh-editor/src/app/workspace.rs b/crates/fresh-editor/src/app/workspace.rs index 5733c3afc..d225c5003 100644 --- a/crates/fresh-editor/src/app/workspace.rs +++ b/crates/fresh-editor/src/app/workspace.rs @@ -772,11 +772,10 @@ impl Editor { // session. Paths in read_only_files are relative (under working_dir) // or absolute — try both lookups. for ro_path in &workspace.read_only_files { - let buffer_id = path_to_buffer.get(ro_path).copied().or_else(|| { - path_to_buffer - .get(&self.working_dir.join(ro_path)) - .copied() - }); + let buffer_id = path_to_buffer + .get(ro_path) + .copied() + .or_else(|| path_to_buffer.get(&self.working_dir.join(ro_path)).copied()); if let Some(id) = buffer_id { self.mark_buffer_read_only(id, true); } From 2bff7ad818e579004ca1e95d5a7a032bac7f0483 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:31:10 +0300 Subject: [PATCH 25/42] chore(trace): log cursor snapshot + getTextPropertiesAtCursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/mod.rs | 21 +++++--- .../src/backend/quickjs_backend.rs | 49 +++++++++++++------ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 810251d9a..b4dfa41ed 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -5472,18 +5472,25 @@ impl Editor { // keyed_states with a stale position, so a plain find_map // iterates a HashMap non-deterministically and sometimes picks // the wrong one. - let cursor_pos = self + let source_split = self .split_view_states - .values() - .find(|vs| vs.active_buffer == *buffer_id) + .iter() + .find(|(_, vs)| vs.active_buffer == *buffer_id) .or_else(|| { self.split_view_states - .values() - .find(|vs| vs.keyed_states.contains_key(buffer_id)) - }) - .and_then(|vs| vs.buffer_state(*buffer_id)) + .iter() + .find(|(_, vs)| vs.keyed_states.contains_key(buffer_id)) + }); + let cursor_pos = source_split + .and_then(|(_, vs)| vs.buffer_state(*buffer_id)) .map(|bs| bs.cursors.primary().position) .unwrap_or(0); + tracing::trace!( + "snapshot: buffer {:?} cursor_pos={} (from split {:?})", + buffer_id, + cursor_pos, + source_split.map(|(id, _)| *id), + ); snapshot .buffer_cursor_positions .insert(*buffer_id, cursor_pos); diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index 0bd82a234..9974ab09b 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -287,33 +287,54 @@ fn get_text_properties_at_cursor_typed( Err(_) => return TextPropertiesAtCursor(Vec::new()), }; let buffer_id_typed = BufferId(buffer_id as usize); - let cursor_pos = match snap - .buffer_cursor_positions - .get(&buffer_id_typed) - .copied() - .or_else(|| { - if snap.active_buffer_id == buffer_id_typed { - snap.primary_cursor.as_ref().map(|c| c.position) - } else { - None - } - }) { + let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied(); + let fallback_pos = if snap.active_buffer_id == buffer_id_typed { + snap.primary_cursor.as_ref().map(|c| c.position) + } else { + None + }; + let cursor_pos = match snapshot_pos.or(fallback_pos) { Some(pos) => pos, - None => return TextPropertiesAtCursor(Vec::new()), + None => { + tracing::debug!( + "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})", + buffer_id_typed, + snapshot_pos, + snap.active_buffer_id + ); + return TextPropertiesAtCursor(Vec::new()); + } }; let properties = match snap.buffer_text_properties.get(&buffer_id_typed) { Some(p) => p, - None => return TextPropertiesAtCursor(Vec::new()), + None => { + tracing::debug!( + "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})", + buffer_id_typed, + cursor_pos + ); + return TextPropertiesAtCursor(Vec::new()); + } }; - // Find all properties at cursor position let result: Vec<_> = properties .iter() .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end) .map(|prop| prop.properties.clone()) .collect(); + tracing::debug!( + "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}", + buffer_id_typed, + cursor_pos, + snapshot_pos, + fallback_pos, + snap.active_buffer_id, + properties.len(), + result.len() + ); + TextPropertiesAtCursor(result) } From c69c0ac98b8cb9bd087f277a64a2bdb6afad57ea Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:35:36 +0300 Subject: [PATCH 26/42] fix(snapshot): prefer group-panel split for cursor position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/mod.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index b4dfa41ed..12b1cf1f1 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -5466,16 +5466,19 @@ impl Editor { }; snapshot.buffer_saved_diffs.insert(*buffer_id, diff); - // Store cursor position for this buffer. Prefer the split where - // this buffer is `active_buffer` — that's where motion actions - // write to. Group-panel buffers also sit in the outer split's - // keyed_states with a stale position, so a plain find_map - // iterates a HashMap non-deterministically and sometimes picks - // the wrong one. + // Prefer the group-panel split (suppress_chrome=true) — that's + // where motion lands. The outer split can also end up with + // `active_buffer == buffer_id` transiently, but its cursor + // state for that buffer is stale. let source_split = self .split_view_states .iter() - .find(|(_, vs)| vs.active_buffer == *buffer_id) + .find(|(_, vs)| vs.active_buffer == *buffer_id && vs.suppress_chrome) + .or_else(|| { + self.split_view_states + .iter() + .find(|(_, vs)| vs.active_buffer == *buffer_id) + }) .or_else(|| { self.split_view_states .iter() From 3bbb3aed9fd7126734921601961018143c9e1617 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:40:12 +0300 Subject: [PATCH 27/42] fix(buffer_groups): don't leave panel buffers in the outer split's keyed_states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/buffer_groups.rs | 17 ++++++++++++++--- crates/fresh-editor/src/app/mod.rs | 20 +++++--------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_groups.rs b/crates/fresh-editor/src/app/buffer_groups.rs index a3bcf8317..322260245 100644 --- a/crates/fresh-editor/src/app/buffer_groups.rs +++ b/crates/fresh-editor/src/app/buffer_groups.rs @@ -103,14 +103,25 @@ impl super::Editor { } } - // Remove panel buffers from any split's open_buffers list - // (they were added during create_virtual_buffer). + // Remove panel buffers from every OTHER split's open_buffers AND + // keyed_states. create_virtual_buffer adds them to the active split + // when each was created; leaving them there makes the outer split + // carry a stale cursor entry for the panel buffer, which later + // collides with the panel's own view state in any lookup that + // scans split_view_states by buffer id. let hidden_panel_ids: Vec = panel_buffers.values().copied().collect(); - for (_leaf_id, vs) in self.split_view_states.iter_mut() { + let panel_leaf_ids: std::collections::HashSet = + panel_splits.values().copied().collect(); + for (leaf_id, vs) in self.split_view_states.iter_mut() { + if panel_leaf_ids.contains(leaf_id) { + // The panel's own view state needs its buffer. + continue; + } vs.open_buffers.retain(|t| match t { TabTarget::Buffer(b) => !hidden_panel_ids.contains(b), TabTarget::Group(_) => true, }); + vs.keyed_states.retain(|bid, _| !hidden_panel_ids.contains(bid)); } // Add the group as a tab in the CURRENT split's tab bar and make it diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 12b1cf1f1..f629d37b9 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -5466,24 +5466,14 @@ impl Editor { }; snapshot.buffer_saved_diffs.insert(*buffer_id, diff); - // Prefer the group-panel split (suppress_chrome=true) — that's - // where motion lands. The outer split can also end up with - // `active_buffer == buffer_id` transiently, but its cursor - // state for that buffer is stale. + // Panel buffers live in exactly one split's keyed_states + // (enforced at group creation); regular buffers live in the + // split that has them open. Either way, the first keyed_states + // hit is the only hit. let source_split = self .split_view_states .iter() - .find(|(_, vs)| vs.active_buffer == *buffer_id && vs.suppress_chrome) - .or_else(|| { - self.split_view_states - .iter() - .find(|(_, vs)| vs.active_buffer == *buffer_id) - }) - .or_else(|| { - self.split_view_states - .iter() - .find(|(_, vs)| vs.keyed_states.contains_key(buffer_id)) - }); + .find(|(_, vs)| vs.keyed_states.contains_key(buffer_id)); let cursor_pos = source_split .and_then(|(_, vs)| vs.buffer_state(*buffer_id)) .map(|bs| bs.cursors.primary().position) From 361a8575df971b152a90d69830e1958f42b192da Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 22:44:03 +0300 Subject: [PATCH 28/42] test(git_log): regression for Enter-after-closing-file-view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/fresh-editor/src/app/buffer_groups.rs | 3 +- crates/fresh-editor/tests/e2e/plugins/git.rs | 92 +++++++++++++++++++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_groups.rs b/crates/fresh-editor/src/app/buffer_groups.rs index 322260245..862b3ca13 100644 --- a/crates/fresh-editor/src/app/buffer_groups.rs +++ b/crates/fresh-editor/src/app/buffer_groups.rs @@ -121,7 +121,8 @@ impl super::Editor { TabTarget::Buffer(b) => !hidden_panel_ids.contains(b), TabTarget::Group(_) => true, }); - vs.keyed_states.retain(|bid, _| !hidden_panel_ids.contains(bid)); + vs.keyed_states + .retain(|bid, _| !hidden_panel_ids.contains(bid)); } // Add the group as a tab in the CURRENT split's tab bar and make it diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index 7c2dd369b..8eb2329e5 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -1439,10 +1439,7 @@ fn test_git_log_down_arrow_progresses_through_commits() { // After open, HEAD (Delta) should be auto-selected; detail panel shows its diff. harness - .wait_until(|h| { - let s = h.screen_to_string(); - s.contains("Commits:") && s.contains("f4_delta.txt") - }) + .wait_until(|h| h.screen_to_string().contains("f4_delta.txt")) .unwrap(); // Down once — detail should switch to Gamma's diff (f3_gamma.txt). @@ -1466,6 +1463,93 @@ fn test_git_log_down_arrow_progresses_through_commits() { .unwrap(); } +/// Regression: pressing Enter on a diff line in the commit details panel +/// opens the file at that commit. Closing the file-view and pressing Enter +/// again on a diff line must also work — it previously failed with +/// "Move cursor to a diff line with file context" because the panel +/// buffer's cursor position was read from a stale mirror entry in the +/// outer split's keyed_states. +#[test] +fn test_git_log_open_file_works_after_closing_previous_file_view() { + init_tracing_from_env(); + let repo = GitTestRepo::new(); + + repo.create_file("src/main.rs", "fn main() {\n println!(\"first\");\n}\n"); + repo.git_add(&["src/main.rs"]); + repo.git_commit("first commit"); + + // Same file edited twice so each commit has a diff body to land on. + repo.create_file("src/main.rs", "fn main() {\n println!(\"second\");\n}\n"); + repo.git_add(&["src/main.rs"]); + repo.git_commit("second commit"); + + repo.setup_git_log_plugin(); + + let mut harness = EditorTestHarness::with_config_and_working_dir( + 180, + 48, + Config::default(), + repo.path.clone(), + ) + .unwrap(); + + trigger_git_log(&mut harness); + // Wait for the detail panel to render the second (HEAD) commit diff. + harness + .wait_until(|h| h.screen_to_string().contains("+ println!(\"second\");")) + .unwrap(); + + // Focus the detail panel (Tab from log) and land on a diff line. + harness.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + // Move down enough to be on an actual diff line (past the commit header). + for _ in 0..10 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + // First Enter: open file-view of src/main.rs @ HEAD. + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness + .wait_until(|h| { + let s = h.screen_to_string(); + s.contains("println!(\"second\");") && !s.contains("Move cursor to a diff line") + }) + .unwrap(); + + // Close the file-view (q) and go back to the detail panel. + harness + .send_key(KeyCode::Char('q'), KeyModifiers::NONE) + .unwrap(); + harness + .wait_until(|h| h.screen_to_string().contains("+ println!(\"second\");")) + .unwrap(); + + // Nudge the detail cursor and press Enter again. Before the fix, + // the second Enter reported "Move cursor to a diff line with file context". + for _ in 0..5 { + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); + } + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + + // Success: either the file view opens (contains the source line) or + // at worst we get a benign "move cursor" message only if we actually + // landed outside a diff — but we're guaranteed a diff row by the + // Down presses above, so the failure mode is what the assert catches. + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + s.contains("println!(\"second\");") || s.contains("println!(\"first\");") + }) + .unwrap(); + let s = harness.screen_to_string(); + assert!( + !s.contains("Move cursor to a diff line with file context"), + "BUG: second Enter fell back to move-cursor status:\n{s}", + ); +} + // ============================================================================= // Git Blame Tests // ============================================================================= From 90724fe27b32329521d475fc82e617fa860a5c0e Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:10:35 +0300 Subject: [PATCH 29/42] test(git_log): update e2e tests for headerless + live-preview layout The "Commits:" header row was dropped in 268d1d00 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) --- crates/fresh-editor/tests/e2e/plugins/git.rs | 111 ++++++------------- 1 file changed, 32 insertions(+), 79 deletions(-) diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index 8eb2329e5..705afd191 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -975,19 +975,21 @@ fn test_git_log_shows_commits() { // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load + // Wait for git log to load (sticky toolbar + at least one commit subject) harness .wait_until(|h| { let screen = h.screen_to_string(); - // Should show "Commits:" header and at least one commit hash - screen.contains("Commits:") && screen.contains("Initial commit") + screen.contains("switch pane") && screen.contains("Initial commit") }) .unwrap(); let screen = harness.screen_to_string(); println!("Git log screen:\n{screen}"); - assert!(screen.contains("Commits:"), "Should show Commits: header"); + assert!( + screen.contains("Initial commit"), + "Should show the seeded commit subject" + ); } /// Test git log cursor navigation @@ -1027,7 +1029,7 @@ fn test_git_log_cursor_navigation() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Navigate down using j key (should work via inherited normal mode) @@ -1050,7 +1052,7 @@ fn test_git_log_cursor_navigation() { println!("After navigation:\n{screen}"); // Git log should still be visible - assert!(screen.contains("Commits:")); + assert!(screen.contains("switch pane")); } /// Test git log show commit detail with Enter @@ -1077,7 +1079,7 @@ fn test_git_log_show_commit_detail() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Move cursor to a commit line (down from header) @@ -1126,7 +1128,7 @@ fn test_git_log_back_from_commit_detail() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Move to commit and show detail @@ -1152,7 +1154,7 @@ fn test_git_log_back_from_commit_detail() { // Wait for git log to reappear harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); let screen_log = harness.screen_to_string(); @@ -1183,11 +1185,11 @@ fn test_git_log_close() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); let screen_before = harness.screen_to_string(); - assert!(screen_before.contains("Commits:")); + assert!(screen_before.contains("switch pane")); // Press q to close git log harness @@ -1203,7 +1205,8 @@ fn test_git_log_close() { println!("After closing:\n{screen_after}"); // Should no longer show git log - harness.assert_screen_not_contains("Commits:"); + // Toolbar is gone once the plugin's buffer group is closed. + harness.assert_screen_not_contains("switch pane"); } /// Test diff coloring in commit detail @@ -1231,7 +1234,7 @@ fn test_git_log_diff_coloring() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); // Move to the commit and show detail @@ -1299,7 +1302,7 @@ fn test_git_log_open_different_commits_sequentially() { // Wait for git log to load harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) + .wait_until(|h| h.screen_to_string().contains("switch pane")) .unwrap(); let screen_log = harness.screen_to_string(); @@ -1319,81 +1322,31 @@ fn test_git_log_open_different_commits_sequentially() { "Should show first commit" ); - // Navigate down to the first commit line (header is line 1, commits start at line 2) - // Most recent commit (THIRD) is at the top - harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); - - // Open the first commit (THIRD_UNIQUE_COMMIT_CCC - most recent) + // Initial selection is HEAD (THIRD) — detail panel auto-previews its diff. harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) + .wait_until(|h| h.screen_to_string().contains("file3.txt")) .unwrap(); - // Wait for commit detail - harness - .wait_until(|h| { - let screen = h.screen_to_string(); - screen.contains("Author:") && screen.contains("THIRD_UNIQUE_COMMIT_CCC") - }) - .unwrap(); - - let screen_first_detail = harness.screen_to_string(); - println!("First commit detail (should be THIRD):\n{screen_first_detail}"); - - // Press q to go back to git log - harness - .send_key(KeyCode::Char('q'), KeyModifiers::NONE) - .unwrap(); - harness.process_async_and_render().unwrap(); - - // Wait for git log to reappear - harness - .wait_until(|h| h.screen_to_string().contains("Commits:")) - .unwrap(); - - let screen_back_to_log = harness.screen_to_string(); - println!("Back to git log:\n{screen_back_to_log}"); - - // Now navigate DOWN to a DIFFERENT commit (SECOND_UNIQUE_COMMIT_BBB) + // Down → SECOND selected → detail switches to file2.txt. harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); - - let screen_after_nav = harness.screen_to_string(); - println!("After navigating down:\n{screen_after_nav}"); - - // Open the second commit - THIS IS THE BUG: it should open SECOND, not THIRD harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) + .wait_until(|h| h.screen_to_string().contains("file2.txt")) .unwrap(); + let screen_second = harness.screen_to_string(); + assert!( + screen_second.contains("SECOND_UNIQUE_COMMIT_BBB"), + "Detail should reference SECOND commit subject:\n{screen_second}" + ); - // Wait for commit detail + // Down again → FIRST selected → detail switches to file1.txt. + harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); harness - .wait_until(|h| { - let screen = h.screen_to_string(); - screen.contains("Author:") - }) + .wait_until(|h| h.screen_to_string().contains("file1.txt")) .unwrap(); - - let screen_second_detail = harness.screen_to_string(); - println!("Second commit detail (should be SECOND):\n{screen_second_detail}"); - - // CRITICAL ASSERTION: The bug is that it opens the first commit again instead of the second. - // The modern buffer-group layout keeps the commit log visible on the left and shows the - // selected commit's detail on the right, so we can't assert that THIRD is absent from the - // screen (it's still listed in the left panel). Instead, assert that the detail panel has - // moved to SECOND by looking for `file2.txt` (added in SECOND) — the file added in THIRD - // (`file3.txt`) must not appear in the right-hand detail body. - assert!( - screen_second_detail.contains("SECOND_UNIQUE_COMMIT_BBB"), - "BUG: After navigating to a different commit and pressing Enter, it should open SECOND_UNIQUE_COMMIT_BBB, but got:\n{screen_second_detail}" - ); - assert!( - screen_second_detail.contains("file2.txt"), - "BUG: The detail panel should reference file2.txt (added in SECOND commit) but got:\n{screen_second_detail}" - ); + let screen_first = harness.screen_to_string(); assert!( - !screen_second_detail.contains("file3.txt"), - "BUG: The detail panel should NOT reference file3.txt (THIRD commit's file) when SECOND is selected:\n{screen_second_detail}" + screen_first.contains("FIRST_UNIQUE_COMMIT_AAA"), + "Detail should reference FIRST commit subject:\n{screen_first}" ); } From ce00ab7215b403e543648fb40370f5c203139ff1 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:16:51 +0300 Subject: [PATCH 30/42] i18n fixes for audit_mode --- .../fresh-editor/plugins/audit_mode.i18n.json | 507 +++++++++++------- 1 file changed, 325 insertions(+), 182 deletions(-) diff --git a/crates/fresh-editor/plugins/audit_mode.i18n.json b/crates/fresh-editor/plugins/audit_mode.i18n.json index 572d23d7e..90cbaf407 100644 --- a/crates/fresh-editor/plugins/audit_mode.i18n.json +++ b/crates/fresh-editor/plugins/audit_mode.i18n.json @@ -78,10 +78,23 @@ "cmd.stop_review_diff_desc": "Zastavit relaci revize", "cmd.refresh_review_diff": "Obnovit rozdily", "cmd.refresh_review_diff_desc": "Obnovit seznam zmen", + "cmd.review_branch": "Revidovat větev PR", + "cmd.review_branch_desc": "Zkontrolovat všechny commity v aktuální větvi oproti základnímu refu", + "cmd.stop_review_branch": "Ukončit revizi větve", + "cmd.stop_review_branch_desc": "Zavřít panel revize větve PR", + "cmd.refresh_review_branch": "Obnovit větev k revizi", + "cmd.refresh_review_branch_desc": "Znovu načíst seznam commitů pro aktuální základní ref", + "prompt.branch_base": "Základní ref pro porovnání (výchozí: main):", + "status.review_branch_ready": "Kontroluje se %{count} commitů v %{base}..HEAD", + "status.review_branch_empty": "Žádné commity v %{base}..HEAD — není co revidovat.", + "panel.review_branch_header": "Commity (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navigace | Enter: detail | r: obnovit | q: zavřít", "cmd.side_by_side_diff": "Rozdily vedle sebe", "cmd.side_by_side_diff_desc": "Zobrazit rozdily aktualniho souboru vedle sebe", "cmd.add_comment": "Revize: Pridat komentar", "cmd.add_comment_desc": "Pridat komentar k revizi aktualniho bloku", + "cmd.edit_note": "Revize: Upravit poznámku", + "cmd.edit_note_desc": "Upravit poznámku relace", "cmd.export_markdown": "Revize: Exportovat do Markdown", "cmd.export_markdown_desc": "Exportovat revizi do .review/session.md", "cmd.export_json": "Revize: Exportovat do JSON", @@ -94,9 +107,14 @@ "status.failed_new_version": "Nepodarilo se nacist novou verzi souboru", "status.diff_summary": "Rozdily vedle sebe: +%{added} -%{removed} ~%{modified} | 'q' pro navrat", "status.no_hunk_selected": "Zadny blok nevybran pro komentar", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Komentar pridan k %{line}", "status.comment_cancelled": "Komentar zrusen", + "status.overall_comment_added": "Poznámka přidána", "status.exported": "Revize exportovana do %{path}", + "status.hunk_staged": "Blok připraven", + "status.hunk_unstaged": "Blok odstraněn z přípravy", + "status.hunk_discarded": "Blok zahozen", "status.generating": "Generuji proud revize rozdilu...", "status.review_summary": "Revize rozdilu: %{count} bloku | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Rezim revize rozdilu zastaven.", @@ -105,23 +123,15 @@ "status.no_changes": "V tomto souboru nejsou zadne zmeny", "status.failed_old_new_file": "Nepodarilo se nacist starou verzi souboru (soubor muze byt novy)", "prompt.comment": "Komentar k %{line}: ", - "panel.no_changes": "Zadne zmeny k revizi.", - "section.staged": "Připravené změny", - "section.unstaged": "Upravené (nepřipravené)", - "section.untracked": "Nesledované soubory", - "debug.loaded": "Plugin revize rozdilu nacten s podporou komentaru", - "cmd.edit_note": "Revize: Upravit poznámku", - "cmd.edit_note_desc": "Upravit poznámku relace", - "prompt.discard_hunk": "Zahodit tento blok v \"%{file}\"? Tuto akci nelze vrátit.", "prompt.edit_comment": "Upravit komentář na %{line}: ", "prompt.overall_comment": "Poznámka: ", - "status.hunk_discarded": "Blok zahozen", - "status.hunk_staged": "Blok připraven", - "status.hunk_unstaged": "Blok odstraněn z přípravy", - "status.overall_comment_added": "Poznámka přidána", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Zahodit tento blok v \"%{file}\"? Tuto akci nelze vrátit.", + "panel.no_changes": "Zadne zmeny k revizi.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Připravené změny", + "section.unstaged": "Upravené (nepřipravené)", + "section.untracked": "Nesledované soubory", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -130,7 +140,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin revize rozdilu nacten s podporou komentaru", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "de": { "cmd.review_diff": "Unterschiede prufen", @@ -139,10 +150,23 @@ "cmd.stop_review_diff_desc": "Review-Sitzung beenden", "cmd.refresh_review_diff": "Unterschiede aktualisieren", "cmd.refresh_review_diff_desc": "Liste der Anderungen aktualisieren", + "cmd.review_branch": "PR-Zweig überprüfen", + "cmd.review_branch_desc": "Alle Commits im aktuellen Zweig gegen eine Basis-Ref überprüfen", + "cmd.stop_review_branch": "Überprüfung beenden", + "cmd.stop_review_branch_desc": "Das Panel zur Überprüfung des PR-Zweigs schließen", + "cmd.refresh_review_branch": "Überprüfungszweig aktualisieren", + "cmd.refresh_review_branch_desc": "Commit-Liste für die aktuelle Basis-Ref neu laden", + "prompt.branch_base": "Basis-Ref zum Vergleichen (Standard: main):", + "status.review_branch_ready": "%{count} Commits in %{base}..HEAD werden überprüft", + "status.review_branch_empty": "Keine Commits in %{base}..HEAD — nichts zu überprüfen.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: Navigation | Enter: Detail | r: Aktualisieren | q: Schließen", "cmd.side_by_side_diff": "Nebeneinander-Ansicht", "cmd.side_by_side_diff_desc": "Unterschiede der aktuellen Datei nebeneinander anzeigen", "cmd.add_comment": "Review: Kommentar hinzufugen", "cmd.add_comment_desc": "Einen Review-Kommentar zum aktuellen Block hinzufugen", + "cmd.edit_note": "Review: Notiz bearbeiten", + "cmd.edit_note_desc": "Sitzungsnotiz bearbeiten", "cmd.export_markdown": "Review: Als Markdown exportieren", "cmd.export_markdown_desc": "Review nach .review/session.md exportieren", "cmd.export_json": "Review: Als JSON exportieren", @@ -155,9 +179,14 @@ "status.failed_new_version": "Neue Dateiversion konnte nicht geladen werden", "status.diff_summary": "Nebeneinander-Ansicht: +%{added} -%{removed} ~%{modified} | 'q' zum Zuruckkehren", "status.no_hunk_selected": "Kein Block fur Kommentar ausgewahlt", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Kommentar zu %{line} hinzugefugt", "status.comment_cancelled": "Kommentar abgebrochen", + "status.overall_comment_added": "Notiz hinzugefügt", "status.exported": "Review exportiert nach %{path}", + "status.hunk_staged": "Block bereitgestellt", + "status.hunk_unstaged": "Block aus Bereitstellung entfernt", + "status.hunk_discarded": "Block verworfen", "status.generating": "Review-Diff-Stream wird generiert...", "status.review_summary": "Review: %{count} Blocke | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Review-Diff-Modus beendet.", @@ -166,23 +195,15 @@ "status.no_changes": "Keine Anderungen in dieser Datei", "status.failed_old_new_file": "Alte Dateiversion konnte nicht geladen werden (Datei ist moglicherweise neu)", "prompt.comment": "Kommentar zu %{line}: ", - "panel.no_changes": "Keine Anderungen zu prufen.", - "section.staged": "Bereitgestellte Änderungen", - "section.unstaged": "Geändert (nicht bereitgestellt)", - "section.untracked": "Nicht verfolgte Dateien", - "debug.loaded": "Review-Diff-Plugin mit Kommentarunterstutzung geladen", - "cmd.edit_note": "Review: Notiz bearbeiten", - "cmd.edit_note_desc": "Sitzungsnotiz bearbeiten", - "prompt.discard_hunk": "Diesen Block in \"%{file}\" verwerfen? Dies kann nicht rückgängig gemacht werden.", "prompt.edit_comment": "Kommentar bei %{line} bearbeiten: ", "prompt.overall_comment": "Notiz: ", - "status.hunk_discarded": "Block verworfen", - "status.hunk_staged": "Block bereitgestellt", - "status.hunk_unstaged": "Block aus Bereitstellung entfernt", - "status.overall_comment_added": "Notiz hinzugefügt", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Diesen Block in \"%{file}\" verwerfen? Dies kann nicht rückgängig gemacht werden.", + "panel.no_changes": "Keine Anderungen zu prufen.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Bereitgestellte Änderungen", + "section.unstaged": "Geändert (nicht bereitgestellt)", + "section.untracked": "Nicht verfolgte Dateien", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -191,7 +212,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Review-Diff-Plugin mit Kommentarunterstutzung geladen", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "es": { "cmd.review_diff": "Revisar Diferencias", @@ -200,10 +222,23 @@ "cmd.stop_review_diff_desc": "Detener la sesion de revision", "cmd.refresh_review_diff": "Actualizar Diferencias", "cmd.refresh_review_diff_desc": "Actualizar la lista de cambios", + "cmd.review_branch": "Revisar rama del PR", + "cmd.review_branch_desc": "Revisar todos los commits de la rama actual contra una ref base", + "cmd.stop_review_branch": "Detener revisión de rama", + "cmd.stop_review_branch_desc": "Cerrar el panel de revisión de la rama del PR", + "cmd.refresh_review_branch": "Actualizar rama de revisión", + "cmd.refresh_review_branch_desc": "Volver a obtener la lista de commits para la ref base actual", + "prompt.branch_base": "Ref base para comparar (por defecto: main):", + "status.review_branch_ready": "Revisando %{count} commits en %{base}..HEAD", + "status.review_branch_empty": "No hay commits en %{base}..HEAD — nada que revisar.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navegar | Enter: detalle | r: actualizar | q: cerrar", "cmd.side_by_side_diff": "Diferencias Lado a Lado", "cmd.side_by_side_diff_desc": "Mostrar diferencias lado a lado del archivo actual", "cmd.add_comment": "Revision: Agregar Comentario", "cmd.add_comment_desc": "Agregar un comentario de revision al bloque actual", + "cmd.edit_note": "Revisión: Editar nota", + "cmd.edit_note_desc": "Editar la nota de la sesión", "cmd.export_markdown": "Revision: Exportar a Markdown", "cmd.export_markdown_desc": "Exportar revision a .review/session.md", "cmd.export_json": "Revision: Exportar a JSON", @@ -216,9 +251,14 @@ "status.failed_new_version": "Error al cargar version nueva del archivo", "status.diff_summary": "Diferencias lado a lado: +%{added} -%{removed} ~%{modified} | 'q' para volver", "status.no_hunk_selected": "Ningun bloque seleccionado para comentar", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Comentario agregado a %{line}", "status.comment_cancelled": "Comentario cancelado", + "status.overall_comment_added": "Nota añadida", "status.exported": "Revision exportada a %{path}", + "status.hunk_staged": "Bloque preparado", + "status.hunk_unstaged": "Bloque retirado de preparación", + "status.hunk_discarded": "Bloque descartado", "status.generating": "Generando flujo de revision de diferencias...", "status.review_summary": "Revision: %{count} bloques | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modo de revision de diferencias detenido.", @@ -227,23 +267,15 @@ "status.no_changes": "No hay cambios en este archivo", "status.failed_old_new_file": "Error al cargar version anterior (el archivo puede ser nuevo)", "prompt.comment": "Comentario en %{line}: ", - "panel.no_changes": "No hay cambios para revisar.", - "section.staged": "Cambios preparados", - "section.unstaged": "Modificados (sin preparar)", - "section.untracked": "Archivos sin rastrear", - "debug.loaded": "Plugin de revision de diferencias cargado con soporte de comentarios", - "cmd.edit_note": "Revisión: Editar nota", - "cmd.edit_note_desc": "Editar la nota de la sesión", - "prompt.discard_hunk": "¿Descartar este bloque en \"%{file}\"? Esta acción no se puede deshacer.", "prompt.edit_comment": "Editar comentario en %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Bloque descartado", - "status.hunk_staged": "Bloque preparado", - "status.hunk_unstaged": "Bloque retirado de preparación", - "status.overall_comment_added": "Nota añadida", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "¿Descartar este bloque en \"%{file}\"? Esta acción no se puede deshacer.", + "panel.no_changes": "No hay cambios para revisar.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Cambios preparados", + "section.unstaged": "Modificados (sin preparar)", + "section.untracked": "Archivos sin rastrear", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -252,7 +284,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revision de diferencias cargado con soporte de comentarios", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "fr": { "cmd.review_diff": "Revoir les Differences", @@ -261,10 +294,23 @@ "cmd.stop_review_diff_desc": "Arreter la session de revue", "cmd.refresh_review_diff": "Actualiser les Differences", "cmd.refresh_review_diff_desc": "Actualiser la liste des modifications", + "cmd.review_branch": "Revoir la branche de PR", + "cmd.review_branch_desc": "Revoir tous les commits de la branche actuelle par rapport à une ref de base", + "cmd.stop_review_branch": "Arrêter la revue de branche", + "cmd.stop_review_branch_desc": "Fermer le panneau de revue de branche PR", + "cmd.refresh_review_branch": "Actualiser la branche de revue", + "cmd.refresh_review_branch_desc": "Récupérer à nouveau la liste des commits pour la ref de base actuelle", + "prompt.branch_base": "Ref de base pour la comparaison (par défaut : main) :", + "status.review_branch_ready": "Revue de %{count} commits dans %{base}..HEAD", + "status.review_branch_empty": "Aucun commit dans %{base}..HEAD — rien à revoir.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k : naviguer | Entrée : détail | r : actualiser | q : fermer", "cmd.side_by_side_diff": "Differences Cote a Cote", "cmd.side_by_side_diff_desc": "Afficher les differences du fichier actuel cote a cote", "cmd.add_comment": "Revue: Ajouter un Commentaire", "cmd.add_comment_desc": "Ajouter un commentaire de revue au bloc actuel", + "cmd.edit_note": "Revue : Modifier la note", + "cmd.edit_note_desc": "Modifier la note de session", "cmd.export_markdown": "Revue: Exporter en Markdown", "cmd.export_markdown_desc": "Exporter la revue vers .review/session.md", "cmd.export_json": "Revue: Exporter en JSON", @@ -277,9 +323,14 @@ "status.failed_new_version": "Echec du chargement de la nouvelle version du fichier", "status.diff_summary": "Differences cote a cote: +%{added} -%{removed} ~%{modified} | 'q' pour revenir", "status.no_hunk_selected": "Aucun bloc selectionne pour le commentaire", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Commentaire ajoute a %{line}", "status.comment_cancelled": "Commentaire annule", + "status.overall_comment_added": "Note ajoutée", "status.exported": "Revue exportee vers %{path}", + "status.hunk_staged": "Bloc indexé", + "status.hunk_unstaged": "Bloc retiré de l'index", + "status.hunk_discarded": "Bloc supprimé", "status.generating": "Generation du flux de revue des differences...", "status.review_summary": "Revue: %{count} blocs | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Mode de revue des differences arrete.", @@ -288,23 +339,15 @@ "status.no_changes": "Aucune modification dans ce fichier", "status.failed_old_new_file": "Echec du chargement de l'ancienne version (le fichier est peut-etre nouveau)", "prompt.comment": "Commentaire sur %{line}: ", - "panel.no_changes": "Aucune modification a revoir.", - "section.staged": "Modifications indexées", - "section.unstaged": "Modifiés (non indexés)", - "section.untracked": "Fichiers non suivis", - "debug.loaded": "Plugin de revue des differences charge avec prise en charge des commentaires", - "cmd.edit_note": "Revue : Modifier la note", - "cmd.edit_note_desc": "Modifier la note de session", - "prompt.discard_hunk": "Supprimer ce bloc dans \"%{file}\" ? Cette action est irréversible.", "prompt.edit_comment": "Modifier le commentaire sur %{line} : ", "prompt.overall_comment": "Note : ", - "status.hunk_discarded": "Bloc supprimé", - "status.hunk_staged": "Bloc indexé", - "status.hunk_unstaged": "Bloc retiré de l'index", - "status.overall_comment_added": "Note ajoutée", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Supprimer ce bloc dans \"%{file}\" ? Cette action est irréversible.", + "panel.no_changes": "Aucune modification a revoir.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Modifications indexées", + "section.unstaged": "Modifiés (non indexés)", + "section.untracked": "Fichiers non suivis", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -313,7 +356,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revue des differences charge avec prise en charge des commentaires", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "it": { "cmd.review_diff": "Revisiona differenze", @@ -322,10 +366,23 @@ "cmd.stop_review_diff_desc": "Interrompi la sessione di revisione", "cmd.refresh_review_diff": "Aggiorna differenze", "cmd.refresh_review_diff_desc": "Aggiorna l'elenco delle modifiche", + "cmd.review_branch": "Revisiona ramo PR", + "cmd.review_branch_desc": "Revisiona tutti i commit del ramo corrente rispetto a un ref base", + "cmd.stop_review_branch": "Interrompi revisione ramo", + "cmd.stop_review_branch_desc": "Chiudi il pannello di revisione del ramo PR", + "cmd.refresh_review_branch": "Aggiorna ramo di revisione", + "cmd.refresh_review_branch_desc": "Ricarica la lista dei commit per il ref base corrente", + "prompt.branch_base": "Ref base per il confronto (predefinito: main):", + "status.review_branch_ready": "Revisione di %{count} commit in %{base}..HEAD", + "status.review_branch_empty": "Nessun commit in %{base}..HEAD — niente da revisionare.", + "panel.review_branch_header": "Commit (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: naviga | Invio: dettaglio | r: aggiorna | q: chiudi", "cmd.side_by_side_diff": "Diff affiancato", "cmd.side_by_side_diff_desc": "Mostra diff affiancato per il file corrente", "cmd.add_comment": "Revisione: Aggiungi commento", "cmd.add_comment_desc": "Aggiungi un commento di revisione al blocco corrente", + "cmd.edit_note": "Revisione: Modifica nota", + "cmd.edit_note_desc": "Modifica la nota della sessione", "cmd.export_markdown": "Revisione: Esporta in Markdown", "cmd.export_markdown_desc": "Esporta la revisione in .review/session.md", "cmd.export_json": "Revisione: Esporta in JSON", @@ -338,9 +395,14 @@ "status.failed_new_version": "Impossibile caricare la nuova versione del file", "status.diff_summary": "Diff affiancato: +%{added} -%{removed} ~%{modified} | 'q' per tornare", "status.no_hunk_selected": "Nessun blocco selezionato per il commento", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Commento aggiunto a %{line}", "status.comment_cancelled": "Commento annullato", + "status.overall_comment_added": "Nota aggiunta", "status.exported": "Revisione esportata in %{path}", + "status.hunk_staged": "Blocco preparato", + "status.hunk_unstaged": "Blocco rimosso dalla preparazione", + "status.hunk_discarded": "Blocco eliminato", "status.generating": "Generazione stream differenze revisione...", "status.review_summary": "Differenze revisione: %{count} blocchi | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modalità Revisione differenze interrotta.", @@ -349,23 +411,15 @@ "status.no_changes": "Nessuna modifica in questo file", "status.failed_old_new_file": "Impossibile caricare la vecchia versione (il file potrebbe essere nuovo)", "prompt.comment": "Commento su %{line}: ", - "panel.no_changes": "Nessuna modifica da revisionare.", - "section.staged": "Modifiche preparate", - "section.unstaged": "Modificati (non preparati)", - "section.untracked": "File non tracciati", - "debug.loaded": "Plugin Revisione differenze caricato con supporto commenti", - "cmd.edit_note": "Revisione: Modifica nota", - "cmd.edit_note_desc": "Modifica la nota della sessione", - "prompt.discard_hunk": "Eliminare questo blocco in \"%{file}\"? Questa azione non può essere annullata.", "prompt.edit_comment": "Modifica commento su %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Blocco eliminato", - "status.hunk_staged": "Blocco preparato", - "status.hunk_unstaged": "Blocco rimosso dalla preparazione", - "status.overall_comment_added": "Nota aggiunta", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Eliminare questo blocco in \"%{file}\"? Questa azione non può essere annullata.", + "panel.no_changes": "Nessuna modifica da revisionare.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Modifiche preparate", + "section.unstaged": "Modificati (non preparati)", + "section.untracked": "File non tracciati", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -374,7 +428,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin Revisione differenze caricato con supporto commenti", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ja": { "cmd.review_diff": "差分レビュー", @@ -383,10 +438,23 @@ "cmd.stop_review_diff_desc": "レビューセッションを停止", "cmd.refresh_review_diff": "差分を更新", "cmd.refresh_review_diff_desc": "変更リストを更新", + "cmd.review_branch": "PR ブランチをレビュー", + "cmd.review_branch_desc": "現在のブランチのすべてのコミットをベース ref に対してレビュー", + "cmd.stop_review_branch": "レビューを停止", + "cmd.stop_review_branch_desc": "PR ブランチレビューパネルを閉じる", + "cmd.refresh_review_branch": "レビューブランチを更新", + "cmd.refresh_review_branch_desc": "現在のベース ref のコミットリストを再取得", + "prompt.branch_base": "比較対象のベース ref (デフォルト: main):", + "status.review_branch_ready": "%{base}..HEAD の %{count} 件のコミットをレビュー中", + "status.review_branch_empty": "%{base}..HEAD にコミットはありません — レビューする対象がありません。", + "panel.review_branch_header": "コミット (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: 移動 | Enter: 詳細 | r: 更新 | q: 閉じる", "cmd.side_by_side_diff": "サイドバイサイド差分", "cmd.side_by_side_diff_desc": "現在のファイルの差分を横並びで表示", "cmd.add_comment": "レビュー: コメント追加", "cmd.add_comment_desc": "現在のハンクにレビューコメントを追加", + "cmd.edit_note": "レビュー: メモを編集", + "cmd.edit_note_desc": "セッションメモを編集", "cmd.export_markdown": "レビュー: Markdownにエクスポート", "cmd.export_markdown_desc": "レビューを.review/session.mdにエクスポート", "cmd.export_json": "レビュー: JSONにエクスポート", @@ -399,9 +467,14 @@ "status.failed_new_version": "新しいファイルバージョンの読み込みに失敗しました", "status.diff_summary": "サイドバイサイド差分: +%{added} -%{removed} ~%{modified} | 'q'で戻る", "status.no_hunk_selected": "コメントするハンクが選択されていません", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "%{line}にコメントを追加しました", "status.comment_cancelled": "コメントがキャンセルされました", + "status.overall_comment_added": "メモを追加しました", "status.exported": "レビューを%{path}にエクスポートしました", + "status.hunk_staged": "ハンクをステージしました", + "status.hunk_unstaged": "ハンクをアンステージしました", + "status.hunk_discarded": "ハンクを破棄しました", "status.generating": "レビュー差分ストリームを生成中...", "status.review_summary": "レビュー差分: %{count}ハンク | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "レビュー差分モードを停止しました。", @@ -410,23 +483,15 @@ "status.no_changes": "このファイルに変更はありません", "status.failed_old_new_file": "古いファイルバージョンの読み込みに失敗しました(新規ファイルの可能性があります)", "prompt.comment": "%{line}へのコメント: ", - "panel.no_changes": "レビューする変更がありません。", - "section.staged": "ステージ済みの変更", - "section.unstaged": "変更あり(未ステージ)", - "section.untracked": "未追跡ファイル", - "debug.loaded": "レビュー差分プラグインがコメントサポート付きで読み込まれました", - "cmd.edit_note": "レビュー: メモを編集", - "cmd.edit_note_desc": "セッションメモを編集", - "prompt.discard_hunk": "\"%{file}\" のこのハンクを破棄しますか?この操作は元に戻せません。", "prompt.edit_comment": "%{line} のコメントを編集: ", "prompt.overall_comment": "メモ: ", - "status.hunk_discarded": "ハンクを破棄しました", - "status.hunk_staged": "ハンクをステージしました", - "status.hunk_unstaged": "ハンクをアンステージしました", - "status.overall_comment_added": "メモを追加しました", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "\"%{file}\" のこのハンクを破棄しますか?この操作は元に戻せません。", + "panel.no_changes": "レビューする変更がありません。", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "ステージ済みの変更", + "section.unstaged": "変更あり(未ステージ)", + "section.untracked": "未追跡ファイル", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -435,7 +500,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "レビュー差分プラグインがコメントサポート付きで読み込まれました", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ko": { "cmd.review_diff": "차이점 검토", @@ -444,10 +510,23 @@ "cmd.stop_review_diff_desc": "리뷰 세션 중지", "cmd.refresh_review_diff": "차이점 새로고침", "cmd.refresh_review_diff_desc": "변경 목록 새로고침", + "cmd.review_branch": "PR 브랜치 리뷰", + "cmd.review_branch_desc": "현재 브랜치의 모든 커밋을 기준 ref와 비교해 리뷰", + "cmd.stop_review_branch": "리뷰 중지", + "cmd.stop_review_branch_desc": "PR 브랜치 리뷰 패널 닫기", + "cmd.refresh_review_branch": "리뷰 브랜치 새로 고침", + "cmd.refresh_review_branch_desc": "현재 기준 ref의 커밋 목록을 다시 가져오기", + "prompt.branch_base": "비교할 기준 ref (기본값: main):", + "status.review_branch_ready": "%{base}..HEAD의 %{count}개 커밋을 리뷰 중", + "status.review_branch_empty": "%{base}..HEAD에 커밋이 없습니다 — 리뷰할 내용이 없습니다.", + "panel.review_branch_header": "커밋 (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: 이동 | Enter: 상세 | r: 새로 고침 | q: 닫기", "cmd.side_by_side_diff": "나란히 비교", "cmd.side_by_side_diff_desc": "현재 파일의 차이점을 나란히 표시", "cmd.add_comment": "검토: 코멘트 추가", "cmd.add_comment_desc": "현재 헝크에 리뷰 코멘트 추가", + "cmd.edit_note": "검토: 메모 편집", + "cmd.edit_note_desc": "세션 메모 편집", "cmd.export_markdown": "검토: Markdown으로 내보내기", "cmd.export_markdown_desc": "리뷰를 .review/session.md로 내보내기", "cmd.export_json": "검토: JSON으로 내보내기", @@ -460,9 +539,14 @@ "status.failed_new_version": "새 파일 버전 로드 실패", "status.diff_summary": "나란히 비교: +%{added} -%{removed} ~%{modified} | 'q'로 돌아가기", "status.no_hunk_selected": "코멘트할 헝크가 선택되지 않았습니다", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "%{line}에 코멘트가 추가되었습니다", "status.comment_cancelled": "코멘트가 취소되었습니다", + "status.overall_comment_added": "메모가 추가되었습니다", "status.exported": "리뷰가 %{path}로 내보내졌습니다", + "status.hunk_staged": "헝크가 스테이지되었습니다", + "status.hunk_unstaged": "헝크가 언스테이지되었습니다", + "status.hunk_discarded": "헝크가 삭제되었습니다", "status.generating": "리뷰 차이점 스트림 생성 중...", "status.review_summary": "리뷰 차이점: %{count}개 헝크 | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "리뷰 차이점 모드가 중지되었습니다.", @@ -471,23 +555,15 @@ "status.no_changes": "이 파일에 변경 사항이 없습니다", "status.failed_old_new_file": "이전 파일 버전 로드 실패 (새 파일일 수 있음)", "prompt.comment": "%{line}에 대한 코멘트: ", - "panel.no_changes": "검토할 변경 사항이 없습니다.", - "section.staged": "스테이지된 변경사항", - "section.unstaged": "수정됨 (스테이지 안됨)", - "section.untracked": "추적되지 않는 파일", - "debug.loaded": "리뷰 차이점 플러그인이 코멘트 지원과 함께 로드되었습니다", - "cmd.edit_note": "검토: 메모 편집", - "cmd.edit_note_desc": "세션 메모 편집", - "prompt.discard_hunk": "\"%{file}\"의 이 헝크를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "prompt.edit_comment": "%{line}의 코멘트 편집: ", "prompt.overall_comment": "메모: ", - "status.hunk_discarded": "헝크가 삭제되었습니다", - "status.hunk_staged": "헝크가 스테이지되었습니다", - "status.hunk_unstaged": "헝크가 언스테이지되었습니다", - "status.overall_comment_added": "메모가 추가되었습니다", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "\"%{file}\"의 이 헝크를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "panel.no_changes": "검토할 변경 사항이 없습니다.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "스테이지된 변경사항", + "section.unstaged": "수정됨 (스테이지 안됨)", + "section.untracked": "추적되지 않는 파일", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -496,7 +572,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "리뷰 차이점 플러그인이 코멘트 지원과 함께 로드되었습니다", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "pt-BR": { "cmd.review_diff": "Revisar Diferencas", @@ -505,10 +582,23 @@ "cmd.stop_review_diff_desc": "Parar a sessao de revisao", "cmd.refresh_review_diff": "Atualizar Diferencas", "cmd.refresh_review_diff_desc": "Atualizar a lista de alteracoes", + "cmd.review_branch": "Revisar branch do PR", + "cmd.review_branch_desc": "Revisar todos os commits da branch atual contra uma ref base", + "cmd.stop_review_branch": "Parar revisão da branch", + "cmd.stop_review_branch_desc": "Fechar o painel de revisão da branch do PR", + "cmd.refresh_review_branch": "Atualizar branch de revisão", + "cmd.refresh_review_branch_desc": "Recarregar a lista de commits para a ref base atual", + "prompt.branch_base": "Ref base para comparação (padrão: main):", + "status.review_branch_ready": "Revisando %{count} commits em %{base}..HEAD", + "status.review_branch_empty": "Sem commits em %{base}..HEAD — nada para revisar.", + "panel.review_branch_header": "Commits (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: navegar | Enter: detalhe | r: atualizar | q: fechar", "cmd.side_by_side_diff": "Diferencas Lado a Lado", "cmd.side_by_side_diff_desc": "Mostrar diferencas do arquivo atual lado a lado", "cmd.add_comment": "Revisao: Adicionar Comentario", "cmd.add_comment_desc": "Adicionar um comentario de revisao ao bloco atual", + "cmd.edit_note": "Revisão: Editar nota", + "cmd.edit_note_desc": "Editar a nota da sessão", "cmd.export_markdown": "Revisao: Exportar para Markdown", "cmd.export_markdown_desc": "Exportar revisao para .review/session.md", "cmd.export_json": "Revisao: Exportar para JSON", @@ -521,9 +611,14 @@ "status.failed_new_version": "Falha ao carregar versao nova do arquivo", "status.diff_summary": "Diferencas lado a lado: +%{added} -%{removed} ~%{modified} | 'q' para voltar", "status.no_hunk_selected": "Nenhum bloco selecionado para comentario", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Comentario adicionado em %{line}", "status.comment_cancelled": "Comentario cancelado", + "status.overall_comment_added": "Nota adicionada", "status.exported": "Revisao exportada para %{path}", + "status.hunk_staged": "Bloco preparado", + "status.hunk_unstaged": "Bloco removido da preparação", + "status.hunk_discarded": "Bloco descartado", "status.generating": "Gerando fluxo de revisao de diferencas...", "status.review_summary": "Revisao: %{count} blocos | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Modo de revisao de diferencas parado.", @@ -532,23 +627,15 @@ "status.no_changes": "Sem alteracoes neste arquivo", "status.failed_old_new_file": "Falha ao carregar versao antiga (arquivo pode ser novo)", "prompt.comment": "Comentario em %{line}: ", - "panel.no_changes": "Sem alteracoes para revisar.", - "section.staged": "Alterações preparadas", - "section.unstaged": "Modificados (não preparados)", - "section.untracked": "Arquivos não rastreados", - "debug.loaded": "Plugin de revisao de diferencas carregado com suporte a comentarios", - "cmd.edit_note": "Revisão: Editar nota", - "cmd.edit_note_desc": "Editar a nota da sessão", - "prompt.discard_hunk": "Descartar este bloco em \"%{file}\"? Esta ação não pode ser desfeita.", "prompt.edit_comment": "Editar comentário em %{line}: ", "prompt.overall_comment": "Nota: ", - "status.hunk_discarded": "Bloco descartado", - "status.hunk_staged": "Bloco preparado", - "status.hunk_unstaged": "Bloco removido da preparação", - "status.overall_comment_added": "Nota adicionada", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Descartar este bloco em \"%{file}\"? Esta ação não pode ser desfeita.", + "panel.no_changes": "Sem alteracoes para revisar.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Alterações preparadas", + "section.unstaged": "Modificados (não preparados)", + "section.untracked": "Arquivos não rastreados", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -557,7 +644,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin de revisao de diferencas carregado com suporte a comentarios", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "ru": { "cmd.review_diff": "Просмотр изменений", @@ -566,10 +654,23 @@ "cmd.stop_review_diff_desc": "Остановить сеанс ревью", "cmd.refresh_review_diff": "Обновить изменения", "cmd.refresh_review_diff_desc": "Обновить список изменений", + "cmd.review_branch": "Ревью ветки PR", + "cmd.review_branch_desc": "Просмотреть все коммиты текущей ветки относительно базового ref", + "cmd.stop_review_branch": "Остановить ревью ветки", + "cmd.stop_review_branch_desc": "Закрыть панель ревью ветки PR", + "cmd.refresh_review_branch": "Обновить ветку для ревью", + "cmd.refresh_review_branch_desc": "Перезагрузить список коммитов для текущего базового ref", + "prompt.branch_base": "Базовый ref для сравнения (по умолчанию: main):", + "status.review_branch_ready": "Ревью %{count} коммитов в %{base}..HEAD", + "status.review_branch_empty": "Нет коммитов в %{base}..HEAD — нечего просматривать.", + "panel.review_branch_header": "Коммиты (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: навигация | Enter: детали | r: обновить | q: закрыть", "cmd.side_by_side_diff": "Сравнение бок о бок", "cmd.side_by_side_diff_desc": "Показать изменения текущего файла бок о бок", "cmd.add_comment": "Ревью: Добавить комментарий", "cmd.add_comment_desc": "Добавить комментарий к текущему блоку", + "cmd.edit_note": "Ревью: Редактировать заметку", + "cmd.edit_note_desc": "Редактировать заметку сессии", "cmd.export_markdown": "Ревью: Экспорт в Markdown", "cmd.export_markdown_desc": "Экспортировать ревью в .review/session.md", "cmd.export_json": "Ревью: Экспорт в JSON", @@ -582,9 +683,14 @@ "status.failed_new_version": "Не удалось загрузить новую версию файла", "status.diff_summary": "Сравнение бок о бок: +%{added} -%{removed} ~%{modified} | 'q' для возврата", "status.no_hunk_selected": "Блок для комментария не выбран", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Комментарий добавлен к %{line}", "status.comment_cancelled": "Комментарий отменен", + "status.overall_comment_added": "Заметка добавлена", "status.exported": "Ревью экспортировано в %{path}", + "status.hunk_staged": "Блок подготовлен", + "status.hunk_unstaged": "Блок убран из подготовки", + "status.hunk_discarded": "Блок отброшен", "status.generating": "Генерация потока ревью изменений...", "status.review_summary": "Ревью: %{count} блоков | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Режим ревью изменений остановлен.", @@ -593,23 +699,15 @@ "status.no_changes": "Нет изменений в этом файле", "status.failed_old_new_file": "Не удалось загрузить старую версию файла (файл может быть новым)", "prompt.comment": "Комментарий к %{line}: ", - "panel.no_changes": "Нет изменений для просмотра.", - "section.staged": "Подготовленные изменения", - "section.unstaged": "Изменённые (неподготовленные)", - "section.untracked": "Неотслеживаемые файлы", - "debug.loaded": "Плагин ревью изменений загружен с поддержкой комментариев", - "cmd.edit_note": "Ревью: Редактировать заметку", - "cmd.edit_note_desc": "Редактировать заметку сессии", - "prompt.discard_hunk": "Отбросить этот блок в \"%{file}\"? Это действие нельзя отменить.", "prompt.edit_comment": "Редактировать комментарий на %{line}: ", "prompt.overall_comment": "Заметка: ", - "status.hunk_discarded": "Блок отброшен", - "status.hunk_staged": "Блок подготовлен", - "status.hunk_unstaged": "Блок убран из подготовки", - "status.overall_comment_added": "Заметка добавлена", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Отбросить этот блок в \"%{file}\"? Это действие нельзя отменить.", + "panel.no_changes": "Нет изменений для просмотра.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Подготовленные изменения", + "section.unstaged": "Изменённые (неподготовленные)", + "section.untracked": "Неотслеживаемые файлы", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -618,7 +716,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Плагин ревью изменений загружен с поддержкой комментариев", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "th": { "cmd.review_diff": "ตรวจสอบความแตกต่าง", @@ -627,10 +726,23 @@ "cmd.stop_review_diff_desc": "หยุดเซสชันการตรวจสอบ", "cmd.refresh_review_diff": "รีเฟรชความแตกต่าง", "cmd.refresh_review_diff_desc": "รีเฟรชรายการการเปลี่ยนแปลง", + "cmd.review_branch": "ตรวจสอบสาขา PR", + "cmd.review_branch_desc": "ตรวจสอบคอมมิตทั้งหมดในสาขาปัจจุบันเทียบกับ ref ฐาน", + "cmd.stop_review_branch": "หยุดการตรวจสอบสาขา", + "cmd.stop_review_branch_desc": "ปิดแผงตรวจสอบสาขา PR", + "cmd.refresh_review_branch": "รีเฟรชสาขาสำหรับการตรวจสอบ", + "cmd.refresh_review_branch_desc": "โหลดรายการคอมมิตใหม่สำหรับ ref ฐานปัจจุบัน", + "prompt.branch_base": "ref ฐานสำหรับเปรียบเทียบ (ค่าเริ่มต้น: main):", + "status.review_branch_ready": "กำลังตรวจสอบคอมมิต %{count} รายการใน %{base}..HEAD", + "status.review_branch_empty": "ไม่มีคอมมิตใน %{base}..HEAD — ไม่มีอะไรต้องตรวจสอบ", + "panel.review_branch_header": "คอมมิต (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: นำทาง | Enter: รายละเอียด | r: รีเฟรช | q: ปิด", "cmd.side_by_side_diff": "เปรียบเทียบแบบเคียงข้าง", "cmd.side_by_side_diff_desc": "แสดงความแตกต่างของไฟล์ปัจจุบันแบบเคียงข้าง", "cmd.add_comment": "ตรวจสอบ: เพิ่มความคิดเห็น", "cmd.add_comment_desc": "เพิ่มความคิดเห็นการตรวจสอบไปยังบล็อกปัจจุบัน", + "cmd.edit_note": "ตรวจสอบ: แก้ไขบันทึก", + "cmd.edit_note_desc": "แก้ไขบันทึกของเซสชัน", "cmd.export_markdown": "ตรวจสอบ: ส่งออกเป็น Markdown", "cmd.export_markdown_desc": "ส่งออกการตรวจสอบไปยัง .review/session.md", "cmd.export_json": "ตรวจสอบ: ส่งออกเป็น JSON", @@ -643,9 +755,14 @@ "status.failed_new_version": "ไม่สามารถโหลดเวอร์ชันใหม่ของไฟล์", "status.diff_summary": "เปรียบเทียบแบบเคียงข้าง: +%{added} -%{removed} ~%{modified} | 'q' เพื่อกลับ", "status.no_hunk_selected": "ไม่ได้เลือกบล็อกสำหรับความคิดเห็น", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "เพิ่มความคิดเห็นที่ %{line} แล้ว", "status.comment_cancelled": "ยกเลิกความคิดเห็นแล้ว", + "status.overall_comment_added": "เพิ่มบันทึกแล้ว", "status.exported": "ส่งออกการตรวจสอบไปยัง %{path} แล้ว", + "status.hunk_staged": "สเตจบล็อกแล้ว", + "status.hunk_unstaged": "ยกเลิกสเตจบล็อกแล้ว", + "status.hunk_discarded": "ทิ้งบล็อกแล้ว", "status.generating": "กำลังสร้างสตรีมความแตกต่างการตรวจสอบ...", "status.review_summary": "ตรวจสอบ: %{count} บล็อก | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "หยุดโหมดตรวจสอบความแตกต่างแล้ว", @@ -654,23 +771,15 @@ "status.no_changes": "ไม่มีการเปลี่ยนแปลงในไฟล์นี้", "status.failed_old_new_file": "ไม่สามารถโหลดเวอร์ชันเก่าของไฟล์ (ไฟล์อาจเป็นไฟล์ใหม่)", "prompt.comment": "ความคิดเห็นที่ %{line}: ", - "panel.no_changes": "ไม่มีการเปลี่ยนแปลงให้ตรวจสอบ", - "section.staged": "การเปลี่ยนแปลงที่จัดเตรียมแล้ว", - "section.unstaged": "แก้ไขแล้ว (ยังไม่จัดเตรียม)", - "section.untracked": "ไฟล์ที่ไม่ได้ติดตาม", - "debug.loaded": "โหลดปลั๊กอินตรวจสอบความแตกต่างพร้อมรองรับความคิดเห็นแล้ว", - "cmd.edit_note": "ตรวจสอบ: แก้ไขบันทึก", - "cmd.edit_note_desc": "แก้ไขบันทึกของเซสชัน", - "prompt.discard_hunk": "ทิ้งบล็อกนี้ใน \"%{file}\" หรือไม่? การกระทำนี้ไม่สามารถย้อนกลับได้", "prompt.edit_comment": "แก้ไขความคิดเห็นที่ %{line}: ", "prompt.overall_comment": "บันทึก: ", - "status.hunk_discarded": "ทิ้งบล็อกแล้ว", - "status.hunk_staged": "สเตจบล็อกแล้ว", - "status.hunk_unstaged": "ยกเลิกสเตจบล็อกแล้ว", - "status.overall_comment_added": "เพิ่มบันทึกแล้ว", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "ทิ้งบล็อกนี้ใน \"%{file}\" หรือไม่? การกระทำนี้ไม่สามารถย้อนกลับได้", + "panel.no_changes": "ไม่มีการเปลี่ยนแปลงให้ตรวจสอบ", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "การเปลี่ยนแปลงที่จัดเตรียมแล้ว", + "section.unstaged": "แก้ไขแล้ว (ยังไม่จัดเตรียม)", + "section.untracked": "ไฟล์ที่ไม่ได้ติดตาม", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -679,7 +788,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "โหลดปลั๊กอินตรวจสอบความแตกต่างพร้อมรองรับความคิดเห็นแล้ว", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "uk": { "cmd.review_diff": "Перегляд змін", @@ -688,10 +798,23 @@ "cmd.stop_review_diff_desc": "Зупинити сеанс рев'ю", "cmd.refresh_review_diff": "Оновити зміни", "cmd.refresh_review_diff_desc": "Оновити список змін", + "cmd.review_branch": "Рев'ю гілки PR", + "cmd.review_branch_desc": "Переглянути всі коміти поточної гілки відносно базового ref", + "cmd.stop_review_branch": "Зупинити рев'ю гілки", + "cmd.stop_review_branch_desc": "Закрити панель рев'ю гілки PR", + "cmd.refresh_review_branch": "Оновити гілку для рев'ю", + "cmd.refresh_review_branch_desc": "Перезавантажити список комітів для поточного базового ref", + "prompt.branch_base": "Базовий ref для порівняння (за замовчуванням: main):", + "status.review_branch_ready": "Рев'ю %{count} комітів у %{base}..HEAD", + "status.review_branch_empty": "Немає комітів у %{base}..HEAD — нічого переглядати.", + "panel.review_branch_header": "Коміти (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: навігація | Enter: деталі | r: оновити | q: закрити", "cmd.side_by_side_diff": "Порівняння поруч", "cmd.side_by_side_diff_desc": "Показати зміни поточного файлу поруч", "cmd.add_comment": "Рев'ю: Додати коментар", "cmd.add_comment_desc": "Додати коментар до поточного блоку", + "cmd.edit_note": "Рев'ю: Редагувати нотатку", + "cmd.edit_note_desc": "Редагувати нотатку сесії", "cmd.export_markdown": "Рев'ю: Експорт у Markdown", "cmd.export_markdown_desc": "Експортувати рев'ю до .review/session.md", "cmd.export_json": "Рев'ю: Експорт у JSON", @@ -704,9 +827,14 @@ "status.failed_new_version": "Не вдалося завантажити нову версію файлу", "status.diff_summary": "Порівняння поруч: +%{added} -%{removed} ~%{modified} | 'q' для повернення", "status.no_hunk_selected": "Блок для коментаря не вибрано", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Коментар додано до %{line}", "status.comment_cancelled": "Коментар скасовано", + "status.overall_comment_added": "Нотатку додано", "status.exported": "Рев'ю експортовано до %{path}", + "status.hunk_staged": "Блок підготовлено", + "status.hunk_unstaged": "Блок прибрано з підготовки", + "status.hunk_discarded": "Блок відкинуто", "status.generating": "Генерація потоку змін рев'ю...", "status.review_summary": "Рев'ю: %{count} блоків | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Режим рев'ю змін зупинено.", @@ -715,23 +843,15 @@ "status.no_changes": "Немає змін у цьому файлі", "status.failed_old_new_file": "Не вдалося завантажити стару версію файлу (файл може бути новим)", "prompt.comment": "Коментар до %{line}: ", - "panel.no_changes": "Немає змін для перегляду.", - "section.staged": "Підготовлені зміни", - "section.unstaged": "Змінені (непідготовлені)", - "section.untracked": "Невідстежувані файли", - "debug.loaded": "Плагін рев'ю змін завантажено з підтримкою коментарів", - "cmd.edit_note": "Рев'ю: Редагувати нотатку", - "cmd.edit_note_desc": "Редагувати нотатку сесії", - "prompt.discard_hunk": "Відкинути цей блок у \"%{file}\"? Цю дію не можна скасувати.", "prompt.edit_comment": "Редагувати коментар на %{line}: ", "prompt.overall_comment": "Нотатка: ", - "status.hunk_discarded": "Блок відкинуто", - "status.hunk_staged": "Блок підготовлено", - "status.hunk_unstaged": "Блок прибрано з підготовки", - "status.overall_comment_added": "Нотатку додано", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Відкинути цей блок у \"%{file}\"? Цю дію не можна скасувати.", + "panel.no_changes": "Немає змін для перегляду.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Підготовлені зміни", + "section.unstaged": "Змінені (непідготовлені)", + "section.untracked": "Невідстежувані файли", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -740,7 +860,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Плагін рев'ю змін завантажено з підтримкою коментарів", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "vi": { "cmd.review_diff": "Xem xét khác biệt", @@ -749,10 +870,23 @@ "cmd.stop_review_diff_desc": "Dừng phiên xem xét", "cmd.refresh_review_diff": "Làm mới khác biệt", "cmd.refresh_review_diff_desc": "Làm mới danh sách thay đổi", + "cmd.review_branch": "Xem xét nhánh PR", + "cmd.review_branch_desc": "Xem xét tất cả commit trên nhánh hiện tại so với ref cơ sở", + "cmd.stop_review_branch": "Dừng xem xét nhánh", + "cmd.stop_review_branch_desc": "Đóng bảng xem xét nhánh PR", + "cmd.refresh_review_branch": "Làm mới nhánh xem xét", + "cmd.refresh_review_branch_desc": "Tải lại danh sách commit cho ref cơ sở hiện tại", + "prompt.branch_base": "Ref cơ sở để so sánh (mặc định: main):", + "status.review_branch_ready": "Đang xem xét %{count} commit trong %{base}..HEAD", + "status.review_branch_empty": "Không có commit nào trong %{base}..HEAD — không có gì để xem xét.", + "panel.review_branch_header": "Commit (%{base}..HEAD)", + "panel.review_branch_footer": "j/k: điều hướng | Enter: chi tiết | r: làm mới | q: đóng", "cmd.side_by_side_diff": "So sánh song song", "cmd.side_by_side_diff_desc": "Hiển thị khác biệt song song cho tệp hiện tại", "cmd.add_comment": "Xem xét: Thêm nhận xét", "cmd.add_comment_desc": "Thêm nhận xét xem xét cho khối hiện tại", + "cmd.edit_note": "Xem xét: Sửa ghi chú", + "cmd.edit_note_desc": "Sửa ghi chú phiên", "cmd.export_markdown": "Xem xét: Xuất ra Markdown", "cmd.export_markdown_desc": "Xuất xem xét ra .review/session.md", "cmd.export_json": "Xem xét: Xuất ra JSON", @@ -765,9 +899,14 @@ "status.failed_new_version": "Không thể tải phiên bản mới của tệp", "status.diff_summary": "So sánh song song: +%{added} -%{removed} ~%{modified} | 'q' để quay lại", "status.no_hunk_selected": "Chưa chọn khối để nhận xét", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "Đã thêm nhận xét vào %{line}", "status.comment_cancelled": "Đã hủy nhận xét", + "status.overall_comment_added": "Đã thêm ghi chú", "status.exported": "Đã xuất xem xét ra %{path}", + "status.hunk_staged": "Đã đưa khối vào vùng chuẩn bị", + "status.hunk_unstaged": "Đã bỏ khối khỏi vùng chuẩn bị", + "status.hunk_discarded": "Đã bỏ khối", "status.generating": "Đang tạo luồng khác biệt xem xét...", "status.review_summary": "Xem xét: %{count} khối | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "Đã dừng chế độ xem xét khác biệt.", @@ -776,23 +915,15 @@ "status.no_changes": "Không có thay đổi trong tệp này", "status.failed_old_new_file": "Không thể tải phiên bản cũ của tệp (tệp có thể là mới)", "prompt.comment": "Nhận xét trên %{line}: ", - "panel.no_changes": "Không có thay đổi để xem xét.", - "section.staged": "Thay đổi đã chuẩn bị", - "section.unstaged": "Đã sửa đổi (chưa chuẩn bị)", - "section.untracked": "Tệp không được theo dõi", - "debug.loaded": "Plugin xem xét khác biệt đã tải với hỗ trợ nhận xét", - "cmd.edit_note": "Xem xét: Sửa ghi chú", - "cmd.edit_note_desc": "Sửa ghi chú phiên", - "prompt.discard_hunk": "Bỏ khối này trong \"%{file}\"? Hành động này không thể hoàn tác.", "prompt.edit_comment": "Sửa nhận xét tại %{line}: ", "prompt.overall_comment": "Ghi chú: ", - "status.hunk_discarded": "Đã bỏ khối", - "status.hunk_staged": "Đã đưa khối vào vùng chuẩn bị", - "status.hunk_unstaged": "Đã bỏ khối khỏi vùng chuẩn bị", - "status.overall_comment_added": "Đã thêm ghi chú", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "Bỏ khối này trong \"%{file}\"? Hành động này không thể hoàn tác.", + "panel.no_changes": "Không có thay đổi để xem xét.", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "Thay đổi đã chuẩn bị", + "section.unstaged": "Đã sửa đổi (chưa chuẩn bị)", + "section.untracked": "Tệp không được theo dõi", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -801,7 +932,8 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "Plugin xem xét khác biệt đã tải với hỗ trợ nhận xét", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" }, "zh-CN": { "cmd.review_diff": "审查差异", @@ -810,10 +942,23 @@ "cmd.stop_review_diff_desc": "停止审查会话", "cmd.refresh_review_diff": "刷新差异", "cmd.refresh_review_diff_desc": "刷新更改列表", + "cmd.review_branch": "审查 PR 分支", + "cmd.review_branch_desc": "将当前分支的所有提交与基础 ref 进行比较审查", + "cmd.stop_review_branch": "停止分支审查", + "cmd.stop_review_branch_desc": "关闭 PR 分支审查面板", + "cmd.refresh_review_branch": "刷新审查分支", + "cmd.refresh_review_branch_desc": "重新获取当前基础 ref 的提交列表", + "prompt.branch_base": "用于比较的基础 ref(默认:main):", + "status.review_branch_ready": "正在审查 %{base}..HEAD 中的 %{count} 个提交", + "status.review_branch_empty": "%{base}..HEAD 中没有提交 — 无需审查。", + "panel.review_branch_header": "提交 (%{base}..HEAD)", + "panel.review_branch_footer": "j/k:导航 | Enter:详情 | r:刷新 | q:关闭", "cmd.side_by_side_diff": "并排差异", "cmd.side_by_side_diff_desc": "并排显示当前文件的差异", "cmd.add_comment": "审查: 添加评论", "cmd.add_comment_desc": "为当前代码块添加审查评论", + "cmd.edit_note": "审查: 编辑备注", + "cmd.edit_note_desc": "编辑会话备注", "cmd.export_markdown": "审查: 导出为Markdown", "cmd.export_markdown_desc": "将审查导出到.review/session.md", "cmd.export_json": "审查: 导出为JSON", @@ -826,9 +971,14 @@ "status.failed_new_version": "加载新文件版本失败", "status.diff_summary": "并排差异: +%{added} -%{removed} ~%{modified} | 按'q'返回", "status.no_hunk_selected": "未选择要评论的代码块", + "status.comment_needs_line": "Position cursor on a diff line to add a comment", "status.comment_added": "评论已添加到%{line}", "status.comment_cancelled": "评论已取消", + "status.overall_comment_added": "备注已添加", "status.exported": "审查已导出到%{path}", + "status.hunk_staged": "代码块已暂存", + "status.hunk_unstaged": "代码块已取消暂存", + "status.hunk_discarded": "代码块已丢弃", "status.generating": "正在生成审查差异流...", "status.review_summary": "审查差异: %{count}个代码块 | [c]omment [a]pprove [r]efresh [e]xport", "status.stopped": "审查差异模式已停止。", @@ -837,23 +987,15 @@ "status.no_changes": "此文件没有更改", "status.failed_old_new_file": "加载旧文件版本失败(文件可能是新建的)", "prompt.comment": "在%{line}上评论: ", - "panel.no_changes": "没有需要审查的更改。", - "section.staged": "已暂存的更改", - "section.unstaged": "已修改(未暂存)", - "section.untracked": "未跟踪的文件", - "debug.loaded": "审查差异插件已加载,支持评论功能", - "cmd.edit_note": "审查: 编辑备注", - "cmd.edit_note_desc": "编辑会话备注", - "prompt.discard_hunk": "丢弃 \"%{file}\" 中的此代码块?此操作无法撤销。", "prompt.edit_comment": "编辑 %{line} 的评论: ", "prompt.overall_comment": "备注: ", - "status.hunk_discarded": "代码块已丢弃", - "status.hunk_staged": "代码块已暂存", - "status.hunk_unstaged": "代码块已取消暂存", - "status.overall_comment_added": "备注已添加", - "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}", + "prompt.discard_hunk": "丢弃 \"%{file}\" 中的此代码块?此操作无法撤销。", + "panel.no_changes": "没有需要审查的更改。", "panel.comments": "Comments", "panel.no_comments": "No comments yet.", + "section.staged": "已暂存的更改", + "section.unstaged": "已修改(未暂存)", + "section.untracked": "未跟踪的文件", "status.review_empty": "Review Diff", "status.no_comments": "No comments", "status.visual_no_diff_line": "Visual selection requires a diff line", @@ -862,6 +1004,7 @@ "status.lines_staged": "Lines staged", "status.lines_unstaged": "Lines unstaged", "status.lines_discarded": "Lines discarded", - "status.comment_needs_line": "Position cursor on a diff line to add a comment" + "debug.loaded": "审查差异插件已加载,支持评论功能", + "status.review_summary_indexed": "Review Diff: Hunk %{current} of %{count}" } } From ab28f3e6e8220c2ba9c0920d94538ddf3fbf3b05 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:36:51 +0300 Subject: [PATCH 31/42] fix(buffer_groups): make non-scrollable panels truly inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/src/app/buffer_groups.rs | 39 +++++++++++++-- crates/fresh-editor/src/app/input.rs | 26 ++++++++-- crates/fresh-editor/src/app/mod.rs | 12 +++++ crates/fresh-editor/src/app/mouse_input.rs | 17 +++++++ crates/fresh-editor/src/state.rs | 6 +++ .../src/view/ui/split_rendering.rs | 47 ++++++++++--------- 6 files changed, 118 insertions(+), 29 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_groups.rs b/crates/fresh-editor/src/app/buffer_groups.rs index 862b3ca13..3aa174964 100644 --- a/crates/fresh-editor/src/app/buffer_groups.rs +++ b/crates/fresh-editor/src/app/buffer_groups.rs @@ -15,9 +15,22 @@ use std::collections::HashMap; #[serde(tag = "type")] enum LayoutDesc { #[serde(rename = "scrollable")] - Scrollable { id: String }, + Scrollable { + id: String, + /// Whether this panel responds to scroll events. Defaults to true + /// for scrollable panels. + scrollable: Option, + }, #[serde(rename = "fixed")] - Fixed { id: String, height: u16 }, + Fixed { + id: String, + height: u16, + /// Whether this panel responds to scroll events. Defaults to false + /// for fixed-height panels — their content is pinned to the panel + /// size, so mouse-wheel scroll is a no-op and no scrollbar is drawn. + /// Callers can override by passing `"scrollable": true`. + scrollable: Option, + }, #[serde(rename = "split")] Split { direction: String, // "h" or "v" @@ -232,13 +245,14 @@ impl super::Editor { panel_buffers: &mut HashMap, ) -> Result { match desc { - LayoutDesc::Scrollable { id } => { + LayoutDesc::Scrollable { id, scrollable } => { + let scrollable = scrollable.unwrap_or(true); let buffer_id = self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true); - // Configure the buffer for panel use if let Some(state) = self.buffers.get_mut(&buffer_id) { state.show_cursors = false; state.editing_disabled = true; + state.scrollable = scrollable; state.margins.configure_for_line_numbers(false); } panel_buffers.insert(id.clone(), buffer_id); @@ -248,12 +262,18 @@ impl super::Editor { split_id: None, }) } - LayoutDesc::Fixed { id, height } => { + LayoutDesc::Fixed { + id, + height, + scrollable, + } => { + let scrollable = scrollable.unwrap_or(false); let buffer_id = self.create_virtual_buffer(format!("*{}*", id), mode.to_string(), true); if let Some(state) = self.buffers.get_mut(&buffer_id) { state.show_cursors = false; state.editing_disabled = true; + state.scrollable = scrollable; state.margins.configure_for_line_numbers(false); } panel_buffers.insert(id.clone(), buffer_id); @@ -502,6 +522,15 @@ fn fixed_height_of(node: &GroupLayoutNode) -> Option { } } +impl super::Editor { + /// Whether the given buffer is marked non-scrollable. Buffer-group + /// panels can set `scrollable: false` (and Fixed panels default to + /// it) so the mouse wheel is a no-op and no scrollbar is drawn. + pub(crate) fn is_non_scrollable_buffer(&self, buffer_id: BufferId) -> bool { + self.buffers.get(&buffer_id).is_some_and(|s| !s.scrollable) + } +} + /// Find the first scrollable leaf in the layout tree. fn find_first_scrollable_name(node: &GroupLayoutNode) -> Option { match node { diff --git a/crates/fresh-editor/src/app/input.rs b/crates/fresh-editor/src/app/input.rs index e38c57e6f..6d3f06a2d 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -1504,6 +1504,13 @@ impl Editor { .split_at_position(col, row) .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer())); + // Panels marked non-scrollable (buffer-group toolbars/headers/footers + // default to this) swallow the wheel event — their content is pinned + // so scrolling would just shift the visible rows by one line. + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } + // Check if this is a composite buffer - if so, use composite scroll if self.is_composite_buffer(buffer_id) { let max_row = self @@ -1613,10 +1620,13 @@ impl Editor { row: u16, delta: i32, ) -> AnyhowResult<()> { - let target_split = self + let (target_split, buffer_id) = self .split_at_position(col, row) - .map(|(id, _)| id) - .unwrap_or_else(|| self.split_manager.active_split()); + .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer())); + + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); + } if let Some(view_state) = self.split_view_states.get_mut(&target_split) { // Line wrap makes horizontal scroll a no-op. @@ -2666,6 +2676,16 @@ impl Editor { ); } + // Buffers with hidden cursors (buffer-group toolbars/headers/footers) + // aren't interactive targets: focusing them would let arrow keys move + // an invisible cursor and scroll the pinned content. Swallow the click + // after the plugin hook has had a chance to observe it. + if let Some(state) = self.buffers.get(&buffer_id) { + if !state.show_cursors { + return Ok(()); + } + } + // Focus this split (handles terminal mode exit, tab state, etc.) self.focus_split(split_id, buffer_id); diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index f629d37b9..d83ca07ef 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -2589,6 +2589,18 @@ impl Editor { /// /// Use this instead of calling set_active_split directly when switching focus. pub(super) fn focus_split(&mut self, split_id: LeafId, buffer_id: BufferId) { + // Buffer-group panels with hidden cursors (toolbars, headers, footers) + // aren't focus targets: focusing them would route keyboard input at an + // invisible cursor. Plugins can still detect clicks via the mouse_click + // hook, which fires in the click handlers before reaching here. + if self + .buffers + .get(&buffer_id) + .is_some_and(|s| !s.show_cursors) + { + return; + } + let previous_split = self.split_manager.active_split(); let previous_buffer = self.active_buffer(); // Get BEFORE changing split let split_changed = previous_split != split_id; diff --git a/crates/fresh-editor/src/app/mouse_input.rs b/crates/fresh-editor/src/app/mouse_input.rs index 61ed84262..5d9689ad5 100644 --- a/crates/fresh-editor/src/app/mouse_input.rs +++ b/crates/fresh-editor/src/app/mouse_input.rs @@ -1105,6 +1105,15 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; + // Non-interactive panels (hidden cursor) swallow double-click. + if self + .buffers + .get(&buffer_id) + .is_some_and(|s| !s.show_cursors) + { + return Ok(()); + } + // Focus this split self.focus_split(split_id, buffer_id); @@ -1245,6 +1254,14 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; + if self + .buffers + .get(&buffer_id) + .is_some_and(|s| !s.show_cursors) + { + return Ok(()); + } + // Focus this split self.focus_split(split_id, buffer_id); diff --git a/crates/fresh-editor/src/state.rs b/crates/fresh-editor/src/state.rs index 54cf8da35..2763d06df 100644 --- a/crates/fresh-editor/src/state.rs +++ b/crates/fresh-editor/src/state.rs @@ -185,6 +185,11 @@ pub struct EditorState { /// but navigation, selection, and copy are still allowed pub editing_disabled: bool, + /// Whether this buffer can be scrolled (default true). Fixed buffer-group + /// panels (toolbars, headers, footers) set this to false so the mouse + /// wheel is ignored and no scrollbar is drawn. + pub scrollable: bool, + /// Per-buffer user settings (tab size, indentation style, etc.) /// These settings are preserved across file reloads (auto-revert) pub buffer_settings: BufferSettings, @@ -287,6 +292,7 @@ impl EditorState { text_properties: TextPropertyManager::new(), show_cursors: true, editing_disabled: false, + scrollable: true, buffer_settings: BufferSettings::default(), reference_highlighter: ReferenceHighlighter::new(), is_composite_buffer: false, diff --git a/crates/fresh-editor/src/view/ui/split_rendering.rs b/crates/fresh-editor/src/view/ui/split_rendering.rs index c37e7c3a3..e46633999 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering.rs @@ -1113,6 +1113,12 @@ impl SplitRenderer { .and_then(|svs| svs.get(&split_id)) .is_some_and(|vs| vs.hide_tilde); + // Non-scrollable panels (Fixed toolbars/headers/footers by default, + // or any panel created with `scrollable: false`) don't get a + // scrollbar — their content is pinned to the panel size. + let is_non_scrollable = buffers.get(&buffer_id).is_some_and(|s| !s.scrollable); + let panel_show_vscroll = show_vertical_scrollbar && !is_non_scrollable; + let layout = if is_inner_group_leaf { // Inner leaf: split_area IS the content rect already. SplitLayout { @@ -1120,17 +1126,15 @@ impl SplitRenderer { content_rect: Rect::new( split_area.x, split_area.y, - split_area.width.saturating_sub(if show_vertical_scrollbar { - 1 - } else { - 0 - }), + split_area + .width + .saturating_sub(if panel_show_vscroll { 1 } else { 0 }), split_area.height, ), scrollbar_rect: Rect::new( split_area.x + split_area.width.saturating_sub(1), split_area.y, - if show_vertical_scrollbar { 1 } else { 0 }, + if panel_show_vscroll { 1 } else { 0 }, split_area.height, ), horizontal_scrollbar_rect: Rect::new(0, 0, 0, 0), @@ -1139,8 +1143,8 @@ impl SplitRenderer { Self::split_layout( split_area, split_tab_bar_visible, - show_vertical_scrollbar, - show_horizontal_scrollbar, + show_vertical_scrollbar && !is_non_scrollable, + show_horizontal_scrollbar && !is_non_scrollable, ) }; let (split_buffers, tab_scroll_offset) = if is_inner_group_leaf { @@ -1334,18 +1338,19 @@ impl SplitRenderer { // Render scrollbar for composite buffer let total_rows = composite.row_count(); let content_height = layout.content_rect.height.saturating_sub(1) as usize; // -1 for header - let (thumb_start, thumb_end) = if show_vertical_scrollbar { - Self::render_composite_scrollbar( - frame, - layout.scrollbar_rect, - total_rows, - view_state.scroll_row, - content_height, - is_active, - ) - } else { - (0, 0) - }; + let (thumb_start, thumb_end) = + if show_vertical_scrollbar && !is_non_scrollable { + Self::render_composite_scrollbar( + frame, + layout.scrollbar_rect, + total_rows, + view_state.scroll_row, + content_height, + is_active, + ) + } else { + (0, 0) + }; // Store the areas for mouse handling split_areas.push(( @@ -1487,7 +1492,7 @@ impl SplitRenderer { }; // Render vertical scrollbar for this split and get thumb position - let (thumb_start, thumb_end) = if show_vertical_scrollbar { + let (thumb_start, thumb_end) = if show_vertical_scrollbar && !is_non_scrollable { Self::render_scrollbar( frame, state, From b071d9774845456e72e25e647003d8edd0fdd1ad Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:45:52 +0300 Subject: [PATCH 32/42] feat(git_log): clickable toolbar buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/plugins/git_log.ts | 134 ++++++++++++++++++------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 888f26681..980ad7420 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -36,6 +36,9 @@ interface GitLogState { groupId: number | null; logBufferId: number | null; detailBufferId: number | null; + toolbarBufferId: number | null; + /** Click-regions for the toolbar's buttons, populated by `renderToolbar`. */ + toolbarButtons: ToolbarButton[]; commits: GitCommit[]; selectedIndex: number; /** Cached `git show` output for the currently-displayed detail commit. */ @@ -67,6 +70,8 @@ const state: GitLogState = { groupId: null, logBufferId: null, detailBufferId: null, + toolbarBufferId: null, + toolbarButtons: [], commits: [], selectedIndex: 0, detailCache: null, @@ -136,7 +141,6 @@ editor.defineMode( ["Return", "git_log_enter"], ["Tab", "git_log_tab"], ["q", "git_log_q"], - ["Escape", "git_log_q"], ["r", "git_log_refresh"], ["y", "git_log_copy_hash"], ], @@ -177,57 +181,77 @@ const GROUP_LAYOUT = JSON.stringify({ interface ToolbarHint { key: string; label: string; + /** Click action — `null` for hints that are keyboard-only (j/k, PgUp). */ + onClick: (() => void | Promise) | null; } -const TOOLBAR_HINTS: ToolbarHint[] = [ - { key: "j/k", label: "navigate" }, - { key: "PgUp/PgDn", label: "page" }, - { key: "Tab", label: "switch pane" }, - { key: "RET", label: "open file" }, - { key: "y", label: "yank hash" }, - { key: "r", label: "refresh" }, - { key: "q", label: "quit" }, -]; +interface ToolbarButton { + row: number; + startCol: number; + endCol: number; + onClick: (() => void | Promise) | null; +} + +function toolbarHints(): ToolbarHint[] { + return [ + { key: "Tab", label: "switch pane", onClick: git_log_tab }, + { key: "RET", label: "open file", onClick: git_log_enter }, + { key: "y", label: "copy hash", onClick: git_log_copy_hash }, + { key: "r", label: "refresh", onClick: git_log_refresh }, + { key: "q", label: "quit", onClick: git_log_q }, + ]; +} /** - * Build a single-row sticky toolbar. Keys render bold; separators between - * hints are dim. No width-aware truncation — the host crops to panel width, - * and the hints are already short enough to fit a typical terminal. + * Build the single-row toolbar. Each hint renders as a discrete button with + * its own background so it reads as clickable; the column range of each + * button is captured in `state.toolbarButtons` so `on_git_log_toolbar_click` + * can map a mouse click back to the right handler. */ -function buildToolbarEntries(): TextPropertyEntry[] { - let text = " "; +function buildToolbarEntries(width: number): TextPropertyEntry[] { + const W = Math.max(20, width); + const buttons: ToolbarButton[] = []; + let text = ""; const overlays: InlineOverlay[] = []; - for (let i = 0; i < TOOLBAR_HINTS.length; i++) { - if (i > 0) { - const sep = " │ "; - const sepStart = utf8Len(text); - text += sep; - overlays.push({ - start: sepStart, - end: utf8Len(text), - style: { fg: "ui.split_separator_fg" }, - }); - } - const { key, label } = TOOLBAR_HINTS[i]; - const keyDisplay = `[${key}]`; - const keyStart = utf8Len(text); - text += keyDisplay; + for (const hint of toolbarHints()) { + const body = ` [${hint.key}] ${hint.label} `; + const bodyLen = body.length; + const gap = text.length > 0 ? 1 : 0; + if (text.length + gap + bodyLen > W) break; + + if (gap) text += " "; + + const startCol = text.length; + const startByte = utf8Len(text); + text += body; + const endByte = utf8Len(text); + const endCol = text.length; + overlays.push({ - start: keyStart, - end: utf8Len(text), + start: startByte, + end: endByte, + style: { bg: "ui.status_bar_bg" }, + }); + const keyDisplay = `[${hint.key}]`; + const keyStartByte = startByte + utf8Len(" "); + const keyEndByte = keyStartByte + utf8Len(keyDisplay); + overlays.push({ + start: keyStartByte, + end: keyEndByte, style: { fg: "editor.fg", bold: true }, }); - const labelText = " " + label; - const labelStart = utf8Len(text); - text += labelText; overlays.push({ - start: labelStart, - end: utf8Len(text), + start: keyEndByte, + end: endByte, style: { fg: "editor.line_number_fg" }, }); + + buttons.push({ row: 0, startCol, endCol, onClick: hint.onClick }); } + state.toolbarButtons = buttons; + return [ { text: text + "\n", @@ -240,8 +264,35 @@ function buildToolbarEntries(): TextPropertyEntry[] { function renderToolbar(): void { if (state.groupId === null) return; - editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries()); + const vp = editor.getViewport(); + const width = vp ? vp.width : 80; + editor.setPanelContent(state.groupId, "toolbar", buildToolbarEntries(width)); +} + +function on_git_log_toolbar_click(data: { + buffer_id: number | null; + buffer_row: number | null; + buffer_col: number | null; +}): void { + if (!state.isOpen) return; + if (data.buffer_id === null || data.buffer_id !== state.toolbarBufferId) return; + if (data.buffer_row === null || data.buffer_col === null) return; + const row = data.buffer_row; + const col = data.buffer_col; + const hit = state.toolbarButtons.find( + (b) => b.row === row && col >= b.startCol && col < b.endCol + ); + if (hit && hit.onClick) { + void hit.onClick(); + } +} +registerHandler("on_git_log_toolbar_click", on_git_log_toolbar_click); + +function on_git_log_resize(_data: { width: number; height: number }): void { + if (!state.isOpen) return; + renderToolbar(); } +registerHandler("on_git_log_resize", on_git_log_resize); // ============================================================================= // Rendering @@ -374,6 +425,7 @@ async function show_git_log(): Promise { state.groupId = group.groupId as number; state.logBufferId = (group.panels["log"] as number | undefined) ?? null; state.detailBufferId = (group.panels["detail"] as number | undefined) ?? null; + state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null; state.selectedIndex = 0; state.detailCache = null; state.isOpen = true; @@ -407,6 +459,8 @@ async function show_git_log(): Promise { editor.focusBufferGroupPanel(state.groupId, "log"); } editor.on("cursor_moved", "on_git_log_cursor_moved"); + editor.on("mouse_click", "on_git_log_toolbar_click"); + editor.on("resize", "on_git_log_resize"); editor.setStatus( editor.t("status.log_ready", { count: String(state.commits.length) }) @@ -420,10 +474,14 @@ function git_log_close(): void { editor.closeBufferGroup(state.groupId); } editor.off("cursor_moved", "on_git_log_cursor_moved"); + editor.off("mouse_click", "on_git_log_toolbar_click"); + editor.off("resize", "on_git_log_resize"); state.isOpen = false; state.groupId = null; state.logBufferId = null; state.detailBufferId = null; + state.toolbarBufferId = null; + state.toolbarButtons = []; state.commits = []; state.selectedIndex = 0; state.detailCache = null; From 138c387315a2a3b1831aa5e9cd855cf72d0c1d97 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:50:34 +0300 Subject: [PATCH 33/42] fix(git_log): reset state when panels close externally 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) --- crates/fresh-editor/plugins/git_log.ts | 31 ++++++++++++++++--- .../fresh-editor/src/app/buffer_management.rs | 9 ++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index 980ad7420..e4f6d110a 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -461,6 +461,7 @@ async function show_git_log(): Promise { editor.on("cursor_moved", "on_git_log_cursor_moved"); editor.on("mouse_click", "on_git_log_toolbar_click"); editor.on("resize", "on_git_log_resize"); + editor.on("buffer_closed", "on_git_log_buffer_closed"); editor.setStatus( editor.t("status.log_ready", { count: String(state.commits.length) }) @@ -468,14 +469,15 @@ async function show_git_log(): Promise { } registerHandler("show_git_log", show_git_log); -function git_log_close(): void { +/** Reset all state + unsubscribe. Idempotent; safe to call from either + * path (user-initiated close or externally-closed group via the tab's + * close button, which triggers `buffer_closed`). */ +function git_log_cleanup(): void { if (!state.isOpen) return; - if (state.groupId !== null) { - editor.closeBufferGroup(state.groupId); - } editor.off("cursor_moved", "on_git_log_cursor_moved"); editor.off("mouse_click", "on_git_log_toolbar_click"); editor.off("resize", "on_git_log_resize"); + editor.off("buffer_closed", "on_git_log_buffer_closed"); state.isOpen = false; state.groupId = null; state.logBufferId = null; @@ -485,10 +487,31 @@ function git_log_close(): void { state.commits = []; state.selectedIndex = 0; state.detailCache = null; +} + +function git_log_close(): void { + if (!state.isOpen) return; + const groupId = state.groupId; + git_log_cleanup(); + if (groupId !== null) { + editor.closeBufferGroup(groupId); + } editor.setStatus(editor.t("status.closed")); } registerHandler("git_log_close", git_log_close); +function on_git_log_buffer_closed(data: { buffer_id: number }): void { + if (!state.isOpen) return; + if ( + data.buffer_id === state.logBufferId || + data.buffer_id === state.detailBufferId || + data.buffer_id === state.toolbarBufferId + ) { + git_log_cleanup(); + } +} +registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed); + async function git_log_refresh(): Promise { if (!state.isOpen) return; editor.setStatus(editor.t("status.refreshing")); diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 4cbeae4b5..7094a0d51 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -2458,6 +2458,15 @@ impl Editor { } } + // Notify plugins so they can reset any state tied to this buffer + // (e.g. a plugin that owns a buffer group clears its `isOpen` flag + // when the group is closed via the tab's close button rather than + // through the plugin's own close command). + self.plugin_manager.run_hook( + "buffer_closed", + fresh_core::hooks::HookArgs::BufferClosed { buffer_id: id }, + ); + Ok(()) } From 87edee6cf13510dca29f78ca671e913048f07a6b Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:53:56 +0300 Subject: [PATCH 34/42] feat(git_log): activate existing tab when re-invoked 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) --- crates/fresh-editor/plugins/git_log.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index e4f6d110a..d3b198189 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -403,7 +403,11 @@ function indexFromCursorByte(bytePos: number): number { async function show_git_log(): Promise { if (state.isOpen) { - editor.setStatus(editor.t("status.already_open")); + // Already open — pull the existing tab to the front instead of + // bailing out with a status message. + if (state.groupId !== null) { + editor.focusBufferGroupPanel(state.groupId, "log"); + } return; } editor.setStatus(editor.t("status.loading")); From da05563902a3f397c71ab654b95fb35e68e626a0 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Tue, 14 Apr 2026 23:56:54 +0300 Subject: [PATCH 35/42] fix(tabs): inactive-split active tab invisible on high-contrast theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/src/view/ui/tabs.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/src/view/ui/tabs.rs b/crates/fresh-editor/src/view/ui/tabs.rs index 858d6a964..27069a149 100644 --- a/crates/fresh-editor/src/view/ui/tabs.rs +++ b/crates/fresh-editor/src/view/ui/tabs.rs @@ -469,7 +469,13 @@ impl TabsRenderer { _ => (false, false), }; - // Determine base style + // Determine base style. For the inactive split's active tab, + // we keep BOLD to show which tab is active inside that split, + // but use `tab_inactive_fg` instead of `tab_active_fg`. Pairing + // `tab_active_fg` with `tab_inactive_bg` assumed active_fg was + // chosen against active_bg — which breaks on themes (e.g. + // high-contrast) where active_fg == inactive_bg and the tab + // label disappears. let mut base_style = if is_active { if is_active_split { Style::default() @@ -478,7 +484,7 @@ impl TabsRenderer { .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(theme.tab_active_fg) + .fg(theme.tab_inactive_fg) .bg(theme.tab_inactive_bg) .add_modifier(Modifier::BOLD) } From 884cf8df06e7cf6693eb3240767d29bd73d43ec3 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 00:08:39 +0300 Subject: [PATCH 36/42] fix(grammar): highlight TypeScript from set-language and list it in grammars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/src/main.rs | 5 ++- .../src/primitives/detected_language.rs | 29 ++++++------- .../src/primitives/grammar/types.rs | 42 +++++++++++++++++++ .../src/primitives/highlight_engine.rs | 9 ++++ 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/crates/fresh-editor/src/main.rs b/crates/fresh-editor/src/main.rs index fb7f9f0c7..9887c32ce 100644 --- a/crates/fresh-editor/src/main.rs +++ b/crates/fresh-editor/src/main.rs @@ -2130,7 +2130,10 @@ fn list_grammars_command() -> AnyhowResult<()> { let dir_context = fresh::config_io::DirectoryContext::from_system()?; let config_dir = dir_context.config_dir.clone(); let registry = fresh::primitives::grammar::GrammarRegistry::for_editor(config_dir); - let grammars = registry.available_grammar_info(); + // Load the user config so tree-sitter-only languages (e.g. TypeScript) + // still appear even though syntect has no grammar for them. + let config = fresh::config::Config::load_with_layers(&dir_context, &std::env::current_dir()?); + let grammars = registry.available_grammar_info_with_languages(&config.languages); if grammars.is_empty() { println!("No grammars available."); diff --git a/crates/fresh-editor/src/primitives/detected_language.rs b/crates/fresh-editor/src/primitives/detected_language.rs index be3874395..9b4a79922 100644 --- a/crates/fresh-editor/src/primitives/detected_language.rs +++ b/crates/fresh-editor/src/primitives/detected_language.rs @@ -141,21 +141,22 @@ impl DetectedLanguage { registry: &GrammarRegistry, languages: &HashMap, ) -> Option { - if registry.find_syntax_by_name(name).is_some() { - let ts_language = Language::from_name(name); - let highlighter = HighlightEngine::for_syntax_name(name, registry, ts_language); - // Resolve the canonical language ID from config (e.g., "Rust" → "rust"). - let language_id = - resolve_language_id(name, registry, languages).unwrap_or_else(|| name.to_string()); - Some(Self { - name: language_id, - display_name: name.to_string(), - highlighter, - ts_language, - }) - } else { - None + let ts_language = Language::from_name(name); + // Accept the selection if EITHER syntect has a grammar by this name + // OR tree-sitter does. Bailing on syntect-only lookup skipped + // tree-sitter-only languages like TypeScript. + if registry.find_syntax_by_name(name).is_none() && ts_language.is_none() { + return None; } + let highlighter = HighlightEngine::for_syntax_name(name, registry, ts_language); + let language_id = + resolve_language_id(name, registry, languages).unwrap_or_else(|| name.to_string()); + Some(Self { + name: language_id, + display_name: name.to_string(), + highlighter, + ts_language, + }) } /// Create a DetectedLanguage for a user-configured language that has no diff --git a/crates/fresh-editor/src/primitives/grammar/types.rs b/crates/fresh-editor/src/primitives/grammar/types.rs index 61c1f2a90..9cca7552c 100644 --- a/crates/fresh-editor/src/primitives/grammar/types.rs +++ b/crates/fresh-editor/src/primitives/grammar/types.rs @@ -1016,6 +1016,48 @@ impl GrammarRegistry { .collect() } + /// Like `available_grammar_info` but also merges in languages that only + /// have a tree-sitter grammar (no syntect definition). Those languages + /// are selectable from the language palette and can highlight a buffer, + /// but wouldn't show up if we only listed syntect syntaxes — e.g. + /// TypeScript is tree-sitter-only. + pub fn available_grammar_info_with_languages( + &self, + languages: &HashMap, + ) -> Vec { + let mut result = self.available_grammar_info(); + let existing: std::collections::HashSet = + result.iter().map(|g| g.name.to_lowercase()).collect(); + + for (lang_id, lang_cfg) in languages { + let grammar = if lang_cfg.grammar.is_empty() { + lang_id.as_str() + } else { + lang_cfg.grammar.as_str() + }; + // Resolve to a tree-sitter language; skip if neither syntect nor + // tree-sitter knows this grammar (there's nothing to highlight). + let Some(ts_lang) = fresh_languages::Language::from_name(grammar) + .or_else(|| fresh_languages::Language::from_id(lang_id)) + else { + continue; + }; + let display_name = ts_lang.display_name().to_string(); + if existing.contains(&display_name.to_lowercase()) { + continue; + } + result.push(GrammarInfo { + name: display_name, + source: GrammarSource::BuiltIn, + file_extensions: lang_cfg.extensions.clone(), + short_name: None, + }); + } + + result.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + result + } + /// List all available grammars with provenance information. /// /// Returns a sorted list of `GrammarInfo` entries. Each entry includes diff --git a/crates/fresh-editor/src/primitives/highlight_engine.rs b/crates/fresh-editor/src/primitives/highlight_engine.rs index fde388a42..856326589 100644 --- a/crates/fresh-editor/src/primitives/highlight_engine.rs +++ b/crates/fresh-editor/src/primitives/highlight_engine.rs @@ -1104,6 +1104,15 @@ impl HighlightEngine { } } + // No TextMate grammar — fall back to tree-sitter the same way + // `for_file` does, so "set language TypeScript" on a non-.ts buffer + // still gets highlighted (syntect ships no TypeScript grammar). + if let Some(lang) = ts_language { + if let Ok(highlighter) = Highlighter::new(lang) { + return Self::TreeSitter(Box::new(highlighter)); + } + } + Self::None } From 78822a51d806c5b09cf12a39a82e01a57474aad8 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 00:38:33 +0300 Subject: [PATCH 37/42] fix(close_buffer): don't shadow panel-buffer cursor in host split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fresh-editor/src/app/buffer_management.rs | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 7094a0d51..d2307f795 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -2372,24 +2372,19 @@ impl Editor { }; let replacement_buffer = match replacement_target { Some(crate::view::split::TabTarget::Buffer(bid)) => bid, - Some(crate::view::split::TabTarget::Group(group_leaf)) => { - // The group's inner panel buffer serves as the split leaf's - // underlying buffer. The group takes over the active target - // via activate_group_tab below, so this isn't user-visible. - self.grouped_subtrees - .get(&group_leaf) - .and_then(|node| { - if let crate::view::split::SplitNode::Grouped { - active_inner_leaf, .. - } = node - { - self.split_view_states - .get(active_inner_leaf) - .map(|vs| vs.active_buffer) - } else { - None - } - }) + Some(crate::view::split::TabTarget::Group(_group_leaf)) => { + // The host split's active_buffer is a housekeeping fiction + // when the active tab is a group — the real cursor lives in + // the group's inner panel split, and activate_group_tab below + // sets the focus marker. Pick a buffer already keyed in the + // host split so switch_buffer does not auto-insert a fresh + // BufferViewState for a panel buffer; that shadow entry + // (cursor=0, never updated) would later collide with the + // panel's authoritative state and make plugin cursor lookups + // non-deterministic. + self.split_view_states + .get(&active_split) + .and_then(|vs| vs.keyed_states.keys().find(|&&bid| bid != id).copied()) .unwrap_or_else(|| fallback_buffer.unwrap_or_else(|| self.new_buffer())) } None => fallback_buffer.unwrap_or_else(|| self.new_buffer()), @@ -2402,19 +2397,6 @@ impl Editor { // looking at — otherwise the current active buffer stays. if closing_active { self.set_active_buffer(replacement_buffer); - - // When the replacement is a group's hidden inner panel buffer, - // undo the side effects of set_active_buffer adding it to the - // host split's tab list and focus history. - if return_to_group.is_some() { - if let Some(vs) = self.split_view_states.get_mut(&active_split) { - use crate::view::split::TabTarget; - vs.open_buffers - .retain(|t| *t != TabTarget::Buffer(replacement_buffer)); - vs.focus_history - .retain(|t| *t != TabTarget::Buffer(replacement_buffer)); - } - } } // Update all splits that are showing this buffer to show the replacement From 53ac87366abf18f83a463ed4f5dc4033f9826b71 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 00:41:11 +0300 Subject: [PATCH 38/42] fix(git_log): q closes the group from any panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/plugins/git_log.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index d3b198189..b1ca0bb8b 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -584,20 +584,9 @@ function git_log_enter(): void { } registerHandler("git_log_enter", git_log_enter); -/** - * q/Escape: closes the entire log group when the log panel is focused, - * otherwise steps back into the log panel (so the user's mental model - * matches the previous "detail is a stacked view on top of the log"). - */ +/** q/Escape: closes the entire log group from any panel. */ function git_log_q(): void { if (state.groupId === null) return; - if (isDetailFocused()) { - editor.focusBufferGroupPanel(state.groupId, "log"); - editor.setStatus( - editor.t("status.log_ready", { count: String(state.commits.length) }) - ); - return; - } git_log_close(); } registerHandler("git_log_q", git_log_q); From 69cf9760fe615b013a19d5a45fe7cc78d10a7e0c Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 01:05:06 +0300 Subject: [PATCH 39/42] fix(buffer_groups): accept clicks on scrollable panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/src/app/input.rs | 16 ++++++++-------- crates/fresh-editor/src/app/mod.rs | 15 ++++++--------- crates/fresh-editor/src/app/mouse_input.rs | 16 +++++----------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/crates/fresh-editor/src/app/input.rs b/crates/fresh-editor/src/app/input.rs index 6d3f06a2d..cb8e083a5 100644 --- a/crates/fresh-editor/src/app/input.rs +++ b/crates/fresh-editor/src/app/input.rs @@ -2676,14 +2676,14 @@ impl Editor { ); } - // Buffers with hidden cursors (buffer-group toolbars/headers/footers) - // aren't interactive targets: focusing them would let arrow keys move - // an invisible cursor and scroll the pinned content. Swallow the click - // after the plugin hook has had a chance to observe it. - if let Some(state) = self.buffers.get(&buffer_id) { - if !state.show_cursors { - return Ok(()); - } + // Fixed buffer-group panels (toolbars/headers/footers) aren't + // interactive targets: focusing them would let arrow keys move an + // invisible cursor and scroll the pinned content. Swallow the click + // after the plugin hook has had a chance to observe it. Scrollable + // group panels still accept the click (focus routes to them) even + // when their cursor is hidden. + if self.is_non_scrollable_buffer(buffer_id) { + return Ok(()); } // Focus this split (handles terminal mode exit, tab state, etc.) diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index d83ca07ef..21d0ea252 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -2589,15 +2589,12 @@ impl Editor { /// /// Use this instead of calling set_active_split directly when switching focus. pub(super) fn focus_split(&mut self, split_id: LeafId, buffer_id: BufferId) { - // Buffer-group panels with hidden cursors (toolbars, headers, footers) - // aren't focus targets: focusing them would route keyboard input at an - // invisible cursor. Plugins can still detect clicks via the mouse_click - // hook, which fires in the click handlers before reaching here. - if self - .buffers - .get(&buffer_id) - .is_some_and(|s| !s.show_cursors) - { + // Fixed buffer-group panels (toolbars, headers, footers) aren't focus + // targets: focusing them would route keyboard input at an invisible + // cursor. Plugins can still detect clicks via the mouse_click hook, + // which fires in the click handlers before reaching here. Scrollable + // panels still receive focus even with a hidden cursor. + if self.is_non_scrollable_buffer(buffer_id) { return; } diff --git a/crates/fresh-editor/src/app/mouse_input.rs b/crates/fresh-editor/src/app/mouse_input.rs index 5d9689ad5..e7da8cc91 100644 --- a/crates/fresh-editor/src/app/mouse_input.rs +++ b/crates/fresh-editor/src/app/mouse_input.rs @@ -1105,12 +1105,10 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; - // Non-interactive panels (hidden cursor) swallow double-click. - if self - .buffers - .get(&buffer_id) - .is_some_and(|s| !s.show_cursors) - { + // Fixed panels (toolbars, headers) are inert — no click focus, + // no selection. Scrollable group panels still accept clicks even + // when their cursor is hidden. + if self.is_non_scrollable_buffer(buffer_id) { return Ok(()); } @@ -1254,11 +1252,7 @@ impl Editor { ) -> AnyhowResult<()> { use crate::model::event::Event; - if self - .buffers - .get(&buffer_id) - .is_some_and(|s| !s.show_cursors) - { + if self.is_non_scrollable_buffer(buffer_id) { return Ok(()); } From 4909fdacf34b9be06893e5948839eb94915bd87f Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 01:05:15 +0300 Subject: [PATCH 40/42] test(git_log): align flaky/stale tests with current behavior - `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) --- crates/fresh-editor/tests/e2e/plugins/git.rs | 62 ++++++-------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/crates/fresh-editor/tests/e2e/plugins/git.rs b/crates/fresh-editor/tests/e2e/plugins/git.rs index 705afd191..f27b6a95e 100644 --- a/crates/fresh-editor/tests/e2e/plugins/git.rs +++ b/crates/fresh-editor/tests/e2e/plugins/git.rs @@ -1104,14 +1104,15 @@ fn test_git_log_show_commit_detail() { println!("Commit detail screen:\n{screen}"); } -/// Test going back from commit detail to git log +/// Pressing `q` while the detail panel has focus closes the whole git-log +/// group. The older behaviour stepped focus back to the log panel first, +/// making close a two-keystroke gesture that surprised users. #[test] -fn test_git_log_back_from_commit_detail() { +fn test_git_log_q_from_detail_closes_group() { let repo = GitTestRepo::new(); repo.setup_typical_project(); repo.setup_git_log_plugin(); - // Change to repo directory so git commands work correctly let original_dir = repo.change_to_repo_dir(); let _guard = DirGuard::new(original_dir); @@ -1123,42 +1124,25 @@ fn test_git_log_back_from_commit_detail() { ) .unwrap(); - // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load - harness - .wait_until(|h| h.screen_to_string().contains("switch pane")) - .unwrap(); - - // Move to commit and show detail - harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap(); - harness.process_async_and_render().unwrap(); - harness - .send_key(KeyCode::Enter, KeyModifiers::NONE) - .unwrap(); - - // Wait for commit detail + // Wait for the detail panel to populate (live-preview of HEAD). harness .wait_until(|h| h.screen_to_string().contains("Author:")) .unwrap(); - let screen_detail = harness.screen_to_string(); - println!("Commit detail:\n{screen_detail}"); + // Move focus into the detail panel. + harness.send_key(KeyCode::Tab, KeyModifiers::NONE).unwrap(); + harness.process_async_and_render().unwrap(); - // Press q to go back to git log + // q from the detail panel should close the entire group: the toolbar + // (and its "switch pane" hint) disappears along with the *Git Log* tab. harness .send_key(KeyCode::Char('q'), KeyModifiers::NONE) .unwrap(); - harness.process_async_and_render().unwrap(); - - // Wait for git log to reappear harness - .wait_until(|h| h.screen_to_string().contains("switch pane")) + .wait_until(|h| !h.screen_to_string().contains("switch pane")) .unwrap(); - - let screen_log = harness.screen_to_string(); - println!("Back to git log:\n{screen_log}"); } /// Test closing git log with q @@ -1300,28 +1284,20 @@ fn test_git_log_open_different_commits_sequentially() { // Trigger git log trigger_git_log(&mut harness); - // Wait for git log to load + // The toolbar renders before `git log` finishes; wait for the actual + // commit rows in the log panel before asserting on them. harness - .wait_until(|h| h.screen_to_string().contains("switch pane")) + .wait_until(|h| { + let s = h.screen_to_string(); + s.contains("THIRD_UNIQUE_COMMIT_CCC") + && s.contains("SECOND_UNIQUE_COMMIT_BBB") + && s.contains("FIRST_UNIQUE_COMMIT_AAA") + }) .unwrap(); let screen_log = harness.screen_to_string(); println!("Git log with commits:\n{screen_log}"); - // Verify all commits are visible - assert!( - screen_log.contains("THIRD_UNIQUE_COMMIT_CCC"), - "Should show third commit" - ); - assert!( - screen_log.contains("SECOND_UNIQUE_COMMIT_BBB"), - "Should show second commit" - ); - assert!( - screen_log.contains("FIRST_UNIQUE_COMMIT_AAA"), - "Should show first commit" - ); - // Initial selection is HEAD (THIRD) — detail panel auto-previews its diff. harness .wait_until(|h| h.screen_to_string().contains("file3.txt")) From 307303f56afde165e8bba292568e5e7c9aef7f80 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 01:11:25 +0300 Subject: [PATCH 41/42] perf(git_log): debounce only the git-show spawn, render highlights immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/fresh-editor/plugins/git_log.ts | 73 +++++++++++++++++++------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/crates/fresh-editor/plugins/git_log.ts b/crates/fresh-editor/plugins/git_log.ts index b1ca0bb8b..77f61fa54 100644 --- a/crates/fresh-editor/plugins/git_log.ts +++ b/crates/fresh-editor/plugins/git_log.ts @@ -346,38 +346,64 @@ function renderDetailForCommit(commit: GitCommit, showOutput: string): void { } /** - * Fetch + render the detail panel for the selected commit. Multiple rapid - * calls can overlap; we tag each call with an id and only render the most - * recent one so the user's final selection always wins. + * Synchronous detail refresh: render from cache if we have it, otherwise + * a "loading…" placeholder. Never spawns git. Called immediately on every + * selection change so the user sees instant feedback even while the real + * `git show` is debounced. + * + * Returns the commit that needs fetching (cache miss) or null (cache hit + * or no commit selected) so the caller can decide whether to spawn. */ -async function refreshDetail(): Promise { - if (state.groupId === null) return; +function refreshDetailImmediate(): GitCommit | null { + if (state.groupId === null) return null; if (state.commits.length === 0) { renderDetailPlaceholder(editor.t("status.no_commits")); - return; + return null; } const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1)); const commit = state.commits[idx]; - if (!commit) return; + if (!commit) return null; - // Cache hit — render immediately, no git invocation. if (state.detailCache && state.detailCache.hash === commit.hash) { renderDetailForCommit(commit, state.detailCache.output); - return; + return null; } - const myId = ++state.pendingDetailId; renderDetailPlaceholder( editor.t("status.loading_commit", { hash: commit.shortHash }) ); + return commit; +} + +/** + * Spawn `git show` for `commit` and render the result. Tagged with + * `pendingDetailId` so a newer selection supersedes in-flight fetches. + */ +async function fetchAndRenderDetail(commit: GitCommit): Promise { + const myId = ++state.pendingDetailId; const output = await fetchCommitShow(editor, commit.hash); - // Discard stale result if the user moved on. if (myId !== state.pendingDetailId) return; if (state.groupId === null) return; state.detailCache = { hash: commit.hash, output }; + // Only render if the current selection is still this commit — a rapid + // Up/Down burst might have moved on before we got here. + const currentIdx = Math.max( + 0, + Math.min(state.selectedIndex, state.commits.length - 1) + ); + if (state.commits[currentIdx]?.hash !== commit.hash) return; renderDetailForCommit(commit, output); } +/** + * Combined synchronous + asynchronous refresh used by open/refresh paths + * where there's no burst of events to collapse. + */ +async function refreshDetail(): Promise { + const pending = refreshDetailImmediate(); + if (pending) await fetchAndRenderDetail(pending); +} + // ============================================================================= // Selection tracking — keeps `state.selectedIndex` in sync with the log // panel's native cursor so the highlight and detail stay consistent. @@ -697,16 +723,12 @@ async function on_git_log_cursor_moved(data: { if (idx === state.selectedIndex) return; state.selectedIndex = idx; - // Debounce: bump the token, wait a beat, bail if a newer event has - // arrived. The log re-render and `git show` are both expensive; a burst - // of cursor events (held j/k, PageDown) must collapse to one render. - const myId = ++state.pendingCursorMoveId; - await editor.delay(CURSOR_DEBOUNCE_MS); - if (myId !== state.pendingCursorMoveId) return; - if (!state.isOpen) return; - + // Immediate feedback: update the log panel's selection highlight and + // either show the cached detail or a "loading" placeholder. Only the + // actual `git show` spawn is debounced below, so a burst of j/k events + // still feels responsive even though we collapse the fetches into one. renderLog(); - refreshDetail(); + const pending = refreshDetailImmediate(); const commit = state.commits[state.selectedIndex]; if (commit) { @@ -717,6 +739,17 @@ async function on_git_log_cursor_moved(data: { }) ); } + + if (!pending) return; + + // Debounce: bump the token, wait a beat, bail if a newer event has + // arrived. `git show` is expensive; a burst of cursor events (held + // j/k, PageDown) must collapse to one spawn. + const myId = ++state.pendingCursorMoveId; + await editor.delay(CURSOR_DEBOUNCE_MS); + if (myId !== state.pendingCursorMoveId) return; + if (!state.isOpen) return; + await fetchAndRenderDetail(pending); } registerHandler("on_git_log_cursor_moved", on_git_log_cursor_moved); From 798abd53cb52488bc99fe65fc455d7bdad991d7e Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Wed, 15 Apr 2026 01:53:10 +0300 Subject: [PATCH 42/42] fix(close_buffer): activate group tab without spawning a phantom [No Name] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fresh-editor/src/app/buffer_management.rs | 113 ++++++++++++------ crates/fresh-editor/src/app/mod.rs | 24 ++-- 2 files changed, 92 insertions(+), 45 deletions(-) diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index d2307f795..f9b999523 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -2343,60 +2343,99 @@ impl Editor { }) }); - // Fall back: any visible buffer in the split, then any visible buffer at all. - let fallback_buffer: Option = if replacement_target.is_none() { - self.buffers - .keys() - .find(|&&bid| { - bid != id - && !self - .buffer_metadata - .get(&bid) - .map(|m| m.hidden_from_tabs) - .unwrap_or(false) - }) - .copied() - } else { - None - }; + // Any visible buffer other than the one being closed. Used as the + // general fallback (no LRU target or LRU points at a gone group). + let fallback_buffer: Option = self + .buffers + .keys() + .find(|&&bid| { + bid != id + && !self + .buffer_metadata + .get(&bid) + .map(|m| m.hidden_from_tabs) + .unwrap_or(false) + }) + .copied(); // Capture before the replacement computation — new_buffer() has the // side effect of calling set_active_buffer which changes active_buffer(). let closing_active = self.active_buffer() == id; - // Determine what to do: activate a group, switch to a buffer, or - // create a new empty buffer as last resort. + // Pick the BufferId that becomes the host split's `active_buffer`. + // When `return_to_group` is set, `active_buffer` is a housekeeping + // fiction — nothing renders it — so any existing buffer works; we + // just need to avoid synthesizing a phantom `[No Name]` when a real + // option exists. A synthetic buffer fires only when the editor has + // literally no other buffer left. let return_to_group = match replacement_target { Some(crate::view::split::TabTarget::Group(leaf)) => Some(leaf), _ => None, }; - let replacement_buffer = match replacement_target { - Some(crate::view::split::TabTarget::Buffer(bid)) => bid, - Some(crate::view::split::TabTarget::Group(_group_leaf)) => { - // The host split's active_buffer is a housekeeping fiction - // when the active tab is a group — the real cursor lives in - // the group's inner panel split, and activate_group_tab below - // sets the focus marker. Pick a buffer already keyed in the - // host split so switch_buffer does not auto-insert a fresh - // BufferViewState for a panel buffer; that shadow entry - // (cursor=0, never updated) would later collide with the - // panel's authoritative state and make plugin cursor lookups - // non-deterministic. - self.split_view_states - .get(&active_split) - .and_then(|vs| vs.keyed_states.keys().find(|&&bid| bid != id).copied()) - .unwrap_or_else(|| fallback_buffer.unwrap_or_else(|| self.new_buffer())) - } - None => fallback_buffer.unwrap_or_else(|| self.new_buffer()), + + let direct_replacement = match replacement_target { + Some(crate::view::split::TabTarget::Buffer(bid)) => Some(bid), + _ => None, }; - let created_empty_buffer = replacement_target.is_none() && fallback_buffer.is_none(); + // Prefer a buffer already keyed in the host split: `switch_buffer` + // inserts a default BufferViewState for any new active_buffer, which + // for hidden panel buffers becomes a shadow entry (cursor=0) that + // the plugin-state snapshot could non-deterministically prefer over + // the panel split's authoritative copy. Picking something already + // keyed sidesteps that insert. (We clean up after the fact if a + // shadow does get created — see below.) + let already_keyed = return_to_group.and_then(|_| { + self.split_view_states + .get(&active_split)? + .keyed_states + .keys() + .find(|&&bid| bid != id) + .copied() + }); + + // Absolute last-resort pool for the Group case: any buffer at all, + // including hidden panel ones. The shadow cleanup below keeps + // those invisible. + let any_remaining = + return_to_group.and_then(|_| self.buffers.keys().copied().find(|&bid| bid != id)); + + let (replacement_buffer, created_empty_buffer) = match direct_replacement + .or(already_keyed) + .or(fallback_buffer) + .or(any_remaining) + { + Some(bid) => (bid, false), + None => (self.new_buffer(), true), + }; // Switch to replacement buffer BEFORE updating splits. // Only needed when the closing buffer is the one the user is // looking at — otherwise the current active buffer stays. if closing_active { self.set_active_buffer(replacement_buffer); + + // If we landed on a hidden panel buffer to fill the Group-case + // housekeeping slot, scrub the *visible* side effects + // (`open_buffers`, `focus_history`) so the panel buffer doesn't + // appear as a tab. The `keyed_states` entry `switch_buffer` + // inserted has to stay — `active_state()` requires + // `active_buffer ∈ keyed_states` — but it's harmless as long as + // the plugin-snapshot lookup skips it; see + // `snapshot_source_split` in `update_plugin_state_snapshot`. + let hidden = self + .buffer_metadata + .get(&replacement_buffer) + .is_some_and(|m| m.hidden_from_tabs); + if return_to_group.is_some() && hidden { + use crate::view::split::TabTarget; + if let Some(vs) = self.split_view_states.get_mut(&active_split) { + vs.open_buffers + .retain(|t| *t != TabTarget::Buffer(replacement_buffer)); + vs.focus_history + .retain(|t| *t != TabTarget::Buffer(replacement_buffer)); + } + } } // Update all splits that are showing this buffer to show the replacement diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 21d0ea252..afbe4137f 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -5475,14 +5475,22 @@ impl Editor { }; snapshot.buffer_saved_diffs.insert(*buffer_id, diff); - // Panel buffers live in exactly one split's keyed_states - // (enforced at group creation); regular buffers live in the - // split that has them open. Either way, the first keyed_states - // hit is the only hit. - let source_split = self - .split_view_states - .iter() - .find(|(_, vs)| vs.keyed_states.contains_key(buffer_id)); + // Regular buffers live in exactly one split's keyed_states. + // Panel (hidden) buffers natively live inside a group's inner + // split — but the close-buffer path can leave a *shadow* + // entry in the group's host split (from `switch_buffer`'s + // auto-insert, kept to preserve the + // `active_buffer ∈ keyed_states` invariant). For hidden + // buffers we therefore skip group-host splits and pick the + // inner split, which is the authoritative home. + let is_hidden = self + .buffer_metadata + .get(buffer_id) + .is_some_and(|m| m.hidden_from_tabs); + let source_split = self.split_view_states.iter().find(|(split_id, vs)| { + vs.keyed_states.contains_key(buffer_id) + && !(is_hidden && self.grouped_subtrees.contains_key(split_id)) + }); let cursor_pos = source_split .and_then(|(_, vs)| vs.buffer_state(*buffer_id)) .map(|bs| bs.cursors.primary().position)