diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ecc091a9bef3..81e950d2ebc7 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -12,6 +12,7 @@ import { KeybindingsLive, ResolvedKeybindingFromConfig, compileResolvedKeybindingRule, + compileResolvedKeybindingsConfig, parseKeybindingShortcut, } from "./keybindings"; @@ -166,6 +167,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("uses defaults in runtime when config is malformed without overriding file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); + + const configState = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.loadConfigState; + }); + + assert.deepEqual(configState.keybindings, compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS)); + assert.deepEqual(configState.issues, [ + { + kind: "keybindings.malformed-config", + message: configState.issues[0]?.message ?? "", + }, + ]); + assert.equal(yield* fs.readFileString(keybindingsConfigPath), "{ not-json"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("ignores invalid entries in runtime and reports them as issues", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString( + keybindingsConfigPath, + JSON.stringify([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d+o", command: "terminal.new" }, + { key: "mod+x", command: "invalid.command" }, + ]), + ); + + const configState = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.loadConfigState; + }); + + assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse(configState.keybindings.some((entry) => entry.command === "invalid.command")); + assert.deepEqual(configState.issues, [ + { kind: "keybindings.invalid-entry", index: 1, message: configState.issues[0]?.message ?? "" }, + { kind: "keybindings.invalid-entry", index: 2, message: configState.issues[1]?.message ?? "" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect( "upserts missing default keybindings on startup without overriding existing command rules", () => @@ -365,23 +415,18 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const [first, second] = yield* Effect.gen(function* () { const keybindings = yield* Keybindings; - const firstLoad = yield* keybindings.loadResolvedKeybindingsConfig; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+x", command: "script.setup.run" }, - ]); - const secondLoad = yield* keybindings.loadResolvedKeybindingsConfig; + const firstLoad = (yield* keybindings.loadConfigState).keybindings; + const secondLoad = (yield* keybindings.loadConfigState).keybindings; return [firstLoad, secondLoad] as const; }); assert.deepEqual(first, second); assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); - assert.isFalse(second.some((entry) => entry.command === "script.setup.run")); }).pipe(Effect.provide(makeKeybindingsLayer())), ); it.effect("updates cached resolved config after upsert", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+j", command: "terminal.toggle" }, @@ -389,13 +434,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const loadedAfterUpsert = yield* Effect.gen(function* () { const keybindings = yield* Keybindings; - yield* keybindings.loadResolvedKeybindingsConfig; + yield* keybindings.loadConfigState; yield* keybindings.upsertKeybindingRule({ key: "mod+shift+r", command: "script.run-tests.run", }); - yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); - return yield* keybindings.loadResolvedKeybindingsConfig; + return (yield* keybindings.loadConfigState).keybindings; }); assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 4a5a060e712a..b82f83b640f0 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -15,6 +15,7 @@ import { MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, + type ServerConfigIssue, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; import { @@ -27,11 +28,13 @@ import { Layer, Option, Predicate, + PubSub, Schema, SchemaGetter, SchemaIssue, SchemaTransformation, ServiceMap, + Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; @@ -412,6 +415,35 @@ const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( }), ); +export interface KeybindingsConfigState { + readonly keybindings: ResolvedKeybindingsConfig; + readonly issues: readonly ServerConfigIssue[]; +} + +export interface KeybindingsChangeEvent { + readonly issues: readonly ServerConfigIssue[]; +} + +function trimIssueMessage(message: string): string { + const trimmed = message.trim(); + return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration."; +} + +function malformedConfigIssue(detail: string): ServerConfigIssue { + return { + kind: "keybindings.malformed-config", + message: trimIssueMessage(detail), + }; +} + +function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { + return { + kind: "keybindings.invalid-entry", + index, + message: trimIssueMessage(detail), + }; +} + function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { if (custom.length === 0) { return [...DEFAULT_RESOLVED_KEYBINDINGS]; @@ -442,14 +474,14 @@ export interface KeybindingsShape { readonly syncDefaultKeybindingsOnStartup: Effect.Effect; /** - * Load and resolve keybindings from disk merged with defaults. - * - * Returns an in-memory cached result after the first successful load. + * Load runtime keybindings state along with non-fatal configuration issues. */ - readonly loadResolvedKeybindingsConfig: Effect.Effect< - readonly ResolvedKeybindingRule[], - KeybindingsConfigError - >; + readonly loadConfigState: Effect.Effect; + + /** + * Stream keybindings config change events. + */ + readonly changes: Stream.Stream; /** * Upsert a keybinding rule and persist the resulting configuration. @@ -475,6 +507,10 @@ const makeKeybindings = Effect.gen(function* () { const path = yield* Path.Path; const upsertSemaphore = yield* Semaphore.make(1); const resolvedConfigCacheKey = "resolved" as const; + const changesPubSub = yield* PubSub.unbounded(); + + const emitChange = (issues: readonly ServerConfigIssue[]) => + PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid); const readConfigExists = fs.exists(keybindingsConfigPath).pipe( Effect.mapError( @@ -487,7 +523,18 @@ const makeKeybindings = Effect.gen(function* () { ), ); - const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< + const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe( + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "failed to read keybindings config", + cause, + }), + ), + ); + + const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< readonly KeybindingRule[], KeybindingsConfigError > { @@ -495,7 +542,7 @@ const makeKeybindings = Effect.gen(function* () { return []; } - const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( + const rawConfig = yield* readRawConfig.pipe( Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), Effect.mapError( (cause) => @@ -532,6 +579,61 @@ const makeKeybindings = Effect.gen(function* () { ).pipe(Effect.map(Array.filter(Predicate.isNotNull))); }); + const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< + { + readonly keybindings: readonly KeybindingRule[]; + readonly issues: readonly ServerConfigIssue[]; + }, + KeybindingsConfigError + > { + if (!(yield* readConfigExists)) { + return { keybindings: [], issues: [] }; + } + + const rawConfig = yield* readRawConfig; + const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig); + if (decodedEntries._tag === "Failure") { + const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`; + return { + keybindings: [], + issues: [malformedConfigIssue(detail)], + }; + } + + const keybindings: KeybindingRule[] = []; + const issues: ServerConfigIssue[] = []; + for (const [index, entry] of decodedEntries.value.entries()) { + const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); + if (decodedRule._tag === "Failure") { + const detail = Cause.pretty(decodedRule.cause); + issues.push(invalidEntryIssue(index, detail)); + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + index, + entry, + error: detail, + }); + continue; + } + + const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); + if (resolvedRule._tag === "Failure") { + const detail = Cause.pretty(resolvedRule.cause); + issues.push(invalidEntryIssue(index, detail)); + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + index, + entry, + error: detail, + }); + continue; + } + keybindings.push(decodedRule.value); + } + + return { keybindings, issues }; + }); + const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; @@ -551,20 +653,65 @@ const makeKeybindings = Effect.gen(function* () { ); }; - const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe( - Effect.map(compileResolvedKeybindingsConfig), - Effect.map(mergeWithDefaultKeybindings), + const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( + Effect.map(({ keybindings, issues }) => ({ + keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)), + issues, + })), ); const resolvedConfigCache = yield* Cache.make< typeof resolvedConfigCacheKey, - ResolvedKeybindingsConfig, + KeybindingsConfigState, KeybindingsConfigError >({ capacity: 1, - lookup: () => loadResolvedFromDisk, + lookup: () => loadConfigStateFromDisk, }); + const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); + + const revalidateAndEmit = upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + const configState = yield* loadConfigStateFromCacheOrDisk; + yield* emitChange(configState.issues); + }), + ); + + const keybindingsConfigDir = path.dirname(keybindingsConfigPath); + const keybindingsConfigFile = path.basename(keybindingsConfigPath); + const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath); + yield* fs + .makeDirectory(keybindingsConfigDir, { recursive: true }) + .pipe(Effect.orElseSucceed(() => undefined)); + yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { + const isTargetConfigEvent = + event.path === keybindingsConfigFile || + event.path === keybindingsConfigPath || + path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; + if (!isTargetConfigEvent) { + return Effect.void; + } + return revalidateAndEmit.pipe( + Effect.catch((error) => + Effect.logWarning("failed to revalidate keybindings config after file update", { + path: keybindingsConfigPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("keybindings config watcher stopped unexpectedly", { + path: keybindingsConfigPath, + cause, + }), + ), + Effect.forkScoped, + ); + const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( Effect.gen(function* () { const configExists = yield* readConfigExists; @@ -574,7 +721,19 @@ const makeKeybindings = Effect.gen(function* () { return; } - const customConfig = yield* loadCustomKeybindingsConfig(); + const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig(); + if (runtimeConfig.issues.length > 0) { + yield* Effect.logWarning( + "skipping startup keybindings default sync because config has issues", + { + path: keybindingsConfigPath, + issues: runtimeConfig.issues, + }, + ); + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + return; + } + const customConfig = runtimeConfig.keybindings; const existingCommands = new Set(customConfig.map((entry) => entry.command)); const missingDefaults: KeybindingRule[] = []; const shortcutConflictWarnings: Array<{ @@ -643,15 +802,14 @@ const makeKeybindings = Effect.gen(function* () { }), ); - const loadResolvedFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); - return { syncDefaultKeybindingsOnStartup, - loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, + loadConfigState: loadConfigStateFromCacheOrDisk, + changes: Stream.fromPubSub(changesPubSub), upsertKeybindingRule: (rule) => upsertSemaphore.withPermits(1)( Effect.gen(function* () { - const customConfig = yield* loadCustomKeybindingsConfig(); + const customConfig = yield* loadWritableCustomKeybindingsConfig(); const nextConfig = [ ...customConfig.filter((entry) => entry.command !== rule.command), rule, @@ -670,7 +828,11 @@ const makeKeybindings = Effect.gen(function* () { const nextResolved = mergeWithDefaultKeybindings( compileResolvedKeybindingsConfig(cappedConfig), ); - yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange([]); return nextResolved; }), ), diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index fc4a131b058b..5403ad05703b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -517,6 +517,7 @@ describe("WebSocket Server", () => { cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [], }); }); @@ -539,12 +540,128 @@ describe("WebSocket Server", () => { cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [], }); const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig; expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); }); + it("falls back to defaults and reports malformed keybindings config issues", async () => { + const stateDir = makeTempDir("t3code-state-malformed-keybindings-"); + const keybindingsPath = path.join(stateDir, "keybindings.json"); + fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [ + { + kind: "keybindings.malformed-config", + message: expect.stringContaining("expected JSON array"), + }, + ], + }); + expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); + }); + + it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { + const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); + const keybindingsPath = path.join(stateDir, "keybindings.json"); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d+o", command: "terminal.new" }, + { key: "mod+x", command: "not-a-real-command" }, + ]), + "utf8", + ); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + const result = response.result as { + cwd: string; + keybindingsConfigPath: string; + keybindings: ResolvedKeybindingsConfig; + issues: Array<{ kind: string; index?: number; message: string }>; + }; + expect(result.cwd).toBe("/my/workspace"); + expect(result.keybindingsConfigPath).toBe(keybindingsPath); + expect(result.issues).toEqual([ + { + kind: "keybindings.invalid-entry", + index: 1, + message: expect.any(String), + }, + { + kind: "keybindings.invalid-entry", + index: 2, + message: expect.any(String), + }, + ]); + expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); + expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); + expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); + }); + + it("pushes server.configUpdated issues when keybindings file changes", async () => { + const stateDir = makeTempDir("t3code-state-keybindings-watch-"); + const keybindingsPath = path.join(stateDir, "keybindings.json"); + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); + const malformedPush = await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + (push) => + Array.isArray((push.data as { issues?: unknown[] }).issues) && + Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) && + (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind === + "keybindings.malformed-config", + ); + expect(malformedPush.data).toEqual({ + issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + }); + + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + const successPush = await waitForPush( + ws, + WS_CHANNELS.serverConfigUpdated, + (push) => + Array.isArray((push.data as { issues?: unknown[] }).issues) && + (push.data as { issues: unknown[] }).issues.length === 0, + ); + expect(successPush.data).toEqual({ issues: [] }); + }); + it("routes shell.openInEditor through the injected open service", async () => { const openCalls: Array<{ cwd: string; editor: string }> = []; const openService: OpenShape = { @@ -599,6 +716,7 @@ describe("WebSocket Server", () => { cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), + issues: [], }); }); @@ -632,6 +750,7 @@ describe("WebSocket Server", () => { expect(persistedCommands.has("script.run-tests.run")).toBe(true); expect(upsertResponse.result).toEqual({ keybindings: compileKeybindings(persistedConfig), + issues: [], }); const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); @@ -640,6 +759,7 @@ describe("WebSocket Server", () => { cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), + issues: [], }); }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 281977ce31a5..03e3e11e93cd 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -208,13 +208,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } - const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) { - const push: WsPush = { - type: "push", - channel: WS_CHANNELS.terminalEvent, - data: event, - }; - const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push); + const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush)); + const broadcastPush = Effect.fnUntraced(function* (push: WsPush) { + const message = yield* encodePush(push); let recipients = 0; for (const client of yield* Ref.get(clients)) { if (client.readyState === client.OPEN) { @@ -225,6 +221,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logOutgoingPush(push, recipients); }); + const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) { + yield* broadcastPush({ + type: "push", + channel: WS_CHANNELS.terminalEvent, + data: event, + }); + }); + // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { // In dev mode, redirect to Vite dev server @@ -317,20 +321,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => Effect.gen(function* () { - const push: WsPush = { + yield* broadcastPush({ type: "push", channel: ORCHESTRATION_WS_CHANNELS.domainEvent, data: event, - }; - const message = yield* Schema.encodeEffect(Schema.fromJsonString(WsPush))(push); - let recipients = 0; - for (const client of yield* Ref.get(clients)) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } - } - logOutgoingPush(push, recipients); + }); + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(keybindingsManager.changes, (event) => + Effect.gen(function* () { + yield* broadcastPush({ + type: "push", + channel: WS_CHANNELS.serverConfigUpdated, + data: event, + }); }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -472,13 +477,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } case WS_METHODS.serverGetConfig: - const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; - return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig }; + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + return { + cwd, + keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + }; case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); - return { keybindings: keybindingsConfig }; + return { keybindings: keybindingsConfig, issues: [] }; } default: { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5a502581d004..cb34f075a518 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -3,15 +3,17 @@ import { createRootRouteWithContext, type ErrorComponentProps, } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; -import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; +import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; -import { onServerWelcome } from "../wsNativeApi"; +import { preferredTerminalEditor } from "../terminal-links"; +import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; export const Route = createRootRouteWithContext<{ @@ -119,6 +121,7 @@ function errorDetails(error: unknown): string { function EventRouter() { const { dispatch } = useStore(); const queryClient = useQueryClient(); + const lastConfigIssuesSignatureRef = useRef(null); useEffect(() => { const api = readNativeApi(); @@ -170,10 +173,56 @@ function EventRouter() { const unsubWelcome = onServerWelcome(() => { void syncSnapshot(); }); + const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { + const signature = JSON.stringify(payload.issues); + if (lastConfigIssuesSignatureRef.current === signature) { + return; + } + lastConfigIssuesSignatureRef.current = signature; + + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + if (!issue) { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } + + toastManager.add({ + type: "warning", + title: "Invalid keybindings configuration", + description: issue.message, + actionProps: { + children: "Open keybindings.json", + onClick: () => { + void queryClient + .ensureQueryData(serverConfigQueryOptions()) + .then((config) => + api.shell.openInEditor( + config.keybindingsConfigPath, + preferredTerminalEditor(), + ), + ) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }); + }); + }, + }, + }); + }); return () => { disposed = true; unsubDomainEvent(); unsubWelcome(); + unsubServerConfigUpdated(); }; }, [dispatch, queryClient]); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index b34fc0be4d02..357c605996e8 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -96,6 +96,57 @@ describe("wsNativeApi", () => { }); }); + it("delivers and caches valid server.configUpdated payloads", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); + + createWsNativeApi(); + const listener = vi.fn(); + onServerConfigUpdated(listener); + + const payload = { + issues: [ + { + kind: "keybindings.invalid-entry", + index: 1, + message: "Entry at index 1 is invalid.", + }, + ], + } as const; + emitPush(WS_CHANNELS.serverConfigUpdated, payload); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(payload); + + const lateListener = vi.fn(); + onServerConfigUpdated(lateListener); + expect(lateListener).toHaveBeenCalledTimes(1); + expect(lateListener).toHaveBeenCalledWith(payload); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("drops malformed server.configUpdated payloads", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); + + createWsNativeApi(); + const listener = vi.fn(); + onServerConfigUpdated(listener); + + emitPush(WS_CHANNELS.serverConfigUpdated, { + issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], + }); + emitPush(WS_CHANNELS.serverConfigUpdated, { + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + it("forwards valid terminal and orchestration events", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f156e22adce6..ec147794e4a4 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, type NativeApi, + ServerConfigUpdatedPayload, TerminalEvent, WS_CHANNELS, WS_METHODS, @@ -15,7 +16,9 @@ import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); +const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); let lastWelcome: WsWelcomePayload | null = null; +let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null; const decodeAndWarnOnFailure = ( schema: Schema.Schema & { readonly DecodingServices: never }, @@ -55,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): }; } +/** + * Subscribe to server config update events. Replays the latest update for + * late subscribers to avoid missing config validation feedback. + */ +export function onServerConfigUpdated( + listener: (payload: ServerConfigUpdatedPayload) => void, +): () => void { + serverConfigUpdatedListeners.add(listener); + + if (lastServerConfigUpdated) { + try { + listener(lastServerConfigUpdated); + } catch { + // Swallow listener errors + } + } + + return () => { + serverConfigUpdatedListeners.delete(listener); + }; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; @@ -74,6 +99,18 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => { + const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data); + if (!payload) return; + lastServerConfigUpdated = payload; + for (const listener of serverConfigUpdatedListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors + } + } + }); const api: NativeApi = { dialogs: { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6e52f8b81d75..0db1180abfaf 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -2,10 +2,33 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; +export const KeybindingsMalformedConfigIssue = Schema.Struct({ + kind: Schema.Literal("keybindings.malformed-config"), + message: TrimmedNonEmptyString, +}); +export type KeybindingsMalformedConfigIssue = typeof KeybindingsMalformedConfigIssue.Type; + +export const KeybindingsInvalidEntryIssue = Schema.Struct({ + kind: Schema.Literal("keybindings.invalid-entry"), + message: TrimmedNonEmptyString, + index: Schema.Number, +}); +export type KeybindingsInvalidEntryIssue = typeof KeybindingsInvalidEntryIssue.Type; + +export const ServerConfigIssue = Schema.Union([ + KeybindingsMalformedConfigIssue, + KeybindingsInvalidEntryIssue, +]); +export type ServerConfigIssue = typeof ServerConfigIssue.Type; + +export const ServerConfigIssues = Schema.Array(ServerConfigIssue); +export type ServerConfigIssues = typeof ServerConfigIssues.Type; + export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, + issues: ServerConfigIssues, }); export type ServerConfig = typeof ServerConfig.Type; @@ -14,5 +37,11 @@ export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Typ export const ServerUpsertKeybindingResult = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, + issues: ServerConfigIssues, }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; + +export const ServerConfigUpdatedPayload = Schema.Struct({ + issues: ServerConfigIssues, +}); +export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index f19f39b967c1..e8b0e073244e 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -73,6 +73,7 @@ export const WS_METHODS = { export const WS_CHANNELS = { terminalEvent: "terminal.event", serverWelcome: "server.welcome", + serverConfigUpdated: "server.configUpdated", } as const; // -- Tagged Union of all request body schemas ─────────────────────────