Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.17.4

### Patch Changes

- a9b8694: Restore OpenTUI's platform-default renderer threading to improve interactive startup on macOS.
- 67674fa: Rapidly pressing Ctrl+S (or double-clicking Save) while saving a draft note no longer saves the same note twice, and saved note ids stay unique even within one millisecond.

## 0.17.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hunkdiff",
"version": "0.17.3",
"version": "0.17.4",
"description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.",
"keywords": [
"ai",
Expand Down
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,14 @@ async function main() {
>(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
hostClient.start();

// Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux).
const renderer = await createCliRenderer({
stdin: controllingTerminal?.stdin,
stdout: process.stdout,
useMouse: shouldUseMouseForApp({
hasControllingTerminal: Boolean(controllingTerminal),
}),
screenMode: "alternate-screen",
useThread: false,
exitOnCtrlC: false,
openConsoleOnError: true,
onDestroy: () => controllingTerminal?.close(),
Expand Down
56 changes: 55 additions & 1 deletion src/ui/hooks/useReviewController.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import { testRender } from "@opentui/react/test-utils";
import { act, StrictMode, useEffect, useState } from "react";
import { SourceTextTooLargeError } from "../../core/fileSource";
Expand Down Expand Up @@ -543,6 +543,60 @@ describe("useReviewController", () => {
}
});

test("rapid duplicate saves persist exactly one user note with a unique id", async () => {
const { controllerRef, setup } = await renderReviewController([createAlphaFile()]);
const fixedNow = 1_700_000_000_000;
const dateNowSpy = spyOn(Date, "now").mockReturnValue(fixedNow);

try {
await flush(setup);

await act(async () => {
expectValue(controllerRef.current).startUserNote();
expectValue(controllerRef.current).updateDraftNote("Save me once.");
});
await flush(setup);

// Coalesced Ctrl+S key events invoke save twice before the draft-clearing
// state update commits; only the first call may persist a note.
const savedIds: { first?: string; second?: string; followUp?: string } = {};
await act(async () => {
const controller = expectValue(controllerRef.current);
savedIds.first = controller.saveDraftNote()?.id;
savedIds.second = controller.saveDraftNote()?.id;
});
await flush(setup);

expect(savedIds.first).toBe(`user:${fixedNow}-1`);
expect(savedIds.second).toBeUndefined();
expect(expectValue(controllerRef.current).userNotesByFileId.alpha).toHaveLength(1);

// A follow-up draft saved within the same millisecond still gets a unique id.
await act(async () => {
expectValue(controllerRef.current).startUserNote();
});
await flush(setup);
await act(async () => {
expectValue(controllerRef.current).updateDraftNote("Save me too.");
});
await flush(setup);

await act(async () => {
savedIds.followUp = expectValue(controllerRef.current).saveDraftNote()?.id;
});
await flush(setup);

expect(savedIds.followUp).toBe(`user:${fixedNow}-2`);
expect(savedIds.followUp).not.toBe(savedIds.first);
expect(expectValue(controllerRef.current).userNotesByFileId.alpha).toHaveLength(2);
} finally {
dateNowSpy.mockRestore();
await act(async () => {
setup.renderer.destroy();
});
}
});

test("session clear can include human user notes", async () => {
const { controllerRef, setup } = await renderReviewController([createTwoHunkFile()]);

Expand Down
14 changes: 11 additions & 3 deletions src/ui/hooks/useReviewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
);
const [userNotesByFileId, setUserNotesByFileId] = useState<Record<string, UserReviewNote[]>>({});
const [draftNote, setDraftNote] = useState<DraftReviewNote | null>(null);
// Track the last saved draft id so coalesced save key events dedup synchronously
// without waiting for the draft-clearing state update to commit.
const savedDraftIdRef = useRef<string | null>(null);
// Monotonic suffix that keeps `user:*` note ids unique within one millisecond.
const userNoteSequenceRef = useRef(0);
const [expandedGapsByFileId, setExpandedGapsByFileId] = useState<
Record<string, ReadonlySet<string>>
>({});
Expand Down Expand Up @@ -821,6 +826,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
newRange: target.side === "new" ? [target.line, target.line] : undefined,
body: "",
};
savedDraftIdRef.current = null;
setDraftNote(draft);
selectHunk(
file.id,
Expand All @@ -842,9 +848,9 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
setDraftNote(null);
}, []);

/** Persist the active draft into the in-memory user note collection. */
/** Persist the active draft into the in-memory user note collection exactly once. */
const saveDraftNote = useCallback((): UserReviewNote | null => {
if (!draftNote) {
if (!draftNote || savedDraftIdRef.current === draftNote.id) {
return null;
}

Expand All @@ -854,8 +860,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
return null;
}

savedDraftIdRef.current = draftNote.id;

const savedNote: UserReviewNote = {
id: `user:${Date.now()}`,
id: `user:${Date.now()}-${++userNoteSequenceRef.current}`,
source: "user",
filePath: draftNote.filePath,
hunkIndex: draftNote.hunkIndex,
Expand Down
34 changes: 34 additions & 0 deletions test/pty/notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,40 @@ describe("PTY notes", () => {
}
});

test("rapid Ctrl+S presses save a draft note exactly once", async () => {
const fixture = harness.createLongWrapFilePair();
const session = await harness.launchHunk({
args: ["diff", fixture.before, fixture.after, "--mode", "split"],
cols: 120,
rows: 24,
});

try {
await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 });

await session.press("c");
await session.waitForText(/Draft note/, { timeout: 5_000 });
await session.type("Save exactly one note.");
await session.waitForText(/Save exactly one note\./, { timeout: 5_000 });

// Send both Ctrl+S bytes in one PTY write so the second save request runs
// before the draft-clearing state update commits.
session.writeRaw("\x13\x13");
await session.waitIdle();

const saved = await session.waitForText(/Your note/, { timeout: 5_000 });
expect(saved).toContain("Save exactly one note.");

// A duplicated save renders numbered "Your note 1/2" cards; a single save must not.
await sleep(250);
const settled = await session.text({ immediate: true });
expect(settled).not.toContain("Your note 1/");
expect((settled.match(/Your note/g) ?? []).length).toBe(1);
} finally {
session.close();
}
});

test("add-note affordance appears only after mouse movement in a real PTY", async () => {
const fixture = harness.createScrollableFilePair();
const session = await harness.launchHunk({
Expand Down