Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ describe("AssetAccess", () => {
cause: platformCause,
});
const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({
resolveAccent: () => Effect.succeed(null),
resolvePath: () => Effect.fail(resolutionCause),
});

Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import {
Expand Down Expand Up @@ -117,7 +118,9 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
Effect.gen(function* () {
const config = yield* makeCliTestServerConfig(baseDir);
const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe(
Layer.provide(orchestrationHttpApiLayer),
Layer.provide(
orchestrationHttpApiLayer.pipe(Layer.provide(ProjectFaviconResolver.layerLive)),
),
Layer.provide(environmentAuthenticatedAuthLayer),
);
const appLayer = HttpRouter.serve(routesLayer, {
Expand All @@ -138,6 +141,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
}),
),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(ProjectFaviconResolver.layerLive.pipe(Layer.provide(NodeServices.layer))),
Layer.provide(ServerConfig.layer(config)),
);

Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/orchestration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../auth/http.ts";
import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
import * as ProjectAccents from "../project/ProjectAccents.ts";

export const orchestrationHttpApiLayer = HttpApiBuilder.group(
EnvironmentHttpApi,
Expand Down Expand Up @@ -51,13 +52,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
Effect.fn("environment.orchestration.shellSnapshot")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationReadScope);
return yield* projectionSnapshotQuery
const snapshot = yield* projectionSnapshotQuery
.getShellSnapshot()
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_snapshot_failed", cause),
),
);
// Clients prefer this snapshot over the WS one, so it has to carry
// accents too or the sidebar repaints a round trip after it loads.
const projects = yield* ProjectAccents.withProjectAccents(snapshot.projects);
return { ...snapshot, projects };
}),
)
.handle(
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/project/ProjectAccents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { ProjectAccent } from "@t3tools/contracts";
import { it, describe, expect } from "@effect/vitest";
import * as Effect from "effect/Effect";

import * as ProjectAccents from "./ProjectAccents.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";

const resolverReturning = (
accentByRoot: Readonly<Record<string, ProjectAccent>>,
): ProjectFaviconResolver.ProjectFaviconResolver["Service"] =>
ProjectFaviconResolver.ProjectFaviconResolver.of({
resolvePath: () => Effect.succeed(null),
resolveAccent: (cwd) => Effect.succeed(accentByRoot[cwd] ?? null),
});

const failingResolver: ProjectFaviconResolver.ProjectFaviconResolver["Service"] =
ProjectFaviconResolver.ProjectFaviconResolver.of({
resolvePath: () => Effect.succeed(null),
resolveAccent: (cwd) =>
Effect.fail(
new ProjectFaviconResolver.ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause: new Error("unreadable checkout"),
}),
),
});

describe("ProjectAccents", () => {
it.effect("attaches each project's accent to the payload that creates its rows", () =>
Effect.gen(function* () {
const projects = [
{ id: "a", workspaceRoot: "/repos/one" },
{ id: "b", workspaceRoot: "/repos/two" },
{ id: "c", workspaceRoot: "/repos/three" },
];

const decorated = yield* ProjectAccents.withProjectAccents(projects).pipe(
Effect.provideService(
ProjectFaviconResolver.ProjectFaviconResolver,
resolverReturning({
"/repos/one": "#1688f0",
"/repos/two": { idle: "#071525", active: "#245181", selected: "#173b60" },
}),
),
);

expect(decorated).toEqual([
{ id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" },
{
id: "b",
workspaceRoot: "/repos/two",
accent: { idle: "#071525", active: "#245181", selected: "#173b60" },
},
// Written as an explicit null, never omitted: clearing accentColor in
// t3.json has to clear the row rather than leave the old value.
{ id: "c", workspaceRoot: "/repos/three", accent: null },
]);
}),
);

it.effect("keeps an unreadable checkout in the snapshot without an accent", () =>
Effect.gen(function* () {
const decorated = yield* ProjectAccents.withProjectAccents([
{ id: "a", workspaceRoot: "/repos/gone" },
]).pipe(
Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, failingResolver),
);

expect(decorated).toEqual([{ id: "a", workspaceRoot: "/repos/gone", accent: null }]);
}),
);

it.effect("decorates a single project the same way", () =>
Effect.gen(function* () {
const decorated = yield* ProjectAccents.withProjectAccent({
id: "a",
workspaceRoot: "/repos/one",
}).pipe(
Effect.provideService(
ProjectFaviconResolver.ProjectFaviconResolver,
resolverReturning({ "/repos/one": "#1688f0" }),
),
);

expect(decorated).toEqual({ id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" });
}),
);
});
50 changes: 50 additions & 0 deletions apps/server/src/project/ProjectAccents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* ProjectAccents - decorates client-facing project payloads with the accent
* declared in each checkout's `t3.json`.
*
* The accent is read live rather than projected because `t3.json` is a
* checked-in file the user edits by hand; an event-sourced copy would go stale
* the moment someone pulls a branch. It rides on the project record instead of
* on the favicon asset URL so sidebar rows know their accent in the same paint
* that creates them. Fetching it separately repaints the whole list a round
* trip later, which reads as a flash.
*
* @module ProjectAccents
*/
import type { ProjectAccent } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";

interface AccentTarget {
readonly workspaceRoot: string;
}

type WithAccent<T> = T & { readonly accent: ProjectAccent | null };

/**
* A project whose checkout cannot be read still belongs in the payload, so an
* unreadable workspace resolves to "no accent" rather than failing the
* snapshot. The accent is always written, never omitted: clearing `accentColor`
* in `t3.json` has to clear the row, not leave the previous value in place.
*/
const resolveAccent = Effect.fn("ProjectAccents.resolveAccent")(function* (workspaceRoot: string) {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
return yield* resolver.resolveAccent(workspaceRoot).pipe(Effect.orElseSucceed(() => null));
});

/** Attach `accent` to one project record. */
export const withProjectAccent = Effect.fn("ProjectAccents.withProjectAccent")(function* <
T extends AccentTarget,
>(project: T) {
const accent = yield* resolveAccent(project.workspaceRoot);
return { ...project, accent } as WithAccent<T>;
});

/**
* Attach `accent` to every project in a shell snapshot.
*/
export const withProjectAccents = <T extends AccentTarget>(projects: ReadonlyArray<T>) =>
Effect.forEach(projects, withProjectAccent, {
concurrency: 16,
});
45 changes: 45 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
);

it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
describe("resolveAccent", () => {
it.effect("reads the validated t3.json accent color", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "#1688f0" }');

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toBe("#1688f0");
}),
);

it.effect("reads exact accent colors for every row state", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"t3.json",
'{ "accentColor": { "idle": "#071525", "active": "#245181", "selected": "#173b60" } }',
);

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toEqual({
idle: "#071525",
active: "#245181",
selected: "#173b60",
});
}),
);

it.effect("returns null when the project file is missing or invalid", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;

const missingAccentColor = yield* resolver.resolveAccent(cwd);
expect(missingAccentColor).toBeNull();
yield* writeTextFile(cwd, "t3.json", '{ "accentColor": "blue" }');
const invalidAccentColor = yield* resolver.resolveAccent(cwd);
expect(invalidAccentColor).toBeNull();
}),
);
});

describe("resolvePath", () => {
it.effect("prefers well-known favicon files", () =>
Effect.gen(function* () {
Expand Down
46 changes: 35 additions & 11 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @module ProjectFaviconResolver
*/
import type { ProjectAccent } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -96,6 +97,9 @@ export class ProjectFaviconResolver extends Context.Service<
cwd: string,
faviconPath?: string,
) => Effect.Effect<string | null, ProjectFaviconResolutionError>;
readonly resolveAccent: (
cwd: string,
) => Effect.Effect<ProjectAccent | null, ProjectFaviconResolutionError>;
}
>()("t3/project/ProjectFaviconResolver") {}

Expand Down Expand Up @@ -129,6 +133,18 @@ export const make = Effect.gen(function* () {
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

const normalizeWorkspaceRoot = (cwd: string) =>
workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);

const resolveIconHref = (href: string): ReadonlyArray<string> => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
Expand Down Expand Up @@ -181,16 +197,7 @@ export const make = Effect.gen(function* () {
const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
)(function* (cwd, faviconPath) {
const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
Effect.mapError(
(cause) =>
new ProjectFaviconResolutionError({
operation: "normalize-workspace",
workspaceRoot: cwd,
cause,
}),
),
);
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
// A grouped project's saved path can be absent from one checkout. Use it
// where it exists and retain automatic discovery for the other checkouts.
if (faviconPath !== undefined) {
Expand Down Expand Up @@ -267,7 +274,24 @@ export const make = Effect.gen(function* () {
return null;
});

return ProjectFaviconResolver.of({ resolvePath });
const resolveAccent: ProjectFaviconResolver["Service"]["resolveAccent"] = Effect.fn(
"ProjectFaviconResolver.resolveAccent",
)(function* (cwd) {
const projectCwd = yield* normalizeWorkspaceRoot(cwd);
const projectFile = yield* projectFileLoader.load(projectCwd);
return Option.isSome(projectFile) ? (projectFile.value.accentColor ?? null) : null;
});

return ProjectFaviconResolver.of({ resolveAccent, resolvePath });
});

export const layer = Layer.effect(ProjectFaviconResolver, make);

/**
* The resolver with its own dependencies satisfied, for the several places
* that assemble project payloads and only need the service itself.
*/
export const layerLive = layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
);
9 changes: 4 additions & 5 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
Expand Down Expand Up @@ -346,10 +345,7 @@ const WorkspaceLayerLive = Layer.mergeAll(
WorkspaceFileSystemLayerLive,
);

const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
);
const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layerLive;

const AuthLayerLive = EnvironmentAuth.layer.pipe(
Layer.provideMerge(PersistenceLayerLive),
Expand Down Expand Up @@ -466,6 +462,9 @@ export const makeRoutesLayer = Layer.mergeAll(
// Both transports consume the same service instance, so caches single-flight across clients
// and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
Layer.provide(PullRequestServiceLive),
// Both transports resolve project accents from t3.json when they assemble a
// shell payload, so the resolver is shared rather than per-route.
Layer.provide(ProjectFaviconResolverLayerLive),
Layer.provide(PreviewAutomationBroker.layer),
Layer.provide(ServerSelfUpdate.layer),
Layer.provide(commandReadinessLayer),
Expand Down
Loading
Loading