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
5 changes: 5 additions & 0 deletions .changeset/draft-note-focus-transition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Preserve text typed immediately after opening a note editor, including immediate saves, without triggering global shortcuts.
48 changes: 47 additions & 1 deletion packages/hunk/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ export function App({
const [showHelp, setShowHelp] = useState(false);
const [showAgentSkill, setShowAgentSkill] = useState(false);
const [storedFocusArea, setFocusArea] = useState<StoredFocusArea>("files");
// Bumped when printable input is held for a draft editor that has not mounted
// yet, so the flush retries after every commit that could have mounted it.
const [draftInputRevision, setDraftInputRevision] = useState(0);
const { text: sessionNoticeText, show: showSessionNotice } = useTimedNotice(4_000);
// Keep an incompatible-daemon notice until the broker reconnects; timed notices must not clear it.
const [daemonNoticeText, setDaemonNoticeText] = useState<string | null>(null);
Expand Down Expand Up @@ -1231,11 +1234,14 @@ export function App({
blurDraftNote,
cancelDraftNote,
focusDraftNote,
isDraftFocusPending,
onActiveAddNoteAffordanceChange,
queueDraftInput,
saveDraftNote,
startUserNote,
startUserNoteEdit,
startUserNoteReply,
takePendingDraftInput,
updateDraftNote,
} = useUserNoteComposer({
draftNote: review.draftNote,
Expand All @@ -1255,6 +1261,43 @@ export function App({
publishEvent: publishNoteEvent,
});

/**
* Deliver printable input held while the draft editor was still mounting.
*
* Nothing happens until the note editor owns the keyboard: the held input
* belongs at the caret, so it is inserted there and the editor's own
* content-change path keeps the semantic draft and its listeners in step.
*/
const flushPendingDraftInput = useCallback(() => {
if (focusArea !== "note") return;
const editor = renderer.currentFocusedEditor;
if (!editor) return;
const pendingInput = takePendingDraftInput();
if (pendingInput.length > 0) {
editor.editBuffer.insertText(pendingInput);
}
}, [focusArea, renderer, takePendingDraftInput]);

/** Hand the keyboard to the mounted draft editor after delivering held input. */
const focusMountedDraftNote = useCallback(() => {
flushPendingDraftInput();
focusDraftNote();
}, [flushPendingDraftInput, focusDraftNote]);

/** Hold one printable character for the mounting editor, retrying after its commit. */
const queuePendingDraftInput = useCallback(
(text: string) => {
queueDraftInput(text);
setDraftInputRevision((revision) => revision + 1);
},
[queueDraftInput],
);

useEffect(() => {
if (draftInputRevision === 0) return;
flushPendingDraftInput();
}, [draftInputRevision, flushPendingDraftInput]);

const reviewSnapshot = review.store.getSnapshot();
const activeNoteId = selectActiveStoredReviewNote(reviewSnapshot)?.note.id;
const activeEditableNoteId = selectActiveEditableReviewNoteId(reviewSnapshot);
Expand Down Expand Up @@ -1420,6 +1463,9 @@ export function App({
exitFileViewMode,
sendFileViewModeKey,
isKeyboardModeActive,
isDraftFocusPending,
queueDraftInput: queuePendingDraftInput,
flushDraftInput: flushPendingDraftInput,
exitKeyboardMode,
sendKeyboardModeKey,
focusArea,
Expand Down Expand Up @@ -1681,7 +1727,7 @@ export function App({
onUpdateDraftNote={updateDraftNote}
onBlurDraftNote={blurDraftNote}
onCancelDraftNote={cancelDraftNote}
onFocusDraftNote={focusDraftNote}
onFocusDraftNote={focusMountedDraftNote}
onScrollCodeHorizontally={(delta) => {
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
}}
Expand Down
45 changes: 41 additions & 4 deletions packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "../lib/appCommands";
import type { ExtensionDialogRequest } from "../lib/extensionDialogs";
import { toExtensionKeyEvent } from "../lib/extensionKeyEvent";
import { isEscapeKey, isSaveDraftNoteKey } from "../lib/keyboard";
import { isEscapeKey, isSaveDraftNoteKey, printableKeyText } from "../lib/keyboard";
import { routeKeyOwnership, type KeyOwner } from "../lib/keyRouting";
import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys";

Expand Down Expand Up @@ -57,6 +57,12 @@ export interface UseAppKeyboardShortcutsOptions {
sendFileViewModeKey: (key: ExtensionKeyEvent) => ExtensionFileViewModeKeyResult;
/** Whether a session-scoped extension keyboard mode currently owns review keys. */
isKeyboardModeActive: () => boolean;
/** Whether a draft note editor is still mounting and has not taken focus yet. */
isDraftFocusPending: () => boolean;
/** Hold printable input for that draft instead of dispatching it as commands. */
queueDraftInput: (text: string) => void;
/** Deliver held input once the mounted draft editor owns the keyboard. */
flushDraftInput: () => void;
/** Leave the active session keyboard mode. */
exitKeyboardMode: () => void;
/** Offer one key to the active session keyboard mode. */
Expand Down Expand Up @@ -122,6 +128,9 @@ export function useAppKeyboardShortcuts({
exitFileViewMode,
sendFileViewModeKey,
isKeyboardModeActive,
isDraftFocusPending,
queueDraftInput,
flushDraftInput,
exitKeyboardMode,
sendKeyboardModeKey,
focusArea,
Expand Down Expand Up @@ -159,6 +168,9 @@ export function useAppKeyboardShortcuts({
const exitFileViewModeRef = useRef(exitFileViewMode);
const sendFileViewModeKeyRef = useRef(sendFileViewModeKey);
const isKeyboardModeActiveRef = useRef(isKeyboardModeActive);
const isDraftFocusPendingRef = useRef(isDraftFocusPending);
const queueDraftInputRef = useRef(queueDraftInput);
const flushDraftInputRef = useRef(flushDraftInput);
const exitKeyboardModeRef = useRef(exitKeyboardMode);
const sendKeyboardModeKeyRef = useRef(sendKeyboardModeKey);
// These three close over live dialog state (the highlighted option, the typed
Expand All @@ -182,6 +194,9 @@ export function useAppKeyboardShortcuts({
exitFileViewModeRef.current = exitFileViewMode;
sendFileViewModeKeyRef.current = sendFileViewModeKey;
isKeyboardModeActiveRef.current = isKeyboardModeActive;
isDraftFocusPendingRef.current = isDraftFocusPending;
queueDraftInputRef.current = queueDraftInput;
flushDraftInputRef.current = flushDraftInput;
exitKeyboardModeRef.current = exitKeyboardMode;
sendKeyboardModeKeyRef.current = sendKeyboardModeKey;
acceptExtensionDialogRef.current = acceptExtensionDialog;
Expand Down Expand Up @@ -491,13 +506,35 @@ export function useAppKeyboardShortcuts({
return "focused";
}

// React may commit note focus before the windowed editor mounts. Until a
// real editor is focused, this transition owns keys in either focus state.
if (isDraftFocusPendingRef.current() && !renderer.currentFocusedEditor) {
if (isEscapeKey(key)) {
cancelDraftNote();
return "mine";
}
if (isSaveDraftNoteKey(key)) {
saveDraftNote();
return "mine";
}
const text = printableKeyText(key);
if (text !== undefined) queueDraftInputRef.current(text);
return "mine";
}

if (focusAreaRef.current !== "note") {
// Extension panes can mount the same OpenTUI editors Hunk uses. The
// renderer is the live focus authority for those inputs, which do not
// participate in App's host-only focus-area state.
// Extension panes also own editors outside the host focus-area state.
return renderer.currentFocusedEditor ? "focused" : "notMine";
}

// The draft editor owns the keyboard now, so input held for it during the
// transition is delivered first and this key lands after it. One input
// chunk's keys are routed before any render answers, which is why the
// delivery also happens here rather than only after a commit.
if (isDraftFocusPendingRef.current()) {
flushDraftInputRef.current();
}

if (isEscapeKey(key)) {
cancelDraftNote();
return "mine";
Expand Down
45 changes: 45 additions & 0 deletions packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,51 @@ function baseOptions(
}

describe("useUserNoteComposer", () => {
for (const kind of ["create", "edit", "reply"] as const) {
test(`saves buffered ${kind} input before the opening render commits`, async () => {
const openedDraft: DraftReviewNote = {
...draftNote,
body: kind === "edit" ? "existing " : "",
...(kind === "edit" ? { kind: "edit", targetNoteId: savedNote.id } : {}),
...(kind === "reply" ? { parentId: savedNote.id } : {}),
};
let body = openedDraft.body;
const events: Array<{ event: string; payload: unknown }> = [];
const harness = await renderComposer(
baseOptions({
startDraft: () => openedDraft,
startEdit: () => openedDraft,
startReply: () => openedDraft,
updateDraft: (nextBody, id) => {
expect(id).toBe(openedDraft.id);
body = nextBody;
return true;
},
saveDraft: () => (body ? { ...savedNote, summary: body } : null),
publishEvent: (event, payload) => events.push({ event, payload }),
}),
);
try {
await act(async () => {
const composer = harness.composer();
if (kind === "edit") composer.startUserNoteEdit(savedNote.id);
else if (kind === "reply") composer.startUserNoteReply(savedNote.id);
else composer.startUserNote();
composer.queueDraftInput("beta");
composer.saveDraftNote();
});
expect(body).toBe(kind === "edit" ? "betaexisting " : "beta");
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: kind === "edit" ? "note_edited" : "note_created",
payload: { note: { body, draft: false, fileId: openedDraft.fileId } },
});
} finally {
await act(async () => harness.setup.renderer.destroy());
}
});
}

test("prefers explicit targets, then hover, then the enabled keyboard cursor", async () => {
const starts: Parameters<UseUserNoteComposerOptions["startDraft"]>[] = [];
let cursorReads = 0;
Expand Down
Loading
Loading