| 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,6 +97,9 @@ Align frontend input/output validation with `@t3tools/contracts` schemas so the |
| 97 | 97 | ||
| 98 | ## Phase 5: Script/Keybinding Validation Consistency | 98 | ## Phase 5: Script/Keybinding Validation Consistency |
| 99 | 99 | ||
| 100 | ### Status | ||
| 101 | Complete | ||
| 102 | |||
| 100 | ### Issue | 103 | ### Issue |
| 101 | `ProjectScriptsControl` and `ChatView` rely mostly on local non-empty checks while sending keybinding data into a contract-validated endpoint (`server.upsertKeybinding`). | 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,6 +92,7 @@ import { CursorIcon, Icon } from "./Icons"; |
| 92 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 92 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 93 | import { Badge } from "./ui/badge"; | 93 | import { Badge } from "./ui/badge"; |
| 94 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | 94 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 95 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | ||
| 95 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 96 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 96 | import { | 97 | import { |
| 97 | commandForProjectScript, | 98 | commandForProjectScript, |
| ... | @@ -814,21 +815,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -814,21 +815,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 814 | scripts: input.nextScripts, | 815 | scripts: input.nextScripts, |
| 815 | }); | 816 | }); |
| 816 | 817 | ||
| 817 | if (isElectron && input.keybinding) { | 818 | const keybindingRule = decodeProjectScriptKeybindingRule({ |
| 818 | const keybindingUpdate = await api.server.upsertKeybinding({ | 819 | keybinding: input.keybinding, |
| 819 | key: input.keybinding, | 820 | command: input.keybindingCommand, |
| 820 | command: input.keybindingCommand, | 821 | }); |
| 821 | }); | 822 | |
| 822 | queryClient.setQueryData( | 823 | if (isElectron && keybindingRule) { |
| 823 | serverQueryKeys.config(), | 824 | await api.server.upsertKeybinding(keybindingRule); |
| 824 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | 825 | await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); |
| 825 | current | ||
| 826 | ? { ...current, keybindings: keybindingUpdate.keybindings } | ||
| 827 | : { | ||
| 828 | cwd: input.projectCwd, | ||
| 829 | keybindings: keybindingUpdate.keybindings, | ||
| 830 | }, | ||
| 831 | ); | ||
| 832 | } | 826 | } |
| 833 | }, | 827 | }, |
| 834 | [queryClient], | 828 | [queryClient], |
apps/web/src/components/ProjectScriptsControl.tsx+9-28| ... | @@ -16,7 +16,8 @@ import { | ... | @@ -16,7 +16,8 @@ import { |
| 16 | } from "lucide-react"; | 16 | } from "lucide-react"; |
| 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; | 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 | import { shortcutLabelForCommand } from "~/keybindings"; | 21 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 21 | import { isMacPlatform } from "~/lib/utils"; | 22 | import { isMacPlatform } from "~/lib/utils"; |
| 22 | import { Button } from "./ui/button"; | 23 | import { Button } from "./ui/button"; |
| ... | @@ -129,32 +130,6 @@ function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | n | ... | @@ -129,32 +130,6 @@ function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | n |
| 129 | return parts.join("+"); | 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 | export default function ProjectScriptsControl({ | 133 | export default function ProjectScriptsControl({ |
| 159 | scripts, | 134 | scripts, |
| 160 | keybindings, | 135 | keybindings, |
| ... | @@ -212,12 +187,18 @@ export default function ProjectScriptsControl({ | ... | @@ -212,12 +187,18 @@ export default function ProjectScriptsControl({ |
| 212 | 187 | ||
| 213 | setValidationError(null); | 188 | setValidationError(null); |
| 214 | try { | 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 | const payload = { | 196 | const payload = { |
| 216 | name: trimmedName, | 197 | name: trimmedName, |
| 217 | command: trimmedCommand, | 198 | command: trimmedCommand, |
| 218 | icon, | 199 | icon, |
| 219 | runOnWorktreeCreate, | 200 | runOnWorktreeCreate, |
| 220 | keybinding: keybinding.trim() || null, | 201 | keybinding: keybindingRule?.key ?? null, |
| 221 | } satisfies NewProjectScriptInput; | 202 | } satisfies NewProjectScriptInput; |
| 222 | if (editingScriptId) { | 203 | if (editingScriptId) { |
| 223 | await onUpdateScript(editingScriptId, payload); | 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 | } | ||