Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent, type Locator } from "vite-plus/test/browser";

import { render } from "../../../test/render";

vi.mock(import("../../state/session"), async (original) => ({
...(await original()),
useEnvironmentScope: () => true,
useEnvironmentsWithScope: ((environments: ReadonlyArray<{ environmentId: string }>) =>
new Set(environments.map(({ environmentId }) => environmentId))) as never,
readEnvironmentScope: () => true,
}));
vi.mock(import("../../state/use-atom-command"), async (original) => ({
...(await original()),
useAtomCommand: (() => vi.fn()) as never,
}));
vi.mock(import("./SettingsScopeContext"), async (original) => ({
...(await original()),
useSettingsScope: (() => {
const environment = { environmentId: "env", serverConfig: { keybindings: [] } };
return { environment, connectedEnvironments: [environment] };
}) as never,
}));

import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
import { KeybindingsSettingsPanel } from "./KeybindingsSettings";

// The modal dialog's inert layer swallows pointer events in this harness, so drive the DOM.
async function activate(locator: Locator, action: "click" | "focus") {
await expect.element(locator).toBeVisible();
(locator.element() as HTMLElement)[action]();
}

const existingField = page.getByRole("textbox", { name: "Keybinding for Sidebar: Toggle" });
const existingPill = page.getByRole("button", { name: /^Edit shortcut for Sidebar: Toggle:/ });
const newField = page.getByRole("textbox", { name: "Keybinding for new keybinding" });

describe("Esc in a shortcut recorder", () => {
it.each<{
name: string;
field: Locator;
open: () => Promise<void>;
expectReset: () => Promise<void>;
}>([
{
name: "existing binding",
field: existingField,
open: () => activate(existingPill, "click"),
expectReset: async () => {
await expect.element(existingField).not.toBeInTheDocument();
await expect.element(existingPill).toHaveFocus();
await expect.element(page.getByRole("button", { name: "Save" })).not.toBeInTheDocument();
},
},
{
name: "new binding",
field: newField,
open: async () => {
await activate(page.getByRole("button", { name: "Add keybinding" }), "click");
await activate(newField, "focus");
},
expectReset: async () => {
await expect.element(newField).toHaveValue("");
await expect.element(newField).toHaveAttribute("placeholder", "Unassigned");
},
},
])("leaves the $name field without closing Settings", async ({ field, open, expectReset }) => {
const onOpenChange = vi.fn();
await using _screen = await render(
<Dialog open onOpenChange={onOpenChange}>
<DialogPopup>
<DialogTitle>Settings</DialogTitle>
<KeybindingsSettingsPanel />
</DialogPopup>
</Dialog>,
);

await open();
await userEvent.keyboard("{Control>}k{/Control}");
await expect.element(field).not.toHaveValue("");
await userEvent.tab();
await activate(field, "focus");
await expect.element(field).toHaveAttribute("placeholder", "Press shortcut");

await userEvent.keyboard("{Escape}");

expect(onOpenChange).not.toHaveBeenCalled();
expect(document.activeElement?.matches("[data-keybinding-capture]")).toBe(false);
await expectReset();

await userEvent.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything());
});
});
27 changes: 24 additions & 3 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import {
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
Expand Down Expand Up @@ -717,6 +718,7 @@ function useKeybindingRowEditor({
onSave: (input: ServerUpsertKeybindingInput) => void;
}) {
const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft);
const pillRef = useRef<HTMLButtonElement>(null);
const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
const whenDraftExpression = whenAstToExpression(whenDraft);
const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when;
Expand All @@ -739,7 +741,13 @@ function useKeybindingRowEditor({
if (event.key === "Tab" && !isRecording) return;
event.preventDefault();
if (event.key === "Escape") {
setDraft({ keyDraft: row.key, isRecording: false });
// An unhandled Escape closes the settings page.
event.stopPropagation();
const field = event.currentTarget;
flushSync(() => setDraft({ keyDraft: row.key, isRecording: false }));
// The field may unmount for the pill; focus would otherwise drop to the page.
if (pillRef.current) pillRef.current.focus();
else field.blur();
return;
}
const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, {
Expand All @@ -760,6 +768,7 @@ function useKeybindingRowEditor({
setDraft,
save,
captureKeybinding,
pillRef,
};
}

Expand Down Expand Up @@ -790,8 +799,16 @@ function KeybindingKeyControl({
isSaving: boolean;
pillClassName?: string | undefined;
}) {
const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } =
editor;
const {
keyDraft,
isRecording,
isDirty,
isWhenDraftValid,
setDraft,
save,
captureKeybinding,
pillRef,
} = editor;
const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty;

return (
Expand All @@ -807,6 +824,7 @@ function KeybindingKeyControl({
) : null}
{showPill ? (
<button
ref={pillRef}
type="button"
onClick={() => setDraft({ isRecording: true })}
aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`}
Expand Down Expand Up @@ -1051,7 +1069,10 @@ function useNewKeybindingDraft({
if (event.key === "Tab" && !isRecording) return;
event.preventDefault();
if (event.key === "Escape") {
// An unhandled Escape closes the settings page.
event.stopPropagation();
setDraft({ keyDraft: "", isRecording: false });
event.currentTarget.blur();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}
const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, {
Expand Down
Loading