Skip to content
Closed
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
107 changes: 106 additions & 1 deletion apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts";
import {
KeybindingCommand,
KeybindingRule,
KeybindingsConfig,
MAX_KEYBINDINGS_COUNT,
} from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { assertFailure } from "@effect/vitest/utils";
Expand Down Expand Up @@ -454,6 +459,106 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("resets customized keybindings to defaults while keeping script bindings", () =>
Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
yield* writeKeybindingsConfig(keybindingsConfigPath, [
{ key: "mod+j", command: "terminal.toggle" },
{ key: "mod+r", command: "script.run-tests.run" },
]);

const resolved = yield* Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
return yield* keybindings.resetKeybindingRulesToDefaults;
});

const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
const persistedView = persisted.map(({ key, command }) => ({ key, command }));
assert.deepEqual(persistedView, [
...Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })),
{ key: "mod+r", command: "script.run-tests.run" },
]);
assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run"));
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("resets a malformed keybindings config to defaults", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
yield* fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true });
yield* fs.writeFileString(keybindingsConfigPath, "{ not an array");

yield* Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
return yield* keybindings.resetKeybindingRulesToDefaults;
});

const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
const persistedView = persisted.map(({ key, command }) => ({ key, command }));
assert.deepEqual(
persistedView,
Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })),
);
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("refuses to reset a keybindings config it cannot read", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
// A directory at the config path fails the read the same way a transient
// filesystem error would, without overwriting rules that are still there.
yield* fs.makeDirectory(keybindingsConfigPath, { recursive: true });

const result = yield* Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
return yield* keybindings.resetKeybindingRulesToDefaults;
}).pipe(toDetailResult);

assertFailure(result, "failed to read keybindings config");
assert.isTrue((yield* fs.stat(keybindingsConfigPath)).type === "Directory");
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("keeps every default when preserved script bindings exceed the max count", () =>
Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
const scriptBudget = MAX_KEYBINDINGS_COUNT - Keybindings.DEFAULT_KEYBINDINGS.length;
const scriptRules = Array.from(
{ length: scriptBudget + 5 },
(_, index): KeybindingRule => ({
key: "mod+r",
command: `script.s${index}.run`,
}),
);
yield* writeKeybindingsConfig(keybindingsConfigPath, scriptRules);

yield* Effect.gen(function* () {
const keybindings = yield* Keybindings.Keybindings;
return yield* keybindings.resetKeybindingRulesToDefaults;
});

const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
const persistedDefaults = persisted
.filter((rule) => !String(rule.command).startsWith("script."))
.map(({ key, command }) => ({ key, command }));
assert.deepEqual(
persistedDefaults,
Keybindings.DEFAULT_KEYBINDINGS.map(({ key, command }) => ({ key, command })),
);
assert.equal(persisted.length, MAX_KEYBINDINGS_COUNT);
// Later rules win, so the surviving scripts must be the trailing ones.
assert.deepEqual(
persisted
.filter((rule) => String(rule.command).startsWith("script."))
.map((rule) => rule.command),
scriptRules.slice(5).map((rule) => rule.command),
);
}).pipe(Effect.provide(makeKeybindingsLayer())),
);

it.effect("refuses to overwrite malformed keybindings config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool
);
}

function isScriptKeybindingRule(rule: KeybindingRule): boolean {
return String(rule.command).startsWith("script.");
}

function keybindingShortcutContext(rule: KeybindingRule): string | null {
const parsed = parseKeybindingShortcut(rule.key);
if (!parsed) return null;
Expand Down Expand Up @@ -284,6 +288,20 @@ export class Keybindings extends Context.Service<
readonly removeKeybindingRule: (
input: ServerRemoveKeybindingInput,
) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;

/**
* Restore every default keybinding, discarding customizations.
*
* Project script bindings are kept — they have no default to restore, so
* dropping them would delete shortcuts the reset cannot give back. A config
* that fails to parse is replaced outright, since that is the state the
* reset exists to escape; a config that cannot be read at all fails
* instead, rather than overwriting rules that are still on disk.
*/
readonly resetKeybindingRulesToDefaults: Effect.Effect<
ResolvedKeybindingsConfig,
KeybindingsConfigError
>;
}
>()("t3/keybindings") {}

Expand Down Expand Up @@ -698,6 +716,43 @@ const make = Effect.gen(function* () {
return nextResolved;
}),
),
resetKeybindingRulesToDefaults: upsertSemaphore.withPermits(1)(
Effect.gen(function* () {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// The runtime loader, not the writable one: a config that cannot be
// parsed must resolve to "no rules to preserve" so the reset can still
// replace it, while a filesystem failure still aborts rather than
// overwriting a file we could not read.
const { keybindings: customConfig } = yield* loadRuntimeCustomKeybindingsConfig();
const preservedScripts = customConfig.filter((entry) => isScriptKeybindingRule(entry));
// Truncate the preserved scripts, never the defaults — a reset that
// dropped default rules to stay under the cap would defeat itself. Drop
// from the front, because later rules have higher precedence, so the
// trailing scripts are the ones actually in effect.
const scriptBudget = Math.max(0, MAX_KEYBINDINGS_COUNT - DEFAULT_KEYBINDINGS.length);
const droppedScripts = Math.max(0, preservedScripts.length - scriptBudget);
const cappedConfig = [...DEFAULT_KEYBINDINGS, ...preservedScripts.slice(droppedScripts)];
if (droppedScripts > 0) {
yield* Effect.logWarning("dropping script keybindings to stay under max entries", {
path: keybindingsConfigPath,
maxEntries: MAX_KEYBINDINGS_COUNT,
dropped: droppedScripts,
});
}
yield* writeConfigAtomically(cappedConfig);
const nextResolved = mergeWithDefaultKeybindings(
compileResolvedKeybindingsConfig(cappedConfig),
);
yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
keybindings: nextResolved,
issues: [],
});
yield* emitChange({
keybindings: nextResolved,
issues: [],
});
return nextResolved;
}),
),
} satisfies Keybindings["Service"];
});

Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope],
[WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
[WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
[WS_METHODS.serverResetKeybindings, AuthOrchestrationOperateScope],
[WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
[WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope],
[WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope],
Expand Down Expand Up @@ -1509,6 +1510,15 @@ const makeWsRpcLayer = (
}),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverResetKeybindings]: (_input) =>
observeRpcEffect(
WS_METHODS.serverResetKeybindings,
Effect.gen(function* () {
const keybindingsConfig = yield* keybindings.resetKeybindingRulesToDefaults;
return { keybindings: keybindingsConfig, issues: [] };
}),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverGetSettings]: (_input) =>
observeRpcEffect(
WS_METHODS.serverGetSettings,
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/components/settings/KeybindingsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
InfoIcon,
MinusIcon,
PlusIcon,
RotateCcwIcon,
SearchIcon,
TriangleAlertIcon,
XIcon,
Expand Down Expand Up @@ -35,6 +36,7 @@ import {

import { isElectron } from "../../env";
import { useOpenInPreferredEditor } from "../../editorPreferences";
import { readLocalApi } from "../../localApi";
import { formatShortcutLabel } from "../../keybindings";
import { cn } from "../../lib/utils";
import {
Expand Down Expand Up @@ -1092,6 +1094,9 @@ export function KeybindingsSettingsPanel() {
const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, {
reportFailure: false,
});
const resetKeybindingsMutation = useAtomCommand(serverEnvironment.resetKeybindings, {
reportFailure: false,
});
const openInPreferredEditor = useOpenInPreferredEditor(
primaryEnvironment?.environmentId ?? null,
availableEditors,
Expand Down Expand Up @@ -1220,6 +1225,48 @@ export function KeybindingsSettingsPanel() {
[saveKeybinding],
);

const [isResetting, setIsResetting] = useState(false);
const primaryEnvironmentRef = useRef(primaryEnvironment);
useEffect(() => {
primaryEnvironmentRef.current = primaryEnvironment;
}, [primaryEnvironment]);
const resetAllKeybindings = useCallback(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const environmentId = primaryEnvironment?.environmentId;
if (!environmentId) return;
void (async () => {
const confirmed = await readLocalApi()?.dialogs.confirm(
[
"Reset all keybindings to their defaults?",
"Every customization is discarded. Project script bindings are kept.",
].join("\n"),
);
if (!confirmed) return;
// The primary environment can change while the dialog is open, and this
// discards every customization — target only what the user was shown.
if (primaryEnvironmentRef.current?.environmentId !== environmentId) {
toastManager.add({
title: "Keybindings were not reset",
description: "The active environment changed while the confirmation was open.",
type: "error",
});
return;
}
setIsResetting(true);
const result = await resetKeybindingsMutation({
environmentId,
input: {},
});
setIsResetting(false);
if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
const error = squashAtomCommandFailure(result);
toastManager.add({
title: "Unable to reset keybindings",
description: error instanceof Error ? error.message : "The keybindings were not reset.",
type: "error",
});
})();
}, [primaryEnvironment?.environmentId, resetKeybindingsMutation]);

const bindingsCount = (
<span className="text-[11px] text-muted-foreground">
{rows.length + (isAddingBinding ? 1 : 0)}{" "}
Expand Down Expand Up @@ -1258,6 +1305,24 @@ export function KeybindingsSettingsPanel() {
/>
<TooltipPopup side="top">Add keybinding</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="ghost"
className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
disabled={!primaryEnvironment || isResetting}
onClick={resetAllKeybindings}
aria-label="Reset all keybindings to defaults"
>
<RotateCcwIcon className="size-3" />
</Button>
}
/>
<TooltipPopup side="top">Reset all to defaults</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
Expand Down
6 changes: 6 additions & 0 deletions packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ export function createServerEnvironmentAtoms<R, E>(
scheduler: configScheduler,
concurrency: configConcurrency,
}),
resetKeybindings: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:reset-keybindings",
tag: WS_METHODS.serverResetKeybindings,
scheduler: configScheduler,
concurrency: configConcurrency,
}),
updateSettings: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:update-settings",
tag: WS_METHODS.serverUpdateSettings,
Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
ServerLifecycleStreamEvent,
ServerRemoveKeybindingInput,
ServerRemoveKeybindingResult,
ServerResetKeybindingsResult,
ServerProviderUpdatedPayload,
ServerSelfUpdateError,
ServerSelfUpdateInput,
Expand Down Expand Up @@ -211,6 +212,7 @@ export const WS_METHODS = {
serverUpdateServer: "server.updateServer",
serverUpsertKeybinding: "server.upsertKeybinding",
serverRemoveKeybinding: "server.removeKeybinding",
serverResetKeybindings: "server.resetKeybindings",
serverGetSettings: "server.getSettings",
serverUpdateSettings: "server.updateSettings",
serverDiscoverSourceControl: "server.discoverSourceControl",
Expand Down Expand Up @@ -251,6 +253,12 @@ export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybi
error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
});

export const WsServerResetKeybindingsRpc = Rpc.make(WS_METHODS.serverResetKeybindings, {
payload: Schema.Struct({}),
success: ServerResetKeybindingsResult,
error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
});

export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, {
payload: Schema.Struct({}),
success: Schema.Struct({}),
Expand Down Expand Up @@ -706,6 +714,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerUpdateServerRpc,
WsServerUpsertKeybindingRpc,
WsServerRemoveKeybindingRpc,
WsServerResetKeybindingsRpc,
WsServerGetSettingsRpc,
WsServerUpdateSettingsRpc,
WsServerDiscoverSourceControlRpc,
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,9 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T
export const ServerRemoveKeybindingResult = ServerUpsertKeybindingResult;
export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type;

export const ServerResetKeybindingsResult = ServerUpsertKeybindingResult;
export type ServerResetKeybindingsResult = typeof ServerResetKeybindingsResult.Type;

export const ServerConfigUpdatedPayload = Schema.Struct({
issues: ServerConfigIssues,
providers: ServerProviders,
Expand Down
Loading