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/filter-coalesced-tab-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Text typed in the same input chunk as Tab now fills the file filter instead of triggering global shortcuts.
18 changes: 18 additions & 0 deletions packages/hunk/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,22 @@ export function App({
[focusFiles, jumpToFile, review.revealLine, review.selectVisibleHunk],
);

/** Report whether the host filter prompt owns typing, read live from the store. */
const isFilterPromptOpen = useCallback(() => {
const promptId = filterPromptIdRef.current;
return promptId !== null && statusLineStore.getSnapshot().prompt?.id === promptId;
}, [statusLineStore]);

/** Append printable input to the filter prompt's value before its input has mounted. */
const queueFilterInput = useCallback(
(text: string) => {
const prompt = statusLineStore.getSnapshot().prompt;
if (!prompt || prompt.id !== filterPromptIdRef.current) return;
statusLineStore.updatePromptValue(prompt.id, prompt.value + text);
},
[statusLineStore],
);

/** Toggle keyboard focus between the file list and the file filter. */
const toggleFocusArea = useCallback(() => {
if (filterPromptIdRef.current !== null) {
Expand Down Expand Up @@ -1526,6 +1542,8 @@ export function App({
exitKeyboardMode,
sendKeyboardModeKey,
focusArea,
isFilterPromptOpen,
queueFilterInput,
promptActive: statusLineState.prompt !== null,
moveMenuItem,
moveThemeSelector,
Expand Down
34 changes: 34 additions & 0 deletions packages/hunk/src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2762,6 +2762,40 @@ describe("App interactions", () => {
}
});

test("typing in the same input chunk as Tab fills the filter instead of running shortcuts", async () => {
const setup = await testRender(<AppHost bootstrap={createBootstrap()} />, {
width: 240,
height: 24,
});

try {
await flush(setup);

// Tab and the text arrive together, before any render can mount the filter input;
// `t` would otherwise open the theme selector.
await act(async () => {
await setup.mockInput.typeText("\ttzz");
});
await flush(setup);

const frame = setup.captureCharFrame();
expect(frame).toContain("filter: tzz");
expect(frame).toContain("No files match the current filter.");
expect(frame).not.toContain("Theme");

// Once mounted, the input continues after the held text.
await act(async () => {
await setup.mockInput.typeText("x");
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("filter: tzzx");
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("filtering away the selected file reselects the first visible match", async () => {
const setup = await testRender(<AppHost bootstrap={createBootstrap()} />, {
width: 240,
Expand Down
21 changes: 20 additions & 1 deletion packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface UseAppKeyboardShortcutsOptions {
/** Offer one key to the active session keyboard mode. */
sendKeyboardModeKey: (key: ExtensionKeyEvent) => ExtensionKeyboardModeKeyResult;
focusArea: FocusArea;
/** Whether the host filter prompt owns typing, including before its input has rendered. */
isFilterPromptOpen: () => boolean;
/** Append printable input to the filter prompt while its input is still mounting. */
queueFilterInput: (text: string) => void;
/** Whether a status-line prompt (the host filter or an extension's) currently owns typing. */
promptActive: boolean;
moveMenuItem: (delta: number) => void;
Expand Down Expand Up @@ -134,6 +138,8 @@ export function useAppKeyboardShortcuts({
exitKeyboardMode,
sendKeyboardModeKey,
focusArea,
isFilterPromptOpen,
queueFilterInput,
promptActive,
moveMenuItem,
moveThemeSelector,
Expand Down Expand Up @@ -172,6 +178,8 @@ export function useAppKeyboardShortcuts({
const flushDraftInputRef = useRef(flushDraftInput);
const exitKeyboardModeRef = useRef(exitKeyboardMode);
const sendKeyboardModeKeyRef = useRef(sendKeyboardModeKey);
const isFilterPromptOpenRef = useRef(isFilterPromptOpen);
const queueFilterInputRef = useRef(queueFilterInput);
// These three close over live dialog state (the highlighted option, the typed
// text), so they are read through refs rather than captured once.
const acceptExtensionDialogRef = useRef(acceptExtensionDialog);
Expand All @@ -198,6 +206,8 @@ export function useAppKeyboardShortcuts({
flushDraftInputRef.current = flushDraftInput;
exitKeyboardModeRef.current = exitKeyboardMode;
sendKeyboardModeKeyRef.current = sendKeyboardModeKey;
isFilterPromptOpenRef.current = isFilterPromptOpen;
queueFilterInputRef.current = queueFilterInput;
acceptExtensionDialogRef.current = acceptExtensionDialog;
cancelExtensionDialogRef.current = cancelExtensionDialog;
moveExtensionDialogSelectionRef.current = moveExtensionDialogSelection;
Expand Down Expand Up @@ -486,7 +496,9 @@ export function useAppKeyboardShortcuts({
* and closes second.
*/
const handleFocusedInputShortcut = (key: KeyEvent): KeyOwner => {
if (focusAreaRef.current === "filter") {
// Asked live rather than from the last render: Tab opens the filter synchronously,
// and the rest of the same input chunk must already be its text.
if (isFilterPromptOpenRef.current()) {
// Deliberately no modifier check: Shift+Tab toggles focus exactly like
// Tab, in both its CSI-u and legacy backtab encodings.
if (key.name === "tab") {
Expand All @@ -498,6 +510,13 @@ export function useAppKeyboardShortcuts({
return "mine";
}

// Until a render mounts the input, hold typed text in the prompt value it mounts with.
if (focusAreaRef.current !== "filter") {
const text = printableKeyText(key);
if (text !== undefined) queueFilterInputRef.current(text);
return "mine";
}

// Everything else is the filter's text.
return "focused";
}
Expand Down
28 changes: 28 additions & 0 deletions test/pty/chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,34 @@ describe("PTY chrome", () => {
}
});

test("text coalesced with Tab fills the filter instead of running shortcuts", async () => {
const fixture = harness.createTwoFileRepoFixture();
const session = await harness.launchHunk({
args: ["diff", "--mode", "split"],
cwd: fixture.dir,
cols: 220,
rows: 24,
});

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

// One chunk, as a paste or a slow remote terminal delivers it; `t` is the theme shortcut.
session.writeRaw("\tbeta");
const filtered = await harness.waitForSnapshot(
session,
(text) => text.includes("filter: beta") && !text.includes("alpha.ts"),
5_000,
);

expect(filtered).toContain("betaValue");
expect(filtered).not.toContain("Theme selector");
} finally {
session.close();
}
});

test("a remapped slash focuses the filter and narrows the visible review stream", async () => {
// `/` ships on content search; the documented one-liner hands it back to the filter.
const configHome = harness.createIsolatedConfigHome();
Expand Down