Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +52,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
host: undefined,
authToken: undefined,
keybindingsConfigPath: path.join(statedir, "keybindings.json"),
themesConfigPath: path.join(statedir, "themes.json"),
staticDir: undefined,
devUrl: undefined,
noBrowser: false,
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type RuntimeMode,
type ServerConfigShape,
} from "./config";
import { fixPath, resolveStateDir } from "./os-jank";
import { fixPath, resolveStateDir, resolveThemesConfigPath } from "./os-jank";
import { Open } from "./open";
import * as SqlitePersistence from "./persistence/Layers/Sqlite";
import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
Expand Down Expand Up @@ -169,6 +169,7 @@ const ServerConfigLive = (input: CliInput) =>
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 ??
Expand All @@ -179,6 +180,7 @@ const ServerConfigLive = (input: CliInput) =>
port,
cwd: cliConfig.cwd,
keybindingsConfigPath,
themesConfigPath,
host,
stateDir,
staticDir,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
2 changes: 2 additions & 0 deletions apps/server/src/serverLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -127,5 +128,6 @@ export function makeServerRuntimeServicesLayer() {
gitManagerLayer,
terminalLayer,
KeybindingsLive,
ThemesLive,
).pipe(Layer.provideMerge(NodeServices.layer));
}
156 changes: 156 additions & 0 deletions apps/server/src/themes.test.ts
Original file line number Diff line number Diff line change
@@ -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())),
);
});
Loading
Loading