authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 15:28:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logf20bbdf26767db0fb286a3d66d719c0d365a9238
tree23bc4a0a532ce463ba4af613e417b90245c7ae31
parent89c364a1a3dfa1f1a16e45376f1bcc1928f5dc48

phase 5


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
9797
98## Phase 5: Script/Keybinding Validation Consistency98## Phase 5: Script/Keybinding Validation Consistency
9999
100### Status
101Complete
102
100### Issue103### 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`).
102105
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";
92import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";92import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
93import { Badge } from "./ui/badge";93import { Badge } from "./ui/badge";
94import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";94import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
95import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
95import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";96import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
96import {97import {
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 });
816817
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";
17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
1818
19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";19import { keybindingValueForCommand, decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
20import { commandForProjectScript, nextProjectScriptId, primaryProjectScript } from "~/projectScripts";
20import { shortcutLabelForCommand } from "~/keybindings";21import { shortcutLabelForCommand } from "~/keybindings";
21import { isMacPlatform } from "~/lib/utils";22import { isMacPlatform } from "~/lib/utils";
22import { Button } from "./ui/button";23import { 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}
131132
132function 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
158export default function ProjectScriptsControl({133export 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({
212187
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 @@
1import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { commandForProjectScript } from "../projectScripts";
5import {
6 decodeProjectScriptKeybindingRule,
7 keybindingValueForCommand,
8 PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE,
9} from "./projectScriptKeybindings";
10
11describe("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 @@
1import {
2 KeybindingRule as KeybindingRuleSchema,
3 type KeybindingCommand,
4 type KeybindingRule,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
7import { Schema } from "effect";
8
9export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding.";
10
11function normalizeProjectScriptKeybindingInput(
12 keybinding: string | null | undefined,
13): string | null {
14 const trimmed = keybinding?.trim() ?? "";
15 return trimmed.length > 0 ? trimmed : null;
16}
17
18export 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
35export 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}