| author | |
| committer | |
| log | 41ba1ec7a8965b42a7a7bf48065e5a800d46dc9e |
| tree | 342ae2f640f996d131d5851d08572d4aace3a917 |
| parent | 3db020a683398511a169c411513a945a1cd2f384 |
- Move default keybindings, config parsing, validation, and merge logic out of `wsServer`
- Add `loadResolvedKeybindingsConfig(logger)` in `apps/server/src/keybindings.ts`
- Update server wiring docs to point to `server.getConfig` integration3 files changed, 117 insertions(+), 108 deletions(-)
KEYBINDINGS.md+3| ... | ... | @@ -12,6 +12,9 @@ Schema source of truth: |
| 12 | 12 | Server-side parsing/default resolution/merging: |
| 13 | 13 | |
| 14 | 14 | - [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) |
| 15 | ||
| 16 | Server wiring (`server.getConfig`): | |
| 17 | ||
| 15 | 18 | - [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts) |
| 16 | 19 | |
| 17 | 20 | The file must be a JSON array of rules: |
apps/server/src/keybindings.ts+112| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import type { |
| 2 | 6 | KeybindingRule, |
| 3 | 7 | KeybindingsConfig, |
| ... | ... | @@ -6,6 +10,11 @@ import type { |
| 6 | 10 | ResolvedKeybindingRule, |
| 7 | 11 | ResolvedKeybindingsConfig, |
| 8 | 12 | } from "@t3tools/contracts"; |
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | interface KeybindingsLogger { | |
| 16 | warn(message: string, context?: Record<string, unknown>): void; | |
| 17 | } | |
| 9 | 18 | |
| 10 | 19 | type WhenToken = |
| 11 | 20 | | { type: "identifier"; value: string } |
| ... | ... | @@ -15,6 +24,14 @@ type WhenToken = |
| 15 | 24 | | { type: "lparen" } |
| 16 | 25 | | { type: "rparen" }; |
| 17 | 26 | |
| 27 | const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ | |
| 28 | { key: "mod+j", command: "terminal.toggle" }, | |
| 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 30 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 31 | { key: "mod+shift+o", command: "chat.new" }, | |
| 32 | { key: "mod+o", command: "editor.openFavorite" }, | |
| 33 | ]; | |
| 34 | ||
| 18 | 35 | function normalizeKeyToken(token: string): string { |
| 19 | 36 | if (token === "space") return " "; |
| 20 | 37 | if (token === "esc") return "escape"; |
| ... | ... | @@ -239,3 +256,98 @@ export function compileResolvedKeybindingsConfig( |
| 239 | 256 | } |
| 240 | 257 | return compiled; |
| 241 | 258 | } |
| 259 | ||
| 260 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | |
| 261 | const resolved: ResolvedKeybindingsConfig = []; | |
| 262 | for (const rule of DEFAULT_KEYBINDINGS) { | |
| 263 | const compiled = compileResolvedKeybindingRule(rule); | |
| 264 | if (!compiled) { | |
| 265 | throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); | |
| 266 | } | |
| 267 | resolved.push(compiled); | |
| 268 | } | |
| 269 | return resolved; | |
| 270 | } | |
| 271 | ||
| 272 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | |
| 273 | ||
| 274 | function mergeWithDefaultKeybindings( | |
| 275 | custom: ResolvedKeybindingsConfig, | |
| 276 | ): ResolvedKeybindingsConfig { | |
| 277 | if (custom.length === 0) { | |
| 278 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 279 | } | |
| 280 | ||
| 281 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 282 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 283 | (binding) => !overriddenCommands.has(binding.command), | |
| 284 | ); | |
| 285 | const merged = [...retainedDefaults, ...custom]; | |
| 286 | ||
| 287 | if (merged.length <= 256) { | |
| 288 | return merged; | |
| 289 | } | |
| 290 | ||
| 291 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 292 | return merged.slice(-256); | |
| 293 | } | |
| 294 | ||
| 295 | export function loadResolvedKeybindingsConfig( | |
| 296 | logger: KeybindingsLogger, | |
| 297 | ): ResolvedKeybindingsConfig { | |
| 298 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | |
| 299 | try { | |
| 300 | const raw = fs.readFileSync(configPath, "utf8"); | |
| 301 | const parsed = JSON.parse(raw) as unknown; | |
| 302 | if (!Array.isArray(parsed)) { | |
| 303 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | |
| 304 | path: configPath, | |
| 305 | }); | |
| 306 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 307 | } | |
| 308 | ||
| 309 | const sanitized: ResolvedKeybindingsConfig = []; | |
| 310 | let invalidEntries = 0; | |
| 311 | for (const entry of parsed) { | |
| 312 | const result = keybindingRuleSchema.safeParse(entry); | |
| 313 | if (result.success) { | |
| 314 | const compiled = compileResolvedKeybindingRule(result.data); | |
| 315 | if (!compiled) { | |
| 316 | invalidEntries += 1; | |
| 317 | continue; | |
| 318 | } | |
| 319 | sanitized.push(compiled); | |
| 320 | continue; | |
| 321 | } | |
| 322 | invalidEntries += 1; | |
| 323 | } | |
| 324 | if (invalidEntries > 0) { | |
| 325 | logger.warn("ignoring invalid keybinding entries", { | |
| 326 | path: configPath, | |
| 327 | invalidEntries, | |
| 328 | totalEntries: parsed.length, | |
| 329 | }); | |
| 330 | } | |
| 331 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | |
| 332 | const mergedBeforeCapLength = | |
| 333 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 334 | .length + sanitized.length; | |
| 335 | const merged = mergeWithDefaultKeybindings(sanitized); | |
| 336 | if (mergedBeforeCapLength > 256) { | |
| 337 | logger.warn("truncating merged keybindings config to max entries", { | |
| 338 | path: configPath, | |
| 339 | maxEntries: 256, | |
| 340 | }); | |
| 341 | } | |
| 342 | return merged; | |
| 343 | } catch (error) { | |
| 344 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | |
| 345 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 346 | } | |
| 347 | logger.warn("ignoring malformed keybindings config", { | |
| 348 | path: configPath, | |
| 349 | error: error instanceof Error ? error.message : String(error), | |
| 350 | }); | |
| 351 | } | |
| 352 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 353 | } |
apps/server/src/wsServer.ts+2-108| ... | ... | @@ -9,9 +9,6 @@ import { |
| 9 | 9 | EDITORS, |
| 10 | 10 | WS_CHANNELS, |
| 11 | 11 | WS_METHODS, |
| 12 | keybindingRuleSchema, | |
| 13 | type KeybindingsConfig, | |
| 14 | type ResolvedKeybindingsConfig, | |
| 15 | 12 | type TerminalEvent, |
| 16 | 13 | type WsPush, |
| 17 | 14 | type WsRequest, |
| ... | ... | @@ -34,7 +31,7 @@ import { |
| 34 | 31 | removeGitWorktree, |
| 35 | 32 | } from "./git"; |
| 36 | 33 | import { TerminalManager } from "./terminalManager"; |
| 37 | import { compileResolvedKeybindingRule } from "./keybindings"; | |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | |
| 38 | 35 | |
| 39 | 36 | const MIME_TYPES: Record<string, string> = { |
| 40 | 37 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -72,109 +69,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 72 | 69 | return undefined; |
| 73 | 70 | } |
| 74 | 71 | |
| 75 | const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ | |
| 76 | { key: "mod+j", command: "terminal.toggle" }, | |
| 77 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 78 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 79 | { key: "mod+shift+o", command: "chat.new" }, | |
| 80 | { key: "mod+o", command: "editor.openFavorite" }, | |
| 81 | ]; | |
| 82 | ||
| 83 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | |
| 84 | const resolved: ResolvedKeybindingsConfig = []; | |
| 85 | for (const rule of DEFAULT_KEYBINDINGS) { | |
| 86 | const compiled = compileResolvedKeybindingRule(rule); | |
| 87 | if (!compiled) { | |
| 88 | throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); | |
| 89 | } | |
| 90 | resolved.push(compiled); | |
| 91 | } | |
| 92 | return resolved; | |
| 93 | } | |
| 94 | ||
| 95 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | |
| 96 | ||
| 97 | function mergeWithDefaultKeybindings( | |
| 98 | custom: ResolvedKeybindingsConfig, | |
| 99 | ): ResolvedKeybindingsConfig { | |
| 100 | if (custom.length === 0) { | |
| 101 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 102 | } | |
| 103 | ||
| 104 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 105 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 106 | (binding) => !overriddenCommands.has(binding.command), | |
| 107 | ); | |
| 108 | const merged = [...retainedDefaults, ...custom]; | |
| 109 | ||
| 110 | if (merged.length <= 256) { | |
| 111 | return merged; | |
| 112 | } | |
| 113 | ||
| 114 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 115 | return merged.slice(-256); | |
| 116 | } | |
| 117 | ||
| 118 | function readKeybindingsConfig( | |
| 119 | logger: ReturnType<typeof createLogger>, | |
| 120 | ): ResolvedKeybindingsConfig { | |
| 121 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | |
| 122 | try { | |
| 123 | const raw = fs.readFileSync(configPath, "utf8"); | |
| 124 | const parsed = JSON.parse(raw) as unknown; | |
| 125 | if (!Array.isArray(parsed)) { | |
| 126 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | |
| 127 | path: configPath, | |
| 128 | }); | |
| 129 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 130 | } | |
| 131 | ||
| 132 | const sanitized: ResolvedKeybindingsConfig = []; | |
| 133 | let invalidEntries = 0; | |
| 134 | for (const entry of parsed) { | |
| 135 | const result = keybindingRuleSchema.safeParse(entry); | |
| 136 | if (result.success) { | |
| 137 | const compiled = compileResolvedKeybindingRule(result.data); | |
| 138 | if (!compiled) { | |
| 139 | invalidEntries += 1; | |
| 140 | continue; | |
| 141 | } | |
| 142 | sanitized.push(compiled); | |
| 143 | continue; | |
| 144 | } | |
| 145 | invalidEntries += 1; | |
| 146 | } | |
| 147 | if (invalidEntries > 0) { | |
| 148 | logger.warn("ignoring invalid keybinding entries", { | |
| 149 | path: configPath, | |
| 150 | invalidEntries, | |
| 151 | totalEntries: parsed.length, | |
| 152 | }); | |
| 153 | } | |
| 154 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | |
| 155 | const mergedBeforeCapLength = | |
| 156 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 157 | .length + sanitized.length; | |
| 158 | const merged = mergeWithDefaultKeybindings(sanitized); | |
| 159 | if (mergedBeforeCapLength > 256) { | |
| 160 | logger.warn("truncating merged keybindings config to max entries", { | |
| 161 | path: configPath, | |
| 162 | maxEntries: 256, | |
| 163 | }); | |
| 164 | } | |
| 165 | return merged; | |
| 166 | } catch (error) { | |
| 167 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | |
| 168 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 169 | } | |
| 170 | logger.warn("ignoring malformed keybindings config", { | |
| 171 | path: configPath, | |
| 172 | error: error instanceof Error ? error.message : String(error), | |
| 173 | }); | |
| 174 | } | |
| 175 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 176 | } | |
| 177 | ||
| 178 | 72 | export function createServer(options: ServerOptions) { |
| 179 | 73 | const { |
| 180 | 74 | port, |
| ... | ... | @@ -197,7 +91,7 @@ export function createServer(options: ServerOptions) { |
| 197 | 91 | const logger = createLogger("ws"); |
| 198 | 92 | const logWebSocketEvents = |
| 199 | 93 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 200 | const keybindingsConfig = readKeybindingsConfig(logger); | |
| 94 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 201 | 95 | |
| 202 | 96 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 203 | 97 | if (!logWebSocketEvents) return; |