diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac84167..27e52b7cccb7 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -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"; @@ -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; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe36232..2a8e6beb85cb 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -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; @@ -284,6 +288,20 @@ export class Keybindings extends Context.Service< readonly removeKeybindingRule: ( input: ServerRemoveKeybindingInput, ) => Effect.Effect; + + /** + * 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") {} @@ -698,6 +716,43 @@ const make = Effect.gen(function* () { return nextResolved; }), ), + resetKeybindingRulesToDefaults: upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + // 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"]; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8f4b07124da..563082c86c9b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -304,6 +304,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [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], @@ -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, diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 67c33c9b9423..562affa55552 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -6,6 +6,7 @@ import { InfoIcon, MinusIcon, PlusIcon, + RotateCcwIcon, SearchIcon, TriangleAlertIcon, XIcon, @@ -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 { @@ -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, @@ -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(() => { + 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 = ( {rows.length + (isAddingBinding ? 1 : 0)}{" "} @@ -1258,6 +1305,24 @@ export function KeybindingsSettingsPanel() { /> Add keybinding + + + + + } + /> + Reset all to defaults + ( 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, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef29..8ec4f36aff7e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -121,6 +121,7 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerResetKeybindingsResult, ServerProviderUpdatedPayload, ServerSelfUpdateError, ServerSelfUpdateInput, @@ -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", @@ -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({}), @@ -706,6 +714,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerResetKeybindingsRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a8394..14afc44c4550 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -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,