From f20bbdf26767db0fb286a3d66d719c0d365a9238 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 15:28:53 -0800 Subject: [PATCH] phase 5 --- ...nd-contract-schema-validation-alignment.md | 3 + apps/web/src/components/ChatView.tsx | 24 ++---- .../src/components/ProjectScriptsControl.tsx | 37 ++------- .../src/lib/projectScriptKeybindings.test.ts | 83 +++++++++++++++++++ apps/web/src/lib/projectScriptKeybindings.ts | 59 +++++++++++++ 5 files changed, 163 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/lib/projectScriptKeybindings.test.ts create mode 100644 apps/web/src/lib/projectScriptKeybindings.ts diff --git a/.plans/16-frontend-contract-schema-validation-alignment.md b/.plans/16-frontend-contract-schema-validation-alignment.md index 5ead8949c4f120e8d399bd3cab5f8c7dde834326..221c2f46f17c8fa277eac587e3296f61f34dd8a5 100644 --- a/.plans/16-frontend-contract-schema-validation-alignment.md +++ b/.plans/16-frontend-contract-schema-validation-alignment.md @@ -97,6 +97,9 @@ Align frontend input/output validation with `@t3tools/contracts` schemas so the ## Phase 5: Script/Keybinding Validation Consistency +### Status +Complete + ### Issue `ProjectScriptsControl` and `ChatView` rely mostly on local non-empty checks while sending keybinding data into a contract-validated endpoint (`server.upsertKeybinding`). diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 42c5ad5e53afb175b16133116c934fbb855f76ac..1c528379f544537695ace20d620ef9fe57afd251 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -92,6 +92,7 @@ import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; +import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, @@ -814,21 +815,14 @@ export default function ChatView({ threadId }: ChatViewProps) { scripts: input.nextScripts, }); - if (isElectron && input.keybinding) { - const keybindingUpdate = await api.server.upsertKeybinding({ - key: input.keybinding, - command: input.keybindingCommand, - }); - queryClient.setQueryData( - serverQueryKeys.config(), - (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => - current - ? { ...current, keybindings: keybindingUpdate.keybindings } - : { - cwd: input.projectCwd, - keybindings: keybindingUpdate.keybindings, - }, - ); + const keybindingRule = decodeProjectScriptKeybindingRule({ + keybinding: input.keybinding, + command: input.keybindingCommand, + }); + + if (isElectron && keybindingRule) { + await api.server.upsertKeybinding(keybindingRule); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, [queryClient], diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 2217a9098897a886544ea8f5647182a05ec0221c..13966a5f508bedc66eb1dd1a35ea4ee5b584a4c5 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -16,7 +16,8 @@ import { } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; -import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; +import { keybindingValueForCommand, decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { commandForProjectScript, nextProjectScriptId, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { isMacPlatform } from "~/lib/utils"; import { Button } from "./ui/button"; @@ -129,32 +130,6 @@ function keybindingFromEvent(event: KeyboardEvent): string | n return parts.join("+"); } -function keybindingValueForCommand( - keybindings: ResolvedKeybindingsConfig, - command: string, -): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - - const parts: string[] = []; - if (binding.shortcut.modKey) parts.push("mod"); - if (binding.shortcut.ctrlKey) parts.push("ctrl"); - if (binding.shortcut.metaKey) parts.push("meta"); - if (binding.shortcut.altKey) parts.push("alt"); - if (binding.shortcut.shiftKey) parts.push("shift"); - const keyToken = - binding.shortcut.key === " " - ? "space" - : binding.shortcut.key === "escape" - ? "esc" - : binding.shortcut.key; - parts.push(keyToken); - return parts.join("+"); - } - return null; -} - export default function ProjectScriptsControl({ scripts, keybindings, @@ -212,12 +187,18 @@ export default function ProjectScriptsControl({ setValidationError(null); try { + const scriptIdForValidation = + editingScriptId ?? nextProjectScriptId(trimmedName, scripts.map((script) => script.id)); + const keybindingRule = decodeProjectScriptKeybindingRule({ + keybinding, + command: commandForProjectScript(scriptIdForValidation), + }); const payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, - keybinding: keybinding.trim() || null, + keybinding: keybindingRule?.key ?? null, } satisfies NewProjectScriptInput; if (editingScriptId) { await onUpdateScript(editingScriptId, payload); diff --git a/apps/web/src/lib/projectScriptKeybindings.test.ts b/apps/web/src/lib/projectScriptKeybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9db0fd5290836a8073a2752f505470d98db234c2 --- /dev/null +++ b/apps/web/src/lib/projectScriptKeybindings.test.ts @@ -0,0 +1,83 @@ +import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { commandForProjectScript } from "../projectScripts"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, + PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE, +} from "./projectScriptKeybindings"; + +describe("projectScriptKeybindings", () => { + it("decodes and trims valid keybinding rules", () => { + const rule = decodeProjectScriptKeybindingRule({ + keybinding: " mod+k ", + command: commandForProjectScript("lint"), + }); + + expect(rule).toEqual({ + key: "mod+k", + command: "script.lint.run", + }); + }); + + it("returns null when keybinding is empty", () => { + expect( + decodeProjectScriptKeybindingRule({ + keybinding: " ", + command: commandForProjectScript("lint"), + }), + ).toBeNull(); + }); + + it("rejects invalid keybinding values", () => { + expect(() => + decodeProjectScriptKeybindingRule({ + keybinding: "k".repeat(MAX_KEYBINDING_VALUE_LENGTH + 1), + command: commandForProjectScript("lint"), + }), + ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); + }); + + it("rejects invalid commands", () => { + expect(() => + decodeProjectScriptKeybindingRule({ + keybinding: "mod+k", + command: "script.BAD.run" as KeybindingCommand, + }), + ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); + }); + + it("reads latest matching keybinding value for a command", () => { + const command = commandForProjectScript("test"); + const value = keybindingValueForCommand( + [ + { + command, + shortcut: { + key: "escape", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + { + command, + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + }, + ], + command, + ); + + expect(value).toBe("mod+shift+k"); + }); +}); diff --git a/apps/web/src/lib/projectScriptKeybindings.ts b/apps/web/src/lib/projectScriptKeybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ea80cf824f20a74d8ccd415af4ae9401c0fab35 --- /dev/null +++ b/apps/web/src/lib/projectScriptKeybindings.ts @@ -0,0 +1,59 @@ +import { + KeybindingRule as KeybindingRuleSchema, + type KeybindingCommand, + type KeybindingRule, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { Schema } from "effect"; + +export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding."; + +function normalizeProjectScriptKeybindingInput( + keybinding: string | null | undefined, +): string | null { + const trimmed = keybinding?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +export function decodeProjectScriptKeybindingRule(input: { + keybinding: string | null | undefined; + command: KeybindingCommand; +}): KeybindingRule | null { + const normalizedKey = normalizeProjectScriptKeybindingInput(input.keybinding); + if (!normalizedKey) return null; + + const decoded = Schema.decodeUnknownOption(KeybindingRuleSchema)({ + key: normalizedKey, + command: input.command, + }); + if (decoded._tag === "None") { + throw new Error(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); + } + return decoded.value; +} + +export function keybindingValueForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + + const parts: string[] = []; + if (binding.shortcut.modKey) parts.push("mod"); + if (binding.shortcut.ctrlKey) parts.push("ctrl"); + if (binding.shortcut.metaKey) parts.push("meta"); + if (binding.shortcut.altKey) parts.push("alt"); + if (binding.shortcut.shiftKey) parts.push("shift"); + const keyToken = + binding.shortcut.key === " " + ? "space" + : binding.shortcut.key === "escape" + ? "esc" + : binding.shortcut.key; + parts.push(keyToken); + return parts.join("+"); + } + return null; +} -- 2.54.0