diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d..28012d962a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -21,6 +21,7 @@ export interface ServerConfigShape { readonly host: string | undefined; readonly cwd: string; readonly keybindingsConfigPath: string; + readonly themesConfigPath: string; readonly stateDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; @@ -51,6 +52,7 @@ export class ServerConfig extends ServiceMap.Service const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const themesConfigPath = yield* resolveThemesConfigPath(stateDir); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -179,6 +180,7 @@ const ServerConfigLive = (input: CliInput) => port, cwd: cliConfig.cwd, keybindingsConfigPath, + themesConfigPath, host, stateDir, staticDir, diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..7518af93ef 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -34,3 +34,12 @@ export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { } return resolve(yield* expandHomePath(raw.trim())); }); + +export const resolveThemesConfigPath = Effect.fn(function* (stateDir: string) { + const path = yield* Path.Path; + const normalizedStateDir = path.resolve(stateDir); + if (path.basename(normalizedStateDir) === "userdata") { + return path.join(path.dirname(normalizedStateDir), "themes.json"); + } + return path.join(normalizedStateDir, "themes.json"); +}); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96f..ba8d4252cc 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -28,6 +28,7 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; +import { ThemesLive } from "./themes"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -127,5 +128,6 @@ export function makeServerRuntimeServicesLayer() { gitManagerLayer, terminalLayer, KeybindingsLive, + ThemesLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/themes.test.ts b/apps/server/src/themes.test.ts new file mode 100644 index 0000000000..abdca3adf5 --- /dev/null +++ b/apps/server/src/themes.test.ts @@ -0,0 +1,156 @@ +import { ThemePaletteConfig, type ThemePaletteDefinition } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { ServerConfig, type ServerConfigShape } from "./config"; +import { Themes, ThemesLive } from "./themes"; + +const ThemePaletteConfigJson = Schema.fromJsonString(ThemePaletteConfig); + +const makeThemesLayer = () => + ThemesLive.pipe( + Layer.provideMerge( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-themes-test-" }); + const configPath = join(dir, "themes.json"); + return { themesConfigPath: configPath } as ServerConfigShape; + }), + ), + ), + ); + +const writeThemesConfig = (configPath: string, themes: readonly ThemePaletteDefinition[]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeEffect(ThemePaletteConfigJson)(themes); + yield* fileSystem.writeFileString(configPath, encoded); + }); + +const readThemesConfig = (configPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const rawConfig = yield* fileSystem.readFileString(configPath); + return yield* Schema.decodeUnknownEffect(ThemePaletteConfigJson)(rawConfig); + }); + +it.layer(NodeServices.layer)("themes", (it) => { + it.effect("bootstraps an empty themes config when the file is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { themesConfigPath } = yield* ServerConfig; + assert.isFalse(yield* fs.exists(themesConfigPath)); + + yield* Effect.gen(function* () { + const themes = yield* Themes; + yield* themes.syncDefaultThemesOnStartup; + }); + + const persisted = yield* readThemesConfig(themesConfigPath); + assert.deepEqual(persisted, []); + }).pipe(Effect.provide(makeThemesLayer())), + ); + + it.effect("reports malformed config while falling back to no custom themes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { themesConfigPath } = yield* ServerConfig; + yield* fs.writeFileString(themesConfigPath, "{ not-json"); + + const configState = yield* Effect.gen(function* () { + const themes = yield* Themes; + return yield* themes.loadConfigState; + }); + + assert.deepEqual(configState.themes, []); + assert.deepEqual(configState.issues, [ + { + kind: "themes.malformed-config", + message: configState.issues[0]?.message ?? "", + }, + ]); + assert.equal(yield* fs.readFileString(themesConfigPath), "{ not-json"); + }).pipe(Effect.provide(makeThemesLayer())), + ); + + it.effect("keeps valid custom themes and reports invalid entries", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { themesConfigPath } = yield* ServerConfig; + yield* fs.writeFileString( + themesConfigPath, + JSON.stringify([ + { + id: "midnight-mint", + label: "Midnight Mint", + dark: { + background: "oklch(0.17 0.02 220)", + primary: "oklch(0.79 0.16 170)", + }, + }, + { + id: "Invalid Id", + label: "Broken", + }, + ]), + ); + + const configState = yield* Effect.gen(function* () { + const themes = yield* Themes; + return yield* themes.loadConfigState; + }); + + assert.deepEqual(configState.themes, [ + { + id: "midnight-mint", + label: "Midnight Mint", + dark: { + background: "oklch(0.17 0.02 220)", + primary: "oklch(0.79 0.16 170)", + }, + }, + ]); + assert.deepEqual(configState.issues, [ + { + kind: "themes.invalid-entry", + index: 1, + message: configState.issues[0]?.message ?? "", + }, + ]); + }).pipe(Effect.provide(makeThemesLayer())), + ); + + it.effect("persists valid custom themes without mutation", () => + Effect.gen(function* () { + const { themesConfigPath } = yield* ServerConfig; + yield* writeThemesConfig(themesConfigPath, [ + { + id: "aurora", + label: "Aurora", + light: { + primary: "oklch(0.61 0.17 210)", + }, + }, + ]); + + const configState = yield* Effect.gen(function* () { + const themes = yield* Themes; + return yield* themes.loadConfigState; + }); + + assert.deepEqual(configState.themes, [ + { + id: "aurora", + label: "Aurora", + light: { + primary: "oklch(0.61 0.17 210)", + }, + }, + ]); + assert.deepEqual(configState.issues, []); + }).pipe(Effect.provide(makeThemesLayer())), + ); +}); diff --git a/apps/server/src/themes.ts b/apps/server/src/themes.ts new file mode 100644 index 0000000000..5eedb0c2e5 --- /dev/null +++ b/apps/server/src/themes.ts @@ -0,0 +1,283 @@ +import { + ThemePaletteConfig, + ThemePaletteDefinition, + type ServerConfigIssue, +} from "@t3tools/contracts"; +import { + Cache, + Cause, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Schema, + SchemaGetter, + Ref, + Scope, + ServiceMap, + Stream, +} from "effect"; +import * as Semaphore from "effect/Semaphore"; +import { ServerConfig } from "./config"; + +export class ThemesConfigError extends Schema.TaggedErrorClass()( + "ThemesConfigError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse themes config at ${this.configPath}: ${this.detail}`; + } +} + +const RawThemeEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); +const ThemePaletteConfigJson = Schema.fromJsonString(ThemePaletteConfig); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const ThemePaletteConfigPrettyJson = ThemePaletteConfigJson.pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); + +export interface ThemesConfigState { + readonly themes: readonly ThemePaletteDefinition[]; + readonly issues: readonly ServerConfigIssue[]; +} + +export interface ThemesChangeEvent { + readonly themes: readonly ThemePaletteDefinition[]; + readonly issues: readonly ServerConfigIssue[]; +} + +function trimIssueMessage(message: string): string { + const trimmed = message.trim(); + return trimmed.length > 0 ? trimmed : "Invalid themes configuration."; +} + +function malformedConfigIssue(detail: string): ServerConfigIssue { + return { + kind: "themes.malformed-config", + message: trimIssueMessage(detail), + }; +} + +function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { + return { + kind: "themes.invalid-entry", + index, + message: trimIssueMessage(detail), + }; +} + +export interface ThemesShape { + readonly start: Effect.Effect; + readonly ready: Effect.Effect; + readonly syncDefaultThemesOnStartup: Effect.Effect; + readonly loadConfigState: Effect.Effect; + readonly getSnapshot: Effect.Effect; + readonly streamChanges: Stream.Stream; +} + +export class Themes extends ServiceMap.Service()("t3/themes") {} + +const makeThemes = Effect.gen(function* () { + const { themesConfigPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const updateSemaphore = yield* Semaphore.make(1); + const changesPubSub = yield* PubSub.unbounded(); + const startedRef = yield* Ref.make(false); + const startedDeferred = yield* Deferred.make(); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const emitChange = (configState: ThemesConfigState) => + PubSub.publish(changesPubSub, configState).pipe(Effect.asVoid); + + const readConfigExists = fs.exists(themesConfigPath).pipe( + Effect.mapError( + (cause) => + new ThemesConfigError({ + configPath: themesConfigPath, + detail: "failed to access themes config", + cause, + }), + ), + ); + + const readRawConfig = fs.readFileString(themesConfigPath).pipe( + Effect.mapError( + (cause) => + new ThemesConfigError({ + configPath: themesConfigPath, + detail: "failed to read themes config", + cause, + }), + ), + ); + + const writeConfigAtomically = (themes: readonly ThemePaletteDefinition[]) => { + const tempPath = `${themesConfigPath}.${process.pid}.${Date.now()}.tmp`; + + return Schema.encodeEffect(ThemePaletteConfigPrettyJson)(themes).pipe( + Effect.map((encoded) => `${encoded}\n`), + Effect.tap(() => fs.makeDirectory(path.dirname(themesConfigPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, themesConfigPath)), + Effect.mapError( + (cause) => + new ThemesConfigError({ + configPath: themesConfigPath, + detail: "failed to write themes config", + cause, + }), + ), + ); + }; + + const loadConfigStateFromDisk = Effect.gen(function* (): Effect.fn.Return< + ThemesConfigState, + ThemesConfigError + > { + if (!(yield* readConfigExists)) { + return { themes: [], issues: [] }; + } + + const rawConfig = yield* readRawConfig; + const decodedEntries = Schema.decodeUnknownExit(RawThemeEntries)(rawConfig); + if (decodedEntries._tag === "Failure") { + const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`; + return { + themes: [], + issues: [malformedConfigIssue(detail)], + }; + } + + const themes: ThemePaletteDefinition[] = []; + const issues: ServerConfigIssue[] = []; + for (const [index, entry] of decodedEntries.value.entries()) { + const decodedTheme = Schema.decodeUnknownExit(ThemePaletteDefinition)(entry); + if (decodedTheme._tag === "Failure") { + const detail = Cause.pretty(decodedTheme.cause); + issues.push(invalidEntryIssue(index, detail)); + yield* Effect.logWarning("ignoring invalid theme entry", { + path: themesConfigPath, + index, + entry, + error: detail, + }); + continue; + } + + themes.push(decodedTheme.value); + } + + return { themes, issues }; + }); + + const configCacheKey = "themes" as const; + const configCache = yield* Cache.make< + typeof configCacheKey, + ThemesConfigState, + ThemesConfigError + >({ + capacity: 1, + lookup: () => loadConfigStateFromDisk, + }); + const loadConfigStateFromCacheOrDisk = Cache.get(configCache, configCacheKey); + + const revalidateAndEmit = updateSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(configCache, configCacheKey); + const configState = yield* loadConfigStateFromCacheOrDisk; + yield* emitChange(configState); + }), + ); + + const syncDefaultThemesOnStartup = updateSemaphore.withPermits(1)( + Effect.gen(function* () { + if (yield* readConfigExists) { + yield* Cache.invalidate(configCache, configCacheKey); + return; + } + + yield* writeConfigAtomically([]); + yield* Cache.invalidate(configCache, configCacheKey); + }), + ); + + const startWatcher = Effect.gen(function* () { + const themesConfigDir = path.dirname(themesConfigPath); + const themesConfigFile = path.basename(themesConfigPath); + const themesConfigPathResolved = path.resolve(themesConfigPath); + + yield* fs.makeDirectory(themesConfigDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ThemesConfigError({ + configPath: themesConfigPath, + detail: "failed to prepare themes config directory", + cause, + }), + ), + ); + + const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + + yield* Stream.runForEach(fs.watch(themesConfigDir), (event) => { + const isTargetConfigEvent = + event.path === themesConfigFile || + event.path === themesConfigPath || + path.resolve(themesConfigDir, event.path) === themesConfigPathResolved; + if (!isTargetConfigEvent) { + return Effect.void; + } + return revalidateAndEmitSafely; + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); + }); + + const start = Effect.gen(function* () { + const alreadyStarted = yield* Ref.get(startedRef); + if (alreadyStarted) { + return yield* Deferred.await(startedDeferred); + } + + yield* Ref.set(startedRef, true); + const startup = Effect.gen(function* () { + yield* startWatcher; + yield* syncDefaultThemesOnStartup; + yield* Cache.invalidate(configCache, configCacheKey); + yield* loadConfigStateFromCacheOrDisk; + }); + + const startupExit = yield* Effect.exit(startup); + if (startupExit._tag === "Failure") { + yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); + return yield* Effect.failCause(startupExit.cause); + } + + yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); + }); + + return { + start, + ready: Deferred.await(startedDeferred), + syncDefaultThemesOnStartup, + loadConfigState: loadConfigStateFromCacheOrDisk, + getSnapshot: loadConfigStateFromCacheOrDisk, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ThemesShape; +}); + +export const ThemesLive = Layer.effect(Themes, makeThemes); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..3fcfebd215 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -503,6 +503,7 @@ describe("WebSocket Server", () => { host: undefined, cwd: options.cwd ?? "/test/project", keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + themesConfigPath: path.join(stateDir, "themes.json"), stateDir, staticDir: options.staticDir, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, @@ -827,7 +828,9 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + themesConfigPath: path.join(stateDir, "themes.json"), keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + customThemes: [], issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), @@ -852,7 +855,9 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + themesConfigPath: path.join(stateDir, "themes.json"), keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + customThemes: [], issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), @@ -882,7 +887,9 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + themesConfigPath: path.join(stateDir, "themes.json"), keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + customThemes: [], issues: [ { kind: "keybindings.malformed-config", @@ -921,13 +928,17 @@ describe("WebSocket Server", () => { const result = response.result as { cwd: string; keybindingsConfigPath: string; + themesConfigPath: string; keybindings: ResolvedKeybindingsConfig; + customThemes: Array<{ id: string }>; issues: Array<{ kind: string; index?: number; message: string }>; providers: ReadonlyArray; availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); expect(result.keybindingsConfigPath).toBe(keybindingsPath); + expect(result.themesConfigPath).toBe(path.join(stateDir, "themes.json")); + expect(result.customThemes).toEqual([]); expect(result.issues).toEqual([ { kind: "keybindings.invalid-entry", @@ -971,6 +982,7 @@ describe("WebSocket Server", () => { expect(malformedPush.data).toEqual({ issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], providers: defaultProviderStatuses, + updated: ["keybindings"], }); const successPush = await rewriteKeybindingsAndWaitForPush( @@ -979,7 +991,11 @@ describe("WebSocket Server", () => { "[]", (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ + issues: [], + providers: defaultProviderStatuses, + updated: ["keybindings"], + }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -1034,7 +1050,9 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + themesConfigPath: path.join(stateDir, "themes.json"), keybindings: compileKeybindings(persistedConfig), + customThemes: [], issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), @@ -1081,7 +1099,9 @@ describe("WebSocket Server", () => { expect(configResponse.result).toEqual({ cwd: "/my/workspace", keybindingsConfigPath: keybindingsPath, + themesConfigPath: path.join(stateDir, "themes.json"), keybindings: compileKeybindings(persistedConfig), + customThemes: [], issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..6429f279fc 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -49,6 +49,7 @@ import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { Themes } from "./themes"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -216,6 +217,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings + | Themes | Open | AnalyticsService; @@ -241,6 +243,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< port, cwd, keybindingsConfigPath, + themesConfigPath, staticDir, devUrl, authToken, @@ -253,6 +256,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; + const themesManager = yield* Themes; const providerHealth = yield* ProviderHealth; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; @@ -267,6 +271,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ), ); + yield* themesManager.syncDefaultThemesOnStartup.pipe( + Effect.catch((error) => + Effect.logWarning("failed to sync themes defaults on startup", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); const providerStatuses = yield* providerHealth.getStatuses; @@ -294,6 +307,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), ), ); + yield* themesManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "themesRuntimeStart", cause }), + ), + ); yield* readiness.markKeybindingsReady; const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { @@ -615,6 +633,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: event.issues, providers: providerStatuses, + updated: ["keybindings"], + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(themesManager.streamChanges, (event) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + providers: providerStatuses, + updated: ["themes"], }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -868,11 +895,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const themesConfig = yield* themesManager.loadConfigState; return { cwd, keybindingsConfigPath, + themesConfigPath, keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, + customThemes: themesConfig.themes, + issues: [...keybindingsConfig.issues, ...themesConfig.issues], providers: providerStatuses, availableEditors, }; diff --git a/apps/server/src/wsServer/pushBus.test.ts b/apps/server/src/wsServer/pushBus.test.ts index 172944607b..6f56763d1a 100644 --- a/apps/server/src/wsServer/pushBus.test.ts +++ b/apps/server/src/wsServer/pushBus.test.ts @@ -54,6 +54,7 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], providers: [], + updated: ["keybindings"], }); const delivered = yield* pushBus.publishClient( @@ -71,6 +72,7 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [], providers: [], + updated: ["themes"], }); yield* Effect.promise(() => client.waitForSentCount(2)); @@ -96,6 +98,7 @@ describe("makeServerPushBus", () => { data: { issues: [], providers: [], + updated: ["themes"], }, }); }), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7b31dbdf38..039dc35c90 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -103,7 +103,9 @@ function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + themesConfigPath: "/repo/project/.t3/themes.json", keybindings: [], + customThemes: [], issues: [], providers: [ { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f4320..25f35cb3ed 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -41,7 +41,9 @@ function createBaseServerConfig(): ServerConfig { return { cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + themesConfigPath: "/repo/project/.t3/themes.json", keybindings: [], + customThemes: [], issues: [], providers: [ { @@ -203,6 +205,7 @@ function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: stri data: { issues, providers: fixture.serverConfig.providers, + updated: ["keybindings"], }, }), ); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6afe83dfe3..d823d90e4b 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,49 +1,86 @@ -import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; +import type { ThemePaletteDefinition } from "@t3tools/contracts"; +import { + DEFAULT_THEME_PALETTE_ID, + getThemePaletteCatalog, + type ThemePalette, + type ThemePreference, +} from "../lib/themePalettes"; -type Theme = "light" | "dark" | "system"; type ThemeSnapshot = { - theme: Theme; - systemDark: boolean; + readonly theme: ThemePreference; + readonly systemDark: boolean; + readonly requestedPaletteId: string; + readonly paletteId: string; + readonly palette: ThemePalette; + readonly palettes: readonly ThemePalette[]; + readonly resolvedTheme: "light" | "dark"; }; -const STORAGE_KEY = "t3code:theme"; +const THEME_STORAGE_KEY = "t3code:theme"; +const PALETTE_STORAGE_KEY = "t3code:theme-palette"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; -let listeners: Array<() => void> = []; +let listeners = new Set<() => void>(); let lastSnapshot: ThemeSnapshot | null = null; -let lastDesktopTheme: Theme | null = null; +let lastDesktopTheme: ThemePreference | null = null; +let customThemes: readonly ThemePaletteDefinition[] = []; +let customThemesSerialized = "[]"; +let customThemesRevision = 0; +let cachedPalettes: { revision: number; palettes: readonly ThemePalette[] } | null = null; + function emitChange() { for (const listener of listeners) listener(); } -function getSystemDark(): boolean { +function getSystemDark() { + if (typeof window.matchMedia !== "function") { + return false; + } return window.matchMedia(MEDIA_QUERY).matches; } -function getStored(): Theme { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw === "light" || raw === "dark" || raw === "system") return raw; +function getStoredTheme(): ThemePreference { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + if (raw === "light" || raw === "dark" || raw === "system") { + return raw; + } return "system"; } -function applyTheme(theme: Theme, suppressTransitions = false) { - if (suppressTransitions) { - document.documentElement.classList.add("no-transitions"); +function getStoredPaletteId() { + const raw = localStorage.getItem(PALETTE_STORAGE_KEY)?.trim(); + return raw && raw.length > 0 ? raw : DEFAULT_THEME_PALETTE_ID; +} + +function getPaletteCatalog() { + if (cachedPalettes && cachedPalettes.revision === customThemesRevision) { + return cachedPalettes.palettes; } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); - document.documentElement.classList.toggle("dark", isDark); - syncDesktopTheme(theme); - if (suppressTransitions) { - // Force a reflow so the no-transitions class takes effect before removal - // oxlint-disable-next-line no-unused-expressions - document.documentElement.offsetHeight; - requestAnimationFrame(() => { - document.documentElement.classList.remove("no-transitions"); - }); + + const palettes = getThemePaletteCatalog(customThemes); + cachedPalettes = { + revision: customThemesRevision, + palettes, + }; + return palettes; +} + +function resolvePalette(paletteId: string) { + const palettes = getPaletteCatalog(); + const fallbackPalette = + palettes.find((candidate) => candidate.id === DEFAULT_THEME_PALETTE_ID) ?? palettes[0]; + if (!fallbackPalette) { + throw new Error("No theme palettes are registered."); } + + return { + palettes, + palette: palettes.find((candidate) => candidate.id === paletteId) ?? fallbackPalette, + }; } -function syncDesktopTheme(theme: Theme) { +function syncDesktopTheme(theme: ThemePreference) { const bridge = window.desktopBridge; if (!bridge || lastDesktopTheme === theme) { return; @@ -57,65 +94,127 @@ function syncDesktopTheme(theme: Theme) { }); } -// Apply immediately on module load to prevent flash -applyTheme(getStored()); +function applyThemeSnapshot(snapshot: ThemeSnapshot, suppressTransitions = false) { + if (suppressTransitions) { + document.documentElement.classList.add("no-transitions"); + } + + document.documentElement.classList.toggle("dark", snapshot.resolvedTheme === "dark"); + document.documentElement.dataset.themePalette = snapshot.paletteId; + document.documentElement.dataset.themeMode = snapshot.resolvedTheme; + + const paletteTokens = + snapshot.resolvedTheme === "dark" ? snapshot.palette.dark : snapshot.palette.light; + for (const [token, value] of Object.entries(paletteTokens)) { + document.documentElement.style.setProperty(`--${token}`, value); + } + + syncDesktopTheme(snapshot.theme); + + if (suppressTransitions) { + // Force a reflow so the no-transitions class takes effect before removal. + // oxlint-disable-next-line no-unused-expressions + document.documentElement.offsetHeight; + requestAnimationFrame(() => { + document.documentElement.classList.remove("no-transitions"); + }); + } +} function getSnapshot(): ThemeSnapshot { - const theme = getStored(); + const theme = getStoredTheme(); const systemDark = theme === "system" ? getSystemDark() : false; - - if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { + const resolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme; + const requestedPaletteId = getStoredPaletteId(); + const { palette, palettes } = resolvePalette(requestedPaletteId); + + if ( + lastSnapshot && + lastSnapshot.theme === theme && + lastSnapshot.systemDark === systemDark && + lastSnapshot.requestedPaletteId === requestedPaletteId && + lastSnapshot.paletteId === palette.id && + lastSnapshot.palettes === palettes + ) { return lastSnapshot; } - lastSnapshot = { theme, systemDark }; + lastSnapshot = { + theme, + systemDark, + requestedPaletteId, + paletteId: palette.id, + palette, + palettes, + resolvedTheme, + }; return lastSnapshot; } -function subscribe(listener: () => void): () => void { - listeners.push(listener); +function updateSnapshot(suppressTransitions = false) { + const snapshot = getSnapshot(); + applyThemeSnapshot(snapshot, suppressTransitions); + emitChange(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); - // Listen for system preference changes - const mq = window.matchMedia(MEDIA_QUERY); const handleChange = () => { - if (getStored() === "system") applyTheme("system", true); - emitChange(); + if (getStoredTheme() === "system") { + updateSnapshot(true); + } }; - mq.addEventListener("change", handleChange); + const mq = typeof window.matchMedia === "function" ? window.matchMedia(MEDIA_QUERY) : null; + mq?.addEventListener("change", handleChange); - // Listen for storage changes from other tabs - const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { - applyTheme(getStored(), true); - emitChange(); + const handleStorage = (event: StorageEvent) => { + if (event.key === THEME_STORAGE_KEY || event.key === PALETTE_STORAGE_KEY) { + updateSnapshot(true); } }; window.addEventListener("storage", handleStorage); return () => { - listeners = listeners.filter((l) => l !== listener); - mq.removeEventListener("change", handleChange); + listeners.delete(listener); + mq?.removeEventListener("change", handleChange); window.removeEventListener("storage", handleStorage); }; } -export function useTheme() { - const snapshot = useSyncExternalStore(subscribe, getSnapshot); - const theme = snapshot.theme; +export function setCustomThemes(nextThemes: readonly ThemePaletteDefinition[]) { + const nextSerialized = JSON.stringify(nextThemes); + if (nextSerialized === customThemesSerialized) { + return; + } - const resolvedTheme: "light" | "dark" = - theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; + customThemes = nextThemes; + customThemesSerialized = nextSerialized; + customThemesRevision += 1; + cachedPalettes = null; + updateSnapshot(true); +} - const setTheme = useCallback((next: Theme) => { - localStorage.setItem(STORAGE_KEY, next); - applyTheme(next, true); - emitChange(); - }, []); +export function useTheme() { + const snapshot = useSyncExternalStore(subscribe, getSnapshot); - // Keep DOM in sync on mount/change - useEffect(() => { - applyTheme(theme); - }, [theme]); + return { + theme: snapshot.theme, + resolvedTheme: snapshot.resolvedTheme, + palette: snapshot.palette, + paletteId: snapshot.paletteId, + palettes: snapshot.palettes, + setTheme(next: ThemePreference) { + localStorage.setItem(THEME_STORAGE_KEY, next); + updateSnapshot(true); + }, + setPaletteId(nextPaletteId: string) { + localStorage.setItem(PALETTE_STORAGE_KEY, nextPaletteId); + updateSnapshot(true); + }, + } as const; +} - return { theme, setTheme, resolvedTheme } as const; +if (typeof window !== "undefined" && typeof document !== "undefined") { + applyThemeSnapshot(getSnapshot()); } diff --git a/apps/web/src/lib/themePalettes.test.ts b/apps/web/src/lib/themePalettes.test.ts new file mode 100644 index 0000000000..c25df4e7f2 --- /dev/null +++ b/apps/web/src/lib/themePalettes.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_THEME_PALETTE_ID, getThemePaletteCatalog } from "./themePalettes"; + +describe("getThemePaletteCatalog", () => { + it("keeps built-in palettes ahead of custom themes", () => { + const palettes = getThemePaletteCatalog([ + { + id: "midnight-mint", + label: "Midnight Mint", + dark: { + primary: "oklch(0.79 0.16 170)", + ring: "oklch(0.79 0.16 170)", + }, + }, + ]); + + expect(palettes[0]?.id).toBe(DEFAULT_THEME_PALETTE_ID); + expect(palettes.at(-1)?.id).toBe("midnight-mint"); + expect(palettes.at(-1)?.source).toBe("custom"); + }); + + it("merges custom palettes with the default token set for both modes", () => { + const palette = getThemePaletteCatalog([ + { + id: "midnight-mint", + label: "Midnight Mint", + dark: { + primary: "oklch(0.79 0.16 170)", + }, + }, + ]).find((candidate) => candidate.id === "midnight-mint"); + + expect(palette).toBeDefined(); + expect(palette?.dark.primary).toBe("oklch(0.79 0.16 170)"); + expect(palette?.dark.background).toBeTruthy(); + expect(palette?.light.primary).toBeTruthy(); + }); +}); diff --git a/apps/web/src/lib/themePalettes.ts b/apps/web/src/lib/themePalettes.ts new file mode 100644 index 0000000000..6e10c5634d --- /dev/null +++ b/apps/web/src/lib/themePalettes.ts @@ -0,0 +1,243 @@ +import { + THEME_TOKEN_NAMES, + type ThemePaletteDefinition, + type ThemeTokenName, +} from "@t3tools/contracts"; + +export type ResolvedThemeMode = "light" | "dark"; +export type ThemePreference = ResolvedThemeMode | "system"; +export type ThemeTokenMap = Record; +export type ThemeTokenOverrides = Partial; + +export interface ThemePalette { + readonly id: string; + readonly label: string; + readonly description: string; + readonly source: "built-in" | "custom"; + readonly light: ThemeTokenMap; + readonly dark: ThemeTokenMap; +} + +interface ThemePaletteInput { + readonly id: string; + readonly label: string; + readonly description: string; + readonly light?: ThemeTokenOverrides; + readonly dark?: ThemeTokenOverrides; +} + +export const DEFAULT_THEME_PALETTE_ID = "default"; + +const DEFAULT_LIGHT_TOKENS: ThemeTokenMap = { + background: "var(--color-white)", + foreground: "var(--color-neutral-800)", + card: "var(--color-white)", + "card-foreground": "var(--color-neutral-800)", + popover: "var(--color-white)", + "popover-foreground": "var(--color-neutral-800)", + primary: "oklch(0.488 0.217 264)", + "primary-foreground": "var(--color-white)", + secondary: "color-mix(in srgb, var(--color-black) 4%, transparent)", + "secondary-foreground": "var(--color-neutral-800)", + muted: "color-mix(in srgb, var(--color-black) 4%, transparent)", + "muted-foreground": "color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black))", + accent: "color-mix(in srgb, var(--color-black) 4%, transparent)", + "accent-foreground": "var(--color-neutral-800)", + destructive: "var(--color-red-500)", + "destructive-foreground": "var(--color-red-700)", + border: "color-mix(in srgb, var(--color-black) 8%, transparent)", + input: "color-mix(in srgb, var(--color-black) 10%, transparent)", + ring: "oklch(0.488 0.217 264)", + info: "var(--color-blue-500)", + "info-foreground": "var(--color-blue-700)", + success: "var(--color-emerald-500)", + "success-foreground": "var(--color-emerald-700)", + warning: "var(--color-amber-500)", + "warning-foreground": "var(--color-amber-700)", +}; + +const DEFAULT_DARK_TOKENS: ThemeTokenMap = { + background: "color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white))", + foreground: "var(--color-neutral-100)", + card: "color-mix(in srgb, var(--background) 98%, var(--color-white))", + "card-foreground": "var(--color-neutral-100)", + popover: "color-mix(in srgb, var(--background) 98%, var(--color-white))", + "popover-foreground": "var(--color-neutral-100)", + primary: "oklch(0.588 0.217 264)", + "primary-foreground": "var(--color-white)", + secondary: "color-mix(in srgb, var(--color-white) 4%, transparent)", + "secondary-foreground": "var(--color-neutral-100)", + muted: "color-mix(in srgb, var(--color-white) 4%, transparent)", + "muted-foreground": "color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white))", + accent: "color-mix(in srgb, var(--color-white) 4%, transparent)", + "accent-foreground": "var(--color-neutral-100)", + destructive: "color-mix(in srgb, var(--color-red-500) 90%, var(--color-white))", + "destructive-foreground": "var(--color-red-400)", + border: "color-mix(in srgb, var(--color-white) 6%, transparent)", + input: "color-mix(in srgb, var(--color-white) 8%, transparent)", + ring: "oklch(0.588 0.217 264)", + info: "var(--color-blue-500)", + "info-foreground": "var(--color-blue-400)", + success: "var(--color-emerald-500)", + "success-foreground": "var(--color-emerald-400)", + warning: "var(--color-amber-500)", + "warning-foreground": "var(--color-amber-400)", +}; + +const BUILT_IN_THEME_PALETTES: readonly ThemePaletteInput[] = [ + { + id: DEFAULT_THEME_PALETTE_ID, + label: "Default", + description: "Neutral surfaces with violet accents.", + }, + { + id: "ocean", + label: "Ocean", + description: "Cool blue surfaces with cyan highlights.", + light: { + background: "oklch(0.985 0.01 240)", + card: "oklch(0.995 0.004 240)", + popover: "oklch(0.995 0.004 240)", + primary: "oklch(0.55 0.18 240)", + ring: "oklch(0.55 0.18 240)", + accent: "oklch(0.93 0.02 235)", + info: "oklch(0.62 0.16 230)", + "info-foreground": "oklch(0.42 0.08 230)", + border: "color-mix(in srgb, oklch(0.55 0.18 240) 10%, transparent)", + input: "color-mix(in srgb, oklch(0.55 0.18 240) 12%, transparent)", + }, + dark: { + background: "oklch(0.18 0.03 245)", + card: "oklch(0.22 0.03 245)", + popover: "oklch(0.22 0.03 245)", + primary: "oklch(0.74 0.16 225)", + ring: "oklch(0.74 0.16 225)", + accent: "color-mix(in srgb, oklch(0.74 0.16 225) 12%, transparent)", + info: "oklch(0.78 0.12 220)", + border: "color-mix(in srgb, oklch(0.74 0.16 225) 16%, transparent)", + input: "color-mix(in srgb, oklch(0.74 0.16 225) 18%, transparent)", + }, + }, + { + id: "forest", + label: "Forest", + description: "Mossy greens with softer neutrals.", + light: { + background: "oklch(0.985 0.01 150)", + card: "oklch(0.995 0.004 150)", + popover: "oklch(0.995 0.004 150)", + primary: "oklch(0.56 0.16 155)", + ring: "oklch(0.56 0.16 155)", + success: "oklch(0.62 0.17 155)", + "success-foreground": "oklch(0.4 0.08 155)", + accent: "oklch(0.94 0.02 155)", + border: "color-mix(in srgb, oklch(0.56 0.16 155) 10%, transparent)", + input: "color-mix(in srgb, oklch(0.56 0.16 155) 12%, transparent)", + }, + dark: { + background: "oklch(0.19 0.02 155)", + card: "oklch(0.22 0.02 155)", + popover: "oklch(0.22 0.02 155)", + primary: "oklch(0.76 0.16 155)", + ring: "oklch(0.76 0.16 155)", + success: "oklch(0.78 0.15 155)", + accent: "color-mix(in srgb, oklch(0.76 0.16 155) 12%, transparent)", + border: "color-mix(in srgb, oklch(0.76 0.16 155) 16%, transparent)", + input: "color-mix(in srgb, oklch(0.76 0.16 155) 18%, transparent)", + }, + }, + { + id: "sunset", + label: "Sunset", + description: "Warm amber accents with soft rose highlights.", + light: { + background: "oklch(0.988 0.008 40)", + card: "oklch(0.998 0.004 30)", + popover: "oklch(0.998 0.004 30)", + primary: "oklch(0.66 0.17 40)", + ring: "oklch(0.66 0.17 40)", + warning: "oklch(0.74 0.16 70)", + "warning-foreground": "oklch(0.47 0.12 70)", + accent: "oklch(0.95 0.02 20)", + border: "color-mix(in srgb, oklch(0.66 0.17 40) 10%, transparent)", + input: "color-mix(in srgb, oklch(0.66 0.17 40) 12%, transparent)", + }, + dark: { + background: "oklch(0.2 0.02 28)", + card: "oklch(0.24 0.02 28)", + popover: "oklch(0.24 0.02 28)", + primary: "oklch(0.76 0.16 55)", + ring: "oklch(0.76 0.16 55)", + warning: "oklch(0.79 0.15 75)", + accent: "color-mix(in srgb, oklch(0.76 0.16 55) 12%, transparent)", + border: "color-mix(in srgb, oklch(0.76 0.16 55) 16%, transparent)", + input: "color-mix(in srgb, oklch(0.76 0.16 55) 18%, transparent)", + }, + }, +] as const; + +export const CUSTOM_THEME_FILE_EXAMPLE = `[ + { + "id": "midnight-mint", + "label": "Midnight Mint", + "description": "Custom palette loaded from ~/.t3/themes.json", + "dark": { + "background": "oklch(0.17 0.02 220)", + "card": "oklch(0.21 0.02 220)", + "primary": "oklch(0.79 0.16 170)", + "ring": "oklch(0.79 0.16 170)" + } + } +]`; + +function resolveThemePalette( + input: ThemePaletteInput | ThemePaletteDefinition, + source: ThemePalette["source"], +) { + const description = + "description" in input && typeof input.description === "string" + ? input.description + : source === "custom" + ? "Loaded from your ~/.t3 themes file." + : "Built into T3 Code."; + + return { + id: input.id, + label: input.label, + description, + source, + light: { ...DEFAULT_LIGHT_TOKENS, ...sanitizeThemeTokenOverrides(input.light) }, + dark: { ...DEFAULT_DARK_TOKENS, ...sanitizeThemeTokenOverrides(input.dark) }, + } satisfies ThemePalette; +} + +function sanitizeThemeTokenOverrides(overrides: Record | undefined) { + const sanitized: ThemeTokenOverrides = {}; + if (!overrides) { + return sanitized; + } + + for (const tokenName of THEME_TOKEN_NAMES) { + const value = overrides[tokenName]; + if (typeof value === "string") { + sanitized[tokenName] = value; + } + } + + return sanitized; +} + +const BUILT_IN_THEME_PALETTE_MAP = new Map( + BUILT_IN_THEME_PALETTES.map((palette) => [palette.id, resolveThemePalette(palette, "built-in")]), +); + +export function getBuiltInThemePalettes() { + return [...BUILT_IN_THEME_PALETTE_MAP.values()]; +} + +export function getThemePaletteCatalog(customThemes: readonly ThemePaletteDefinition[] = []) { + return [ + ...getBuiltInThemePalettes(), + ...customThemes.map((palette) => resolveThemePalette(palette, "custom")), + ]; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..54959581a3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@xterm/xterm/css/xterm.css"; import "./index.css"; +import "./hooks/useTheme"; import { isElectron } from "./env"; import { getRouter } from "./router"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..bb095257a8 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -20,6 +20,7 @@ import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDra import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; +import { setCustomThemes } from "../hooks/useTheme"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; @@ -153,6 +154,19 @@ function EventRouter() { let pending = false; let needsProviderInvalidation = false; + const syncThemeConfig = async () => { + try { + const config = await queryClient.fetchQuery(serverConfigQueryOptions()); + if (disposed) { + return null; + } + setCustomThemes(config.customThemes); + return config; + } catch { + return null; + } + }; + const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; @@ -229,9 +243,11 @@ function EventRouter() { hasRunningSubprocess, ); }); + void syncThemeConfig(); const unsubWelcome = onServerWelcome((payload) => { void (async () => { await syncSnapshot(); + await syncThemeConfig(); if (disposed) { return; } @@ -261,34 +277,79 @@ function EventRouter() { let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - if (!subscribed) return; - 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; - } + const themeIssue = payload.issues.find((entry) => entry.kind.startsWith("themes.")); + const keybindingIssue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + const themeUpdated = payload.updated.includes("themes"); + const keybindingsUpdated = payload.updated.includes("keybindings"); - toastManager.add({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionProps: { - children: "Open keybindings.json", - onClick: () => { - void queryClient - .ensureQueryData(serverConfigQueryOptions()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { + void syncThemeConfig().then((config) => { + if (!subscribed || !config) { + return; + } + + if (themeUpdated) { + if (themeIssue) { + toastManager.add({ + type: "warning", + title: "Invalid themes configuration", + description: themeIssue.message, + actionProps: { + children: "Open themes.json", + onClick: () => { + const editor = resolveAndPersistPreferredEditor(config.availableEditors); + if (!editor) { + toastManager.add({ + type: "error", + title: "Unable to open themes file", + description: "No available editors found.", + }); + return; + } + void api.shell.openInEditor(config.themesConfigPath, editor).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open themes file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }); + }); + }, + }, + }); + } + return; + } + + if (!keybindingsUpdated) { + return; + } + + if (!keybindingIssue) { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } + + toastManager.add({ + type: "warning", + title: "Invalid keybindings configuration", + description: keybindingIssue.message, + actionProps: { + children: "Open keybindings.json", + onClick: () => { + const editor = resolveAndPersistPreferredEditor(config.availableEditors); + if (!editor) { + toastManager.add({ + type: "error", + title: "Unable to open keybindings file", + description: "No available editors found.", + }); + return; + } + void api.shell.openInEditor(config.keybindingsConfigPath, editor).catch((error) => { toastManager.add({ type: "error", title: "Unable to open keybindings file", @@ -296,8 +357,9 @@ function EventRouter() { error instanceof Error ? error.message : "Unknown error opening file.", }); }); + }, }, - }, + }); }); }); subscribed = true; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e79592c99b..0d3b047148 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,6 +7,11 @@ import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../ import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { + CUSTOM_THEME_FILE_EXAMPLE, + type ResolvedThemeMode, + type ThemePalette, +} from "../lib/themePalettes"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "../components/ui/button"; @@ -92,12 +97,74 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } } +function ThemePaletteCard(props: { + palette: ThemePalette; + resolvedTheme: ResolvedThemeMode; + selected: boolean; + onSelect: () => void; +}) { + const tokens = props.resolvedTheme === "dark" ? props.palette.dark : props.palette.light; + + return ( + + ); +} + +function ThemeSwatch(props: { color: string; outline?: boolean }) { + return ( +