| author | |
| committer | |
| log | f20bbdf26767db0fb286a3d66d719c0d365a9238 |
| tree | 23bc4a0a532ce463ba4af613e417b90245c7ae31 |
| parent | 89c364a1a3dfa1f1a16e45376f1bcc1928f5dc48 |
5 files changed, 163 insertions(+), 43 deletions(-)
.plans/16-frontend-contract-schema-validation-alignment.md+3| ... | ... | @@ -97,6 +97,9 @@ Align frontend input/output validation with `@t3tools/contracts` schemas so the |
| 97 | 97 | |
| 98 | 98 | ## Phase 5: Script/Keybinding Validation Consistency |
| 99 | 99 | |
| 100 | ### Status | |
| 101 | Complete | |
| 102 | ||
| 100 | 103 | ### Issue |
| 101 | 104 | `ProjectScriptsControl` and `ChatView` rely mostly on local non-empty checks while sending keybinding data into a contract-validated endpoint (`server.upsertKeybinding`). |
| 102 | 105 |
apps/web/src/components/ChatView.tsx+9-15| ... | ... | @@ -92,6 +92,7 @@ import { CursorIcon, Icon } from "./Icons"; |
| 92 | 92 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 93 | 93 | import { Badge } from "./ui/badge"; |
| 94 | 94 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 95 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | |
| 95 | 96 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 96 | 97 | import { |
| 97 | 98 | commandForProjectScript, |
| ... | ... | @@ -814,21 +815,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 814 | 815 | scripts: input.nextScripts, |
| 815 | 816 | }); |
| 816 | 817 | |
| 817 | if (isElectron && input.keybinding) { | |
| 818 | const keybindingUpdate = await api.server.upsertKeybinding({ | |
| 819 | key: input.keybinding, | |
| 820 | command: input.keybindingCommand, | |
| 821 | }); | |
| 822 | queryClient.setQueryData( | |
| 823 | serverQueryKeys.config(), | |
| 824 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | |
| 825 | current | |
| 826 | ? { ...current, keybindings: keybindingUpdate.keybindings } | |
| 827 | : { | |
| 828 | cwd: input.projectCwd, | |
| 829 | keybindings: keybindingUpdate.keybindings, | |
| 830 | }, | |
| 831 | ); | |
| 818 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 819 | keybinding: input.keybinding, | |
| 820 | command: input.keybindingCommand, | |
| 821 | }); | |
| 822 | ||
| 823 | if (isElectron && keybindingRule) { | |
| 824 | await api.server.upsertKeybinding(keybindingRule); | |
| 825 | await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); | |
| 832 | 826 | } |
| 833 | 827 | }, |
| 834 | 828 | [queryClient], |
apps/web/src/components/ProjectScriptsControl.tsx+9-28| ... | ... | @@ -16,7 +16,8 @@ import { |
| 16 | 16 | } from "lucide-react"; |
| 17 | 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; |
| 18 | 18 | |
| 19 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; | |
| 19 | import { keybindingValueForCommand, decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | |
| 20 | import { commandForProjectScript, nextProjectScriptId, primaryProjectScript } from "~/projectScripts"; | |
| 20 | 21 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 21 | 22 | import { isMacPlatform } from "~/lib/utils"; |
| 22 | 23 | import { Button } from "./ui/button"; |
| ... | ... | @@ -129,32 +130,6 @@ function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | n |
| 129 | 130 | return parts.join("+"); |
| 130 | 131 | } |
| 131 | 132 | |
| 132 | function keybindingValueForCommand( | |
| 133 | keybindings: ResolvedKeybindingsConfig, | |
| 134 | command: string, | |
| 135 | ): string | null { | |
| 136 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 137 | const binding = keybindings[index]; | |
| 138 | if (!binding || binding.command !== command) continue; | |
| 139 | ||
| 140 | const parts: string[] = []; | |
| 141 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 142 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 143 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 144 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 145 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 146 | const keyToken = | |
| 147 | binding.shortcut.key === " " | |
| 148 | ? "space" | |
| 149 | : binding.shortcut.key === "escape" | |
| 150 | ? "esc" | |
| 151 | : binding.shortcut.key; | |
| 152 | parts.push(keyToken); | |
| 153 | return parts.join("+"); | |
| 154 | } | |
| 155 | return null; | |
| 156 | } | |
| 157 | ||
| 158 | 133 | export default function ProjectScriptsControl({ |
| 159 | 134 | scripts, |
| 160 | 135 | keybindings, |
| ... | ... | @@ -212,12 +187,18 @@ export default function ProjectScriptsControl({ |
| 212 | 187 | |
| 213 | 188 | setValidationError(null); |
| 214 | 189 | try { |
| 190 | const scriptIdForValidation = | |
| 191 | editingScriptId ?? nextProjectScriptId(trimmedName, scripts.map((script) => script.id)); | |
| 192 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 193 | keybinding, | |
| 194 | command: commandForProjectScript(scriptIdForValidation), | |
| 195 | }); | |
| 215 | 196 | const payload = { |
| 216 | 197 | name: trimmedName, |
| 217 | 198 | command: trimmedCommand, |
| 218 | 199 | icon, |
| 219 | 200 | runOnWorktreeCreate, |
| 220 | keybinding: keybinding.trim() || null, | |
| 201 | keybinding: keybindingRule?.key ?? null, | |
| 221 | 202 | } satisfies NewProjectScriptInput; |
| 222 | 203 | if (editingScriptId) { |
| 223 | 204 | await onUpdateScript(editingScriptId, payload); |
apps/web/src/lib/projectScriptKeybindings.test.ts created+83| ... | ... | @@ -0,0 +1,83 @@ |
| 1 | import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { commandForProjectScript } from "../projectScripts"; | |
| 5 | import { | |
| 6 | decodeProjectScriptKeybindingRule, | |
| 7 | keybindingValueForCommand, | |
| 8 | PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE, | |
| 9 | } from "./projectScriptKeybindings"; | |
| 10 | ||
| 11 | describe("projectScriptKeybindings", () => { | |
| 12 | it("decodes and trims valid keybinding rules", () => { | |
| 13 | const rule = decodeProjectScriptKeybindingRule({ | |
| 14 | keybinding: " mod+k ", | |
| 15 | command: commandForProjectScript("lint"), | |
| 16 | }); | |
| 17 | ||
| 18 | expect(rule).toEqual({ | |
| 19 | key: "mod+k", | |
| 20 | command: "script.lint.run", | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("returns null when keybinding is empty", () => { | |
| 25 | expect( | |
| 26 | decodeProjectScriptKeybindingRule({ | |
| 27 | keybinding: " ", | |
| 28 | command: commandForProjectScript("lint"), | |
| 29 | }), | |
| 30 | ).toBeNull(); | |
| 31 | }); | |
| 32 | ||
| 33 | it("rejects invalid keybinding values", () => { | |
| 34 | expect(() => | |
| 35 | decodeProjectScriptKeybindingRule({ | |
| 36 | keybinding: "k".repeat(MAX_KEYBINDING_VALUE_LENGTH + 1), | |
| 37 | command: commandForProjectScript("lint"), | |
| 38 | }), | |
| 39 | ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 40 | }); | |
| 41 | ||
| 42 | it("rejects invalid commands", () => { | |
| 43 | expect(() => | |
| 44 | decodeProjectScriptKeybindingRule({ | |
| 45 | keybinding: "mod+k", | |
| 46 | command: "script.BAD.run" as KeybindingCommand, | |
| 47 | }), | |
| 48 | ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 49 | }); | |
| 50 | ||
| 51 | it("reads latest matching keybinding value for a command", () => { | |
| 52 | const command = commandForProjectScript("test"); | |
| 53 | const value = keybindingValueForCommand( | |
| 54 | [ | |
| 55 | { | |
| 56 | command, | |
| 57 | shortcut: { | |
| 58 | key: "escape", | |
| 59 | metaKey: false, | |
| 60 | ctrlKey: false, | |
| 61 | shiftKey: false, | |
| 62 | altKey: false, | |
| 63 | modKey: true, | |
| 64 | }, | |
| 65 | }, | |
| 66 | { | |
| 67 | command, | |
| 68 | shortcut: { | |
| 69 | key: "k", | |
| 70 | metaKey: false, | |
| 71 | ctrlKey: false, | |
| 72 | shiftKey: true, | |
| 73 | altKey: false, | |
| 74 | modKey: true, | |
| 75 | }, | |
| 76 | }, | |
| 77 | ], | |
| 78 | command, | |
| 79 | ); | |
| 80 | ||
| 81 | expect(value).toBe("mod+shift+k"); | |
| 82 | }); | |
| 83 | }); |
apps/web/src/lib/projectScriptKeybindings.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { | |
| 2 | KeybindingRule as KeybindingRuleSchema, | |
| 3 | type KeybindingCommand, | |
| 4 | type KeybindingRule, | |
| 5 | type ResolvedKeybindingsConfig, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Schema } from "effect"; | |
| 8 | ||
| 9 | export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding."; | |
| 10 | ||
| 11 | function normalizeProjectScriptKeybindingInput( | |
| 12 | keybinding: string | null | undefined, | |
| 13 | ): string | null { | |
| 14 | const trimmed = keybinding?.trim() ?? ""; | |
| 15 | return trimmed.length > 0 ? trimmed : null; | |
| 16 | } | |
| 17 | ||
| 18 | export function decodeProjectScriptKeybindingRule(input: { | |
| 19 | keybinding: string | null | undefined; | |
| 20 | command: KeybindingCommand; | |
| 21 | }): KeybindingRule | null { | |
| 22 | const normalizedKey = normalizeProjectScriptKeybindingInput(input.keybinding); | |
| 23 | if (!normalizedKey) return null; | |
| 24 | ||
| 25 | const decoded = Schema.decodeUnknownOption(KeybindingRuleSchema)({ | |
| 26 | key: normalizedKey, | |
| 27 | command: input.command, | |
| 28 | }); | |
| 29 | if (decoded._tag === "None") { | |
| 30 | throw new Error(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 31 | } | |
| 32 | return decoded.value; | |
| 33 | } | |
| 34 | ||
| 35 | export function keybindingValueForCommand( | |
| 36 | keybindings: ResolvedKeybindingsConfig, | |
| 37 | command: KeybindingCommand, | |
| 38 | ): string | null { | |
| 39 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 40 | const binding = keybindings[index]; | |
| 41 | if (!binding || binding.command !== command) continue; | |
| 42 | ||
| 43 | const parts: string[] = []; | |
| 44 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 45 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 46 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 47 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 48 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 49 | const keyToken = | |
| 50 | binding.shortcut.key === " " | |
| 51 | ? "space" | |
| 52 | : binding.shortcut.key === "escape" | |
| 53 | ? "esc" | |
| 54 | : binding.shortcut.key; | |
| 55 | parts.push(keyToken); | |
| 56 | return parts.join("+"); | |
| 57 | } | |
| 58 | return null; | |
| 59 | } |