Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
584e09a
feat(git_log): rewrite around buffer-group + live-preview right panel
claude Apr 14, 2026
45cb9a5
test(blog-showcase): add git log demo for docs/blog
claude Apr 14, 2026
d240620
perf(git_log): debounce cursor_moved + drop huge files from diff
sinelaw Apr 14, 2026
d03df5a
perf(overlay): bulk-add overlays in set_virtual_buffer_content
sinelaw Apr 14, 2026
b33b419
fix(buffer_groups): preserve cursor across set_virtual_buffer_content
sinelaw Apr 14, 2026
92b1e51
feat(git_log): enable line wrap in the commit detail panel
sinelaw Apr 14, 2026
dbb4251
feat(git_log): reflow commit message paragraphs in the detail panel
sinelaw Apr 14, 2026
6aa8110
refactor(git_log): fetch commit metadata, stat, and patch separately
sinelaw Apr 14, 2026
6ff20dd
feat(git_log): move shortcut hints to a sticky top toolbar
sinelaw Apr 14, 2026
0395bf1
fix(git_log): use %x00 placeholder, not literal NUL, in format string
sinelaw Apr 14, 2026
4fff1c6
revert(git_log): drop commit-message reflow
sinelaw Apr 14, 2026
36ea465
fix(buffer_groups): clamp each split's cursor in place on content swap
sinelaw Apr 14, 2026
95ff964
feat(git_log): drop the "Commits:" header row in the log panel
sinelaw Apr 14, 2026
a9a2304
fix(git_log): bind PgUp/PgDn correctly, add Shift+motion for selection
sinelaw Apr 14, 2026
be582f2
feat(plugins): mode option to inherit Normal-context bindings
sinelaw Apr 14, 2026
88be58c
fix(clipboard): active_cursors routes to the focused panel, not the o…
sinelaw Apr 14, 2026
8531851
fix(buffer_groups): preserve focused panel across tab switches
sinelaw Apr 14, 2026
9e53c74
fix(snapshot): cursor_position prefers the split where buffer is active
sinelaw Apr 14, 2026
88f9736
feat(workspace): persist read-only flag across session restore
sinelaw Apr 14, 2026
f1311e4
feat(git_log): reset detail panel cursor on commit switch
sinelaw Apr 14, 2026
cced994
fix(scroll): shift+wheel scrolls horizontally even without scrollbar
sinelaw Apr 14, 2026
e8f4280
fix(git_log): name file-view buffer so syntax highlighting kicks in
sinelaw Apr 14, 2026
4b38b44
fix(git_log): jump cursor to target line when opening file from details
sinelaw Apr 14, 2026
99c477f
fmt
sinelaw Apr 14, 2026
2bff7ad
chore(trace): log cursor snapshot + getTextPropertiesAtCursor
sinelaw Apr 14, 2026
c69c0ac
fix(snapshot): prefer group-panel split for cursor position
sinelaw Apr 14, 2026
3bbb3ae
fix(buffer_groups): don't leave panel buffers in the outer split's ke…
sinelaw Apr 14, 2026
361a857
test(git_log): regression for Enter-after-closing-file-view
sinelaw Apr 14, 2026
90724fe
test(git_log): update e2e tests for headerless + live-preview layout
sinelaw Apr 14, 2026
ce00ab7
i18n fixes for audit_mode
sinelaw Apr 14, 2026
ab28f3e
fix(buffer_groups): make non-scrollable panels truly inert
sinelaw Apr 14, 2026
b071d97
feat(git_log): clickable toolbar buttons
sinelaw Apr 14, 2026
138c387
fix(git_log): reset state when panels close externally
sinelaw Apr 14, 2026
87edee6
feat(git_log): activate existing tab when re-invoked
sinelaw Apr 14, 2026
da05563
fix(tabs): inactive-split active tab invisible on high-contrast theme
sinelaw Apr 14, 2026
884cf8d
fix(grammar): highlight TypeScript from set-language and list it in g…
sinelaw Apr 14, 2026
78822a5
fix(close_buffer): don't shadow panel-buffer cursor in host split
sinelaw Apr 14, 2026
53ac873
fix(git_log): q closes the group from any panel
sinelaw Apr 14, 2026
69cf976
fix(buffer_groups): accept clicks on scrollable panels
sinelaw Apr 14, 2026
4909fda
test(git_log): align flaky/stale tests with current behavior
sinelaw Apr 14, 2026
307303f
perf(git_log): debounce only the git-show spawn, render highlights im…
sinelaw Apr 14, 2026
798abd5
fix(close_buffer): activate group tab without spawning a phantom [No …
sinelaw Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/fresh-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,9 @@
read_only: bool,
/// When true, unbound character keys dispatch as `mode_text_input:<char>`.
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<String>,
},
Expand Down Expand Up @@ -3031,6 +3034,7 @@
bindings,
read_only,
allow_text_input,
inherit_normal_bindings: false,
plugin_name: None,
})
}
Expand Down Expand Up @@ -3857,13 +3861,13 @@
// mutants replace the body with `Ok(())` / `()`, i.e. the side effect
// disappears. One assertion per method ties the side effect down.

fn mk_api() -> (
PluginApi,
std::sync::mpsc::Receiver<PluginCommand>,
Arc<RwLock<HookRegistry>>,
Arc<RwLock<CommandRegistry>>,
Arc<RwLock<EditorStateSnapshot>>,
) {

Check warning on line 3870 in crates/fresh-core/src/api.rs

View workflow job for this annotation

GitHub Actions / clippy

very complex type used. Consider factoring parts into `type` definitions

warning: very complex type used. Consider factoring parts into `type` definitions --> crates/fresh-core/src/api.rs:3864:20 | 3864 | fn mk_api() -> ( | ____________________^ 3865 | | PluginApi, 3866 | | std::sync::mpsc::Receiver<PluginCommand>, 3867 | | Arc<RwLock<HookRegistry>>, 3868 | | Arc<RwLock<CommandRegistry>>, 3869 | | Arc<RwLock<EditorStateSnapshot>>, 3870 | | ) { | |_____^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.92.0/index.html#type_complexity = note: `#[warn(clippy::type_complexity)]` on by default
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
Expand Down Expand Up @@ -3949,7 +3953,7 @@
PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
),
PluginCommand::OpenFileAtLocation { path, line, column }
if path == PathBuf::from("/tmp/x.rs")

Check warning on line 3956 in crates/fresh-core/src/api.rs

View workflow job for this annotation

GitHub Actions / clippy

this creates an owned instance just for comparison

warning: this creates an owned instance just for comparison --> crates/fresh-core/src/api.rs:3956:28 | 3956 | if path == PathBuf::from("/tmp/x.rs") | --------^^^^^^^^^^^^^^^^^^^^^^^^^^ | | | help: try: `path == "/tmp/x.rs"` | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.92.0/index.html#cmp_owned = note: `#[warn(clippy::cmp_owned)]` on by default
&& line == Some(4)
&& column == Some(8)
);
Expand All @@ -3961,7 +3965,7 @@
),
PluginCommand::OpenFileInSplit { split_id, path, line, column }
if split_id == 2
&& path == PathBuf::from("/tmp/y.rs")

Check warning on line 3968 in crates/fresh-core/src/api.rs

View workflow job for this annotation

GitHub Actions / clippy

this creates an owned instance just for comparison

warning: this creates an owned instance just for comparison --> crates/fresh-core/src/api.rs:3968:32 | 3968 | && path == PathBuf::from("/tmp/y.rs") | --------^^^^^^^^^^^^^^^^^^^^^^^^^^ | | | help: try: `path == "/tmp/y.rs"` | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.92.0/index.html#cmp_owned
&& line == Some(5)
&& column.is_none()
);
Expand Down Expand Up @@ -4082,14 +4086,15 @@
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
&& bindings[0].0 == "j"
&& bindings[0].1 == "move_down"
&& read_only
&& !allow_text_input
&& !inherit_normal_bindings
&& plugin_name.is_none()
);

Expand Down
518 changes: 336 additions & 182 deletions crates/fresh-editor/plugins/audit_mode.i18n.json

Large diffs are not rendered by default.

314 changes: 314 additions & 0 deletions crates/fresh-editor/plugins/audit_mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);


Expand Down Expand Up @@ -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<void> {
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<void> {
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<void> {
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);
Expand Down
Loading
Loading