Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/bright-ranges-comment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add persistent mouse and keyboard diff selections with explicit Comment, Copy, and Clear actions, including multiline review-note anchors.
6 changes: 6 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Rules worth knowing:
- Two entries claiming one chord is a conflict: the first in the file wins and
the session reports the other. Unknown command ids and unusable chords are
reported the same way, and the rest of the table still applies.
- **Escape clears an active visual selection contextually** after overlays and
extension modes have had their normal ownership. Without a selection, Escape
remains available to extension commands because clear-selection is unbound.

Chords are `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` joined with `+`
around a base key: a character (`"y"`, `"["`), an uppercase letter for its
Expand Down Expand Up @@ -55,6 +58,8 @@ The built-in commands and the keys they ship with:
| `hunk.review.alignCurrentLineBottom` | Align current line to viewport bottom | _(none)_ |
| `hunk.review.alignCurrentLineCenter` | Center current line in viewport | _(none)_ |
| `hunk.review.alignCurrentLineTop` | Align current line to viewport top | _(none)_ |
| `hunk.review.clearSelection` | Clear the active visual selection | _(none)_ |
| `hunk.review.copySelection` | Copy the active visual selection | `y` |
| `hunk.review.editActiveNote` | Edit the active review note | `E` |
| `hunk.review.editSelectedFile` | Open the selected file in your editor | `e` |
| `hunk.review.focusFilter` | Focus the file filter | `/` |
Expand All @@ -76,6 +81,7 @@ The built-in commands and the keys they ship with:
| `hunk.review.scrollCodeLeft` | Scroll code left (shifted scrolls fast) | `left`, `shift+left` |
| `hunk.review.scrollCodeRight` | Scroll code right (shifted scrolls fast) | `right`, `shift+right` |
| `hunk.review.startNote` | Add a review note | `c` |
| `hunk.review.startVisualSelection` | Start visual line selection | `v` |
| `hunk.review.stepDown` | Scroll down one row | `down`, `j` |
| `hunk.review.stepUp` | Scroll up one row | `up`, `k` |
| `hunk.review.toggleHunkGap` | Expand or collapse the selected context | `z` |
Expand Down
2 changes: 1 addition & 1 deletion examples/extensions/review-triage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Or copy the directory to your Hunk extensions directory and keep its `package.js

## Use

Open **Extensions → Toggle review triage** (`y`). The right pane lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Center current review line**, **Set review focus…**, and **Clear triage decisions** are menu-only commands.
Open **Extensions → Toggle review triage** (`Y`). The right pane lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Center current review line**, **Set review focus…**, and **Clear triage decisions** are menu-only commands.

The board intentionally keeps state only for the running Hunk session. Reloading reconciles decisions against the newly parsed hunks and drops entries that no longer match, rather than silently transferring a decision to changed code.

Expand Down
2 changes: 1 addition & 1 deletion examples/extensions/review-triage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export default function registerReviewTriage(hunk: HunkExtensionAPI) {
component: ReviewTriagePane,
});

hunk.registerCommand({ id: "toggle", title: "Toggle review triage", key: "y" }, (ctx) =>
hunk.registerCommand({ id: "toggle", title: "Toggle review triage", key: "Y" }, (ctx) =>
ctx.panes.toggle("triage"),
);

Expand Down
163 changes: 162 additions & 1 deletion src/app/session/reviewCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,141 @@ describe("applySessionReviewAction", () => {
expect(result).toMatchObject({ ok: false, code: "file-not-found" });
});

test("accepts only ranges fully covered by visible patch rows", () => {
const { producer, publication, file } = createTestProducer();
const visibleTarget = {
newRange: [1, 3] as const,
preferred: { side: "new" as const, line: 2 },
};

expect(
applySessionReviewAction(
producer,
envelope(
{
type: "notes/start-draft",
fileKey: file.key,
hunkIndex: 0,
target: visibleTarget,
},
publication.generation,
),
).ok,
).toBe(true);
producer.applyIntent(
{ type: "notes/create-user", consumeDraft: true },
{
noteId: "discard",
timestamp: "2026-01-01T00:00:00.000Z",
},
);

const collapsed = applySessionReviewAction(
producer,
envelope(
{
type: "notes/start-draft",
fileKey: file.key,
hunkIndex: 0,
target: { newRange: [3, 17], preferred: { side: "new", line: 3 } },
},
publication.generation,
),
);
expect(collapsed).toMatchObject({ ok: false, code: "invalid-request" });
});

test("rejects a range start whose requested hunk differs from its resolved owner", () => {
const { producer, publication, file } = createTestProducer();

const result = applySessionReviewAction(
producer,
envelope(
{
type: "notes/start-draft",
fileKey: file.key,
hunkIndex: 0,
target: { newRange: [17, 19], preferred: { side: "new", line: 18 } },
},
publication.generation,
),
);

expect(result).toMatchObject({ ok: false, code: "invalid-request" });
expect(producer.getReviewState()!.draftNote).toBeNull();
});

test("requires a save precondition to match the draft's exact range", () => {
const { producer, publication, store, file } = createTestProducer();
const target = { newRange: [1, 3] as const, preferred: { side: "new" as const, line: 2 } };
expect(
applySessionReviewAction(
producer,
envelope(
{ type: "notes/start-draft", fileKey: file.key, hunkIndex: 0, target },
publication.generation,
),
).ok,
).toBe(true);
store.dispatch({ type: "draft/update", body: "exact range" });

const competing = applySessionReviewAction(
producer,
envelope(
{
type: "notes/create-user",
consumeDraft: true,
fileKey: file.key,
hunkIndex: 0,
target: { newRange: [2, 3], preferred: { side: "new", line: 2 } },
},
publication.generation,
),
);
expect(competing).toMatchObject({ ok: false, code: "draft-missing" });
expect(producer.getReviewState()!.draftNote).not.toBeNull();

const identitylessRange = applySessionReviewAction(
producer,
envelope({ type: "notes/create-user", consumeDraft: true, target }, publication.generation),
);
expect(identitylessRange).toMatchObject({ ok: false, code: "invalid-request" });
expect(producer.getReviewState()!.draftNote).not.toBeNull();

const wrongOwner = applySessionReviewAction(
producer,
envelope(
{
type: "notes/create-user",
consumeDraft: true,
fileKey: file.key,
hunkIndex: 1,
target,
},
publication.generation,
),
);
expect(wrongOwner).toMatchObject({ ok: false, code: "draft-missing" });
expect(producer.getReviewState()!.draftNote).not.toBeNull();

expect(
applySessionReviewAction(
producer,
envelope(
{
type: "notes/create-user",
consumeDraft: true,
fileKey: file.key,
hunkIndex: 0,
target,
},
publication.generation,
),
).ok,
).toBe(true);
expect(producer.getReviewState()!.userNotes.at(-1)!.note.anchor.newRange).toEqual([1, 3]);
});

// Intent: B10 — a note on a line inside an expanded gap is expressible remotely, and the
// hunk that ends up owning it is core's answer through the shared anchor path, never one
// this tier recomputed (D3).
Expand Down Expand Up @@ -156,6 +291,10 @@ describe("applySessionReviewAction", () => {
line,
hunkIndex: 1,
});
expect(draft.expandedLineSource).toEqual({
sourceIdentity: file.sourceIdentity,
sourceAttested: true,
});

// Remote composition travels through the same semantic body-update intent as the terminal.
expect(
Expand All @@ -168,6 +307,22 @@ describe("applySessionReviewAction", () => {
).ok,
).toBe(true);

const disguisedRangeSave = applySessionReviewAction(
producer,
envelope(
{
type: "notes/create-user",
consumeDraft: true,
fileKey: file.key,
hunkIndex: 1,
target: { newRange: [line, line], preferred: { side: "new", line } },
},
publication.generation,
),
);
expect(disguisedRangeSave).toMatchObject({ ok: false, code: "draft-missing" });
expect(producer.getReviewState()!.draftNote).not.toBeNull();

// Saving with the same target as a precondition persists the note; its owner hunk is
// the fallback the anchor resolver chose, which is the hunk the reviewer was reading.
const saved = applySessionReviewAction(
Expand Down Expand Up @@ -324,7 +479,13 @@ describe("applySessionReviewAction", () => {
const result = applySessionReviewAction(
producer,
envelope(
{ type: "notes/create-user", consumeDraft: true, target: { side: "new", line: 999 } },
{
type: "notes/create-user",
consumeDraft: true,
fileKey: file.key,
hunkIndex: 0,
target: { side: "new", line: 999 },
},
publication.generation,
),
);
Expand Down
110 changes: 98 additions & 12 deletions src/app/session/reviewCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ import { randomUUID } from "node:crypto";
import type { ReviewProducer } from "../review/producer";
import { classifyReviewPublication } from "../../core/review/generationOrder";
import { resolveReviewExpandedLine } from "../../core/review/expansion";
import { reviewRangeTargetCoverageIssue } from "../../core/review/geometry";
import { requireReviewFile, ReviewIntentPlanningError } from "../../core/review/intents";
import type { ReviewState } from "../../core/review/state";
import type { ReviewFileV1, ReviewLineAddressV1 } from "../../core/review/types";
import type { ReviewDraftNote, ReviewState } from "../../core/review/state";
import type {
ReviewFileV1,
ReviewLineAddressV1,
ReviewLineRange,
ReviewNoteTargetV1,
ReviewRangeTargetV1,
} from "../../core/review/types";
import {
toReviewIntent,
type HunkReviewActionEnvelopeV1,
Expand Down Expand Up @@ -134,6 +141,62 @@ function checkExpandedLine(
);
}

/** Compare inclusive ranges without treating tuple identity as semantic identity. */
function rangesEqual(left: ReviewLineRange | undefined, right: ReviewLineRange | undefined) {
return left === undefined
? right === undefined
: right !== undefined && left[0] === right[0] && left[1] === right[1];
}

/** Return whether a save precondition names the active draft's exact anchor. */
function draftMatchesTarget(draft: ReviewDraftNote, target: ReviewNoteTargetV1) {
const anchor = draft.anchor;
if ("line" in target) {
if (draft.targetKind === "range") return false;
if (!anchor) return draft.side === target.side && draft.line === target.line;
const expectedRange = [target.line, target.line] as const;
return (
anchor.preferred?.side === target.side &&
anchor.preferred.line === target.line &&
rangesEqual(anchor.oldRange, target.side === "old" ? expectedRange : undefined) &&
rangesEqual(anchor.newRange, target.side === "new" ? expectedRange : undefined)
);
}
if (draft.targetKind === "line") return false;
return (
anchor !== undefined &&
rangesEqual(anchor.oldRange, target.oldRange) &&
rangesEqual(anchor.newRange, target.newRange) &&
anchor.preferred?.side === target.preferred.side &&
anchor.preferred.line === target.preferred.line
);
}

/** Reject ranges that name absent rows, collapsed gaps, or an unrelated preferred line. */
function checkRangeTarget(
producer: ReviewProducer,
file: ReviewFileV1,
target: ReviewRangeTargetV1,
): HunkReviewFailureV1 | undefined {
const issue = reviewRangeTargetCoverageIssue(file.hunks, target);
if (!issue) return undefined;
if (issue === "preferred") {
return fail(
producer,
"invalid-request",
`The preferred ${target.preferred.side} line is outside the review range it places.`,
);
}
const range = issue === "old" ? target.oldRange : issue === "new" ? target.newRange : undefined;
return fail(
producer,
"invalid-request",
range
? `The ${issue} range ${range[0]}-${range[1]} includes lines not visible in the current patch.`
: "The review range does not contain any source lines.",
);
}

/**
* Validate everything about one action that needs the current review to be known.
*
Expand All @@ -146,28 +209,51 @@ function checkAgainstReview(
state: ReviewState,
action: HunkReviewActionV1,
): HunkReviewFailureV1 | undefined {
if (action.type === "notes/start-draft") {
if (!action.expandedLineProof || !action.target) {
return undefined;
}
if (action.type === "notes/start-draft" && action.target) {
const file = requireReviewFile(state, action.fileKey);
return checkExpandedLine(producer, file, action.target, action.expandedLineProof);
if (!("line" in action.target)) {
if (action.expandedLineProof) {
return fail(
producer,
"invalid-request",
"A one-line expansion proof cannot attest a range.",
);
}
return checkRangeTarget(producer, file, action.target);
}
return action.expandedLineProof
? checkExpandedLine(producer, file, action.target, action.expandedLineProof)
: undefined;
}

if (action.type === "notes/create-user" && action.target) {
// A stated target is a precondition on the draft being saved, so two surfaces cannot
// silently save each other's work: the draft must still be the one the caller opened.
const draft = state.draftNote;
if (!draft || draft.side !== action.target.side || draft.line !== action.target.line) {
if (
!("line" in action.target) &&
(action.fileKey === undefined || action.hunkIndex === undefined)
) {
return fail(
producer,
"draft-missing",
`No review note draft is open at ${action.target.side} line ${action.target.line}.`,
"invalid-request",
"A range save precondition requires its file and owner hunk.",
);
}
if (!action.expandedLineProof) {
return undefined;
if (
!draft ||
(action.fileKey !== undefined && draft.fileKey !== action.fileKey) ||
(action.hunkIndex !== undefined && draft.hunkIndex !== action.hunkIndex) ||
!draftMatchesTarget(draft, action.target)
) {
return fail(producer, "draft-missing", "No review note draft is open at that exact anchor.");
}
if (!("line" in action.target)) {
return action.expandedLineProof
? fail(producer, "invalid-request", "A one-line expansion proof cannot attest a range.")
: undefined;
}
if (!action.expandedLineProof) return undefined;
const file = requireReviewFile(state, draft.fileKey);
return checkExpandedLine(producer, file, action.target, action.expandedLineProof);
}
Expand Down
Loading
Loading