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
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import {
EnvironmentId,
type KeybindingRule,
type ServerUpsertKeybindingInput,
} from "@t3tools/contracts";
import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings";
import { RegistryProvider } from "@effect/atom-react";
import type { ReactNode } from "react";
import { AsyncResult } from "effect/reactivity";
import * as Cause from "effect/Cause";
import { expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

import { render } from "../../../test/render";
import {
readEnvironmentScope,
useEnvironmentScope,
useEnvironmentsWithScope,
} from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { toastManager } from "../ui/toast";
import { KeybindingCommandControl } from "./KeybindingsSettings";
import { useSettingsScope, useOptionalSettingsScope } from "./SettingsScopeContext";

vi.mock(import("../../state/session"), { spy: true });
vi.mock(import("../../state/use-atom-command"), { spy: true });
vi.mock(import("./SettingsScopeContext"), { spy: true });

function setup(rules: readonly (readonly KeybindingRule[] | null)[] = [[]]) {
const environments = rules.map((bindings, index) => ({
environmentId: EnvironmentId.make(`dictation-${index}`),
label: `Environment ${index}`,
serverConfig:
bindings === null
? null
: {
keybindings: compileResolvedKeybindingsConfig(bindings),
keybindingsConfigPath: null,
availableEditors: [],
},
}));
const scope = {
environment: environments[0] ?? null,
connectedEnvironments: environments,
} as unknown as NonNullable<ReturnType<typeof useOptionalSettingsScope>>;
vi.mocked(useOptionalSettingsScope).mockReturnValue(scope);
vi.mocked(useSettingsScope).mockReturnValue(scope);
vi.mocked(useEnvironmentScope).mockReturnValue(true);
vi.mocked(useEnvironmentsWithScope).mockReturnValue(
new Set(environments.map((environment) => environment.environmentId)),
);
vi.mocked(readEnvironmentScope).mockReturnValue(true);
const save = vi
.fn<
(input: {
environmentId: EnvironmentId;
input: ServerUpsertKeybindingInput;
}) => Promise<AsyncResult.Success<void> | AsyncResult.Failure<void, Error>>
>()
.mockResolvedValue(AsyncResult.success(undefined));
vi.mocked(useAtomCommand).mockReturnValue(save as ReturnType<typeof useAtomCommand>);
return { environments, save, scope };
}

function Control({ children }: { children: ReactNode }) {
return (
<RegistryProvider>
<>
<KeybindingCommandControl command="composer.dictation" />
{children}
</>
</RegistryProvider>
);
}

const dictation: KeybindingRule = {
command: "composer.dictation",
key: "mod+alt+d",
when: "!terminalFocus",
};
const editName = /^Edit shortcut for Composer: Start or Finish Dictation:/;
const recorded = /Mac/i.test(navigator.platform) ? "{Meta>}k{/Meta}" : "{Control>}k{/Control}";

it("replaces the saved rule on each selected environment and keeps its condition", async () => {
const { environments, save, scope } = setup([[dictation], [dictation]]);
await using screen = await render(<KeybindingCommandControl command="composer.dictation" />);
await screen.getByRole("button", { name: editName }).click();
await userEvent.keyboard(recorded);
await expect
.element(screen.getByLabelText("Conflicts with Command Palette: Toggle."))
.toBeVisible();
expect(save).not.toHaveBeenCalled();
await screen.getByRole("button", { name: "Save", exact: true }).click();
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2));
expect(save.mock.calls.map(([input]) => input)).toEqual(
environments.map((environment) => ({
environmentId: environment.environmentId,
input: { command: dictation.command, key: "mod+k", when: dictation.when, replace: dictation },
})),
);
vi.mocked(useOptionalSettingsScope).mockReturnValue({
...scope,
connectedEnvironments: scope.connectedEnvironments.map((environment) => ({
...environment,
serverConfig: {
...environment.serverConfig!,
keybindings: compileResolvedKeybindingsConfig([{ ...dictation, key: "mod+k" }]),
},
})),
});
await screen.rerender(<KeybindingCommandControl command="composer.dictation" />);
await expect
.element(
screen.getByRole("button", {
name: /Edit shortcut for Composer: Start or Finish Dictation: (Ctrl\+K|⌘K)/,
}),
)
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.not.toBeInTheDocument();
});

it("discards an inline shortcut draft on Escape without changing the saved binding", async () => {
const { save } = setup([[dictation]]);
await using screen = await render(<Control>{null}</Control>);
await screen.getByRole("button", { name: editName }).click();
await userEvent.keyboard(recorded);
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeVisible();
await userEvent.keyboard("{Escape}");
await expect.element(screen.getByRole("button", { name: editName })).toHaveFocus();
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.not.toBeInTheDocument();
expect(save).not.toHaveBeenCalled();
});

it.each([
{ name: "Mixed shortcuts", rules: [[dictation], [{ ...dictation, key: "mod+alt+v" }]] },
{ name: "Multiple shortcuts", rules: [[dictation, { ...dictation, key: "mod+alt+v" }]] },
{ name: "Unloaded settings", rules: [[], null] },
])("opens detailed editing for $name", async ({ rules }) => {
const { save } = setup(rules);
await using screen = await render(<Control>{null}</Control>);
await expect
.element(screen.getByRole("link", { name: "Edit in Keybindings" }))
.toHaveAttribute("href", "/settings/keybindings#keybinding-composer.dictation");
expect(save).not.toHaveBeenCalled();
});

it("blocks editing without settings permission and rechecks permission when saving", async () => {
const { save } = setup();
vi.mocked(useEnvironmentsWithScope).mockReturnValue(new Set());
await using screen = await render(
<Control>
<button>Recording mode</button>
</Control>,
);
expect(screen.container.querySelector("[inert]")).not.toBeNull();
await expect.element(screen.getByRole("button", { name: "Recording mode" })).toBeEnabled();
vi.mocked(useEnvironmentsWithScope).mockReturnValue(new Set([EnvironmentId.make("dictation-0")]));
await screen.rerender(<Control key="allowed">{null}</Control>);
await screen.getByRole("button", { name: editName }).click();
await userEvent.keyboard(recorded);
vi.mocked(readEnvironmentScope).mockReturnValue(false);
await screen.getByRole("button", { name: "Save", exact: true }).click();
expect(save).not.toHaveBeenCalled();
});

it("keeps the draft available after a failed save", async () => {
const { save } = setup();
save.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Disconnected"))));
await using screen = await render(<Control>{null}</Control>);
using notification = vi.spyOn(toastManager, "add");
await screen.getByRole("button", { name: editName }).click();
await userEvent.keyboard(recorded);
await screen.getByRole("button", { name: "Save", exact: true }).click();
await vi.waitFor(() => expect(notification).toHaveBeenCalledOnce());
expect(notification).toHaveBeenCalledWith(
expect.objectContaining({ title: "Unable to save keybinding", type: "error" }),
);
await expect
.element(
screen.getByRole("textbox", { name: "Keybinding for Composer: Start or Finish Dictation" }),
)
.toHaveValue("mod+k");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
});
166 changes: 118 additions & 48 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import { useLocation } from "@tanstack/react-router";
import { Link, useLocation } from "@tanstack/react-router";
import {
type KeyboardEvent,
type ReactNode,
Expand Down Expand Up @@ -40,7 +40,7 @@ import { useOpenInPreferredEditor } from "../../editorPreferences";
import { formatShortcutLabel } from "../../keybindings";
import { cn } from "../../lib/utils";
import { serverEnvironment } from "../../state/server";
import { useSettingsScope } from "./SettingsScopeContext";
import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
Expand Down Expand Up @@ -1312,21 +1312,128 @@ function useKeybindingCatalog() {
};
}

function useScopedKeybindingSave(
connectedEnvironments: ReturnType<typeof useSettingsScope>["connectedEnvironments"],
onSaved?: () => void,
) {
const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
const canWriteSettings =
connectedEnvironments.length > 0 &&
connectedEnvironments.every((target) => writableIds.has(target.environmentId));
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
reportFailure: false,
});
const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
const saveKeybinding = (input: ServerUpsertKeybindingInput) => {
if (
connectedEnvironments.length === 0 ||
!connectedEnvironments.every((target) =>
readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
)
)
return;
setSavingCommand(input.command);
const payload: ServerUpsertKeybindingInput = {
command: input.command,
key: input.key.trim(),
...(input.when?.trim() ? { when: input.when.trim() } : {}),
...(input.replace ? { replace: input.replace } : {}),
};
void (async () => {
const results = await Promise.all(
connectedEnvironments.map((target) =>
upsertKeybinding({ environmentId: target.environmentId, input: payload }),
),
);
setSavingCommand(null);
const failed = results.find((result) => result._tag === "Failure");
if (!failed) {
onSaved?.();
return;
}
if (!isAtomCommandInterrupted(failed)) {
const error = squashAtomCommandFailure(failed);
toastManager.add({
title: "Unable to save keybinding",
description: error instanceof Error ? error.message : "The keybinding was not saved.",
type: "error",
});
}
})();
};

return { savingCommand, setSavingCommand, saveKeybinding, canWriteSettings };
}

function InlineKeybindingEditor({
row,
allRows,
isSaving,
onSave,
}: {
row: KeybindingRow;
allRows: readonly KeybindingRow[];
isSaving: boolean;
onSave: (input: ServerUpsertKeybindingInput) => void;
}) {
const editor = useKeybindingRowEditor({ row, allRows, onSave });
return (
<div className="flex items-center justify-end gap-1.5">
<KeybindingConflictWarning labels={editor.conflictLabels} />
<KeybindingKeyControl row={row} editor={editor} isSaving={isSaving} />
</div>
);
}

export function KeybindingCommandControl({ command }: { command: KeybindingCommand }) {
const scope = useOptionalSettingsScope();
const environments = scope?.connectedEnvironments ?? [];
const { savingCommand, saveKeybinding, canWriteSettings } = useScopedKeybindingSave(environments);
const catalogs = environments.map((environment) =>
environment.serverConfig
? buildKeybindingRows(mergeWithDefaultKeybindings(environment.serverConfig.keybindings))
: null,
);
const allRows = catalogs.flatMap((rows) => rows ?? []);
const rows = catalogs[0]?.filter((row) => row.command === command);
const row = rows?.[0];
const simple =
row &&
rows.length === 1 &&
catalogs.every((rows) => {
const matches = rows?.filter((candidate) => candidate.command === command);
return matches?.length === 1 && matches[0]?.id === row.id;
});
if (!scope) return null;
return simple ? (
<div inert={!canWriteSettings}>
<InlineKeybindingEditor
key={`${environments.map((environment) => environment.environmentId).join(",")}:${row.id}`}
row={row}
allRows={allRows}
isSaving={savingCommand === command}
onSave={saveKeybinding}
/>
</div>
) : (
<Button
size="sm"
variant="outline"
render={<Link to="/settings/keybindings" hash={keybindingSearchAnchorId(command)} />}
>
Edit in Keybindings
</Button>
);
}

export function KeybindingsSettingsPanel() {
const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();
const canOpenKeybindingsFile = useEnvironmentScope(
primaryEnvironment?.environmentId ?? null,
AuthOrchestrationOperateScope,
);
const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
const canWriteSettings =
connectedEnvironments.length > 0 &&
connectedEnvironments.every((target) => writableIds.has(target.environmentId));
const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;
const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
reportFailure: false,
});
const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, {
reportFailure: false,
});
Expand All @@ -1336,8 +1443,9 @@ export function KeybindingsSettingsPanel() {
);
const [query, setQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
const [isAddingBinding, setIsAddingBinding] = useState(false);
const { savingCommand, setSavingCommand, saveKeybinding, canWriteSettings } =
useScopedKeybindingSave(connectedEnvironments, () => setIsAddingBinding(false));
const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId);

Expand Down Expand Up @@ -1373,44 +1481,6 @@ export function KeybindingsSettingsPanel() {
})();
};

const saveKeybinding = (input: ServerUpsertKeybindingInput) => {
if (
!primaryEnvironment ||
!connectedEnvironments.every((target) =>
readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
)
)
return;
setSavingCommand(input.command);
const payload: ServerUpsertKeybindingInput = {
command: input.command,
key: input.key.trim(),
...(input.when?.trim() ? { when: input.when.trim() } : {}),
...(input.replace ? { replace: input.replace } : {}),
};
void (async () => {
const results = await Promise.all(
connectedEnvironments.map((target) =>
upsertKeybinding({ environmentId: target.environmentId, input: payload }),
),
);
setSavingCommand(null);
const failed = results.find((result) => result._tag === "Failure");
if (!failed) {
setIsAddingBinding(false);
return;
}
if (!isAtomCommandInterrupted(failed)) {
const error = squashAtomCommandFailure(failed);
toastManager.add({
title: "Unable to save keybinding",
description: error instanceof Error ? error.message : "The keybinding was not saved.",
type: "error",
});
}
})();
};

const removeKeybinding = (row: KeybindingRow) => {
if (
!primaryEnvironment ||
Expand Down
Loading
Loading