Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions 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 @@ -118,6 +119,8 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
const config = yield* makeCliTestServerConfig(baseDir);
const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe(
Layer.provide(orchestrationHttpApiLayer),
// The shell snapshot handler resolves each project's t3.json accent.
Layer.provide(ProjectFaviconResolver.layerLive),
Layer.provide(environmentAuthenticatedAuthLayer),
);
const appLayer = HttpRouter.serve(routesLayer, {
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/orchestration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ import {
} from "../auth/http.ts";
import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
import * as ProjectAccents from "../project/ProjectAccents.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";

export const orchestrationHttpApiLayer = HttpApiBuilder.group(
EnvironmentHttpApi,
"orchestration",
Effect.fnUntraced(function* (handlers) {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
const projectFaviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const orchestrationEngine = yield* OrchestrationEngineService;

return handlers
Expand Down Expand Up @@ -51,13 +54,20 @@ 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(
projectFaviconResolver,
snapshot.projects,
);
return { ...snapshot, projects };
}),
)
.handle(
Expand Down
80 changes: 80 additions & 0 deletions apps/server/src/project/ProjectAccents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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(
resolverReturning({
"/repos/one": "#1688f0",
"/repos/two": { idle: "#071525", selected: "#173b60" },
}),
projects,
);

expect(decorated).toEqual([
{ id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" },
{
id: "b",
workspaceRoot: "/repos/two",
accent: { idle: "#071525", 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(failingResolver, [
{ id: "a", workspaceRoot: "/repos/gone" },
]);

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(
resolverReturning({ "/repos/one": "#1688f0" }),
{ id: "a", workspaceRoot: "/repos/one" },
);

expect(decorated).toEqual({ id: "a", workspaceRoot: "/repos/one", accent: "#1688f0" });
}),
);
});
60 changes: 60 additions & 0 deletions apps/server/src/project/ProjectAccents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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.
*
* Callers pass the resolver rather than pulling it from context so decorating
* a payload never widens a handler's requirements.
*
* @module ProjectAccents
*/
import type { ProjectAccent } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

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

type Resolver = ProjectFaviconResolver.ProjectFaviconResolver["Service"];

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 = (
resolver: Resolver,
workspaceRoot: string,
): Effect.Effect<ProjectAccent | null> =>
resolver.resolveAccent(workspaceRoot).pipe(Effect.orElseSucceed(() => null));

/** Attach `accent` to one project record. */
export const withProjectAccent = <T extends AccentTarget>(
resolver: Resolver,
project: T,
): Effect.Effect<WithAccent<T>> =>
resolveAccent(resolver, project.workspaceRoot).pipe(
Effect.map((accent) => ({ ...project, accent })),
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

/**
* Attach `accent` to every project in a shell snapshot.
*/
export const withProjectAccents = <T extends AccentTarget>(
resolver: Resolver,
projects: ReadonlyArray<T>,
): Effect.Effect<ReadonlyArray<WithAccent<T>>> =>
Effect.forEach(projects, (project) => withProjectAccent(resolver, project), {
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", "hover": "#102b46", "selected": "#173b60" } }',
);

const accentColor = yield* resolver.resolveAccent(cwd);
expect(accentColor).toEqual({
idle: "#071525",
hover: "#102b46",
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