Skip to content
Merged
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
21 changes: 15 additions & 6 deletions apps/mobile/src/features/files/sourceHighlightingState.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as Cause from "effect/Cause";
import { AtomRegistry } from "effect/unstable/reactivity";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import {
createSourceHighlightAtomFamily,
SourceHighlightError,
type SourceHighlightTokens,
} from "./sourceHighlightingState";

Expand Down Expand Up @@ -100,22 +102,29 @@ describe("sourceHighlightingState", () => {
registry.dispose();
});

it("exposes highlighter errors as a failed async result", async () => {
const highlight = vi.fn(async () => {
throw new Error("highlight failed");
});
it("preserves highlighter failures with the source path and theme", async () => {
const cause = new Error("highlight failed");
const highlight = vi.fn(() => Promise.reject(cause));
const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight });
const registry = AtomRegistry.make();
const path = "src/example.ts";
const theme = "light" as const;
const atom = sourceHighlightAtom({
path: "src/example.ts",
path,
contents: "const value = 1;",
theme: "light",
theme,
});
const unmount = registry.mount(atom);

await vi.waitFor(() => {
expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
});
const result = registry.get(atom);
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
expect(error).toEqual(new SourceHighlightError({ path, theme, cause }));
expect((error as SourceHighlightError).cause).toBe(cause);
}

unmount();
registry.dispose();
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/features/files/sourceHighlightingState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { Atom } from "effect/unstable/reactivity";

import {
Expand All @@ -22,9 +23,18 @@ type SourceHighlighter = (input: SourceHighlightInput) => Promise<SourceHighligh

class SourceHighlightCacheKey extends Data.Class<SourceHighlightInput> {}

class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{
readonly cause: unknown;
}> {}
export class SourceHighlightError extends Schema.TaggedErrorClass<SourceHighlightError>()(
"SourceHighlightError",
{
path: Schema.String,
theme: Schema.Literals(["light", "dark"]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Could not highlight ${this.path} with the ${this.theme} theme.`;
}
}

export function createSourceHighlightAtomFamily(options?: {
readonly highlight?: SourceHighlighter;
Expand All @@ -36,7 +46,8 @@ export function createSourceHighlightAtomFamily(options?: {
Atom.make(
Effect.tryPromise({
try: () => highlight(request),
catch: (cause) => new SourceHighlightError({ cause }),
catch: (cause) =>
new SourceHighlightError({ path: request.path, theme: request.theme, cause }),
}),
).pipe(
Atom.setIdleTTL(idleTtlMs),
Expand Down
62 changes: 59 additions & 3 deletions apps/mobile/src/features/files/workspace-file-image-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import * as Cause from "effect/Cause";
import * as Hash from "effect/Hash";
import { AtomRegistry } from "effect/unstable/reactivity";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { describe, expect, it, vi } from "vite-plus/test";

import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache";
import {
createWorkspaceFileImageAtomFamily,
WorkspaceImagePrefetchFailedError,
WorkspaceImagePrefetchUnavailableError,
} from "./workspace-file-image-cache";

describe("workspaceFileImageAtom", () => {
it("reuses a prefetched image across route remounts", async () => {
Expand Down Expand Up @@ -48,15 +54,65 @@ describe("workspaceFileImageAtom", () => {
registry.dispose();
});

it("exposes prefetch failures", async () => {
it("reports an unavailable image when prefetch completes without caching it", async () => {
const uri = "https://example.test/api/assets/signed-secret-token/missing.png?signature=private";
const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false });
const registry = AtomRegistry.make();
const atom = imageAtom("https://example.test/missing.png");
const atom = imageAtom(uri);
expect(atom.label?.[0]).not.toContain("signed-secret-token");
expect(atom.label?.[0]).not.toContain("signature=private");
const unmount = registry.mount(atom);

await vi.waitFor(() => {
expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
});
const result = registry.get(atom);
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
expect(error).toEqual(
new WorkspaceImagePrefetchUnavailableError({
uriHash: Hash.hash(uri),
uriLength: uri.length,
uriProtocol: "https:",
}),
);
expect(error).not.toHaveProperty("uri");
expect(String(error)).not.toContain("signed-secret-token");
expect(String(error)).not.toContain("signature=private");
}

unmount();
registry.dispose();
});

it("preserves rejected prefetch causes without retaining the signed image URI", async () => {
const uri =
"https://example.test/api/assets/signed-secret-token/rejected.png?signature=private";
const cause = new Error("native image loader failed");
const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: () => Promise.reject(cause) });
const registry = AtomRegistry.make();
const atom = imageAtom(uri);
const unmount = registry.mount(atom);

await vi.waitFor(() => {
expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
});
const result = registry.get(atom);
if (AsyncResult.isFailure(result)) {
const error = Cause.squash(result.cause);
expect(error).toEqual(
new WorkspaceImagePrefetchFailedError({
uriHash: Hash.hash(uri),
uriLength: uri.length,
uriProtocol: "https:",
cause,
}),
);
expect((error as WorkspaceImagePrefetchFailedError).cause).toBe(cause);
expect(error).not.toHaveProperty("uri");
expect(String(error)).not.toContain("signed-secret-token");
expect(String(error)).not.toContain("signature=private");
}

unmount();
registry.dispose();
Expand Down
81 changes: 61 additions & 20 deletions apps/mobile/src/features/files/workspace-file-image-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Hash from "effect/Hash";
import * as Schema from "effect/Schema";
import { Atom } from "effect/unstable/reactivity";

const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000;
Expand All @@ -8,39 +11,77 @@ type ImagePrefetch = (uri: string) => Promise<boolean>;

class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {}

export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{
readonly cause?: unknown;
readonly uri: string;
}> {}
export class WorkspaceImagePrefetchUnavailableError extends Schema.TaggedErrorClass<WorkspaceImagePrefetchUnavailableError>()(
"WorkspaceImagePrefetchUnavailableError",
{
uriHash: Schema.Number,
uriLength: Schema.Number,
uriProtocol: Schema.NullOr(Schema.String),
},
) {
override get message(): string {
return `Image prefetch did not cache the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`;
}
}

export class WorkspaceImagePrefetchFailedError extends Schema.TaggedErrorClass<WorkspaceImagePrefetchFailedError>()(
"WorkspaceImagePrefetchFailedError",
{
uriHash: Schema.Number,
uriLength: Schema.Number,
uriProtocol: Schema.NullOr(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Image prefetch failed for the requested ${this.uriProtocol ?? "unknown-protocol"} resource (URI length ${this.uriLength}).`;
}
}

export const WorkspaceImagePrefetchError = Schema.Union([
WorkspaceImagePrefetchUnavailableError,
WorkspaceImagePrefetchFailedError,
]);
export type WorkspaceImagePrefetchError = typeof WorkspaceImagePrefetchError.Type;

async function prefetchWithNativeImage(uri: string): Promise<boolean> {
const { Image } = await import("react-native");
return Image.prefetch(uri);
}

function describeWorkspaceImageUri(uri: string) {
const diagnostics = getUrlDiagnostics(uri);
return {
uriHash: Hash.hash(uri),
uriLength: diagnostics.inputLength,
uriProtocol: diagnostics.protocol ?? null,
};
}

export function createWorkspaceFileImageAtomFamily(options?: {
readonly idleTtlMs?: number;
readonly prefetch?: ImagePrefetch;
}) {
const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS;
const prefetch = options?.prefetch ?? prefetchWithNativeImage;
const family = Atom.family((key: WorkspaceImageCacheKey) =>
Atom.make(
Effect.tryPromise({
try: async () => {
const cached = await prefetch(key.uri);
if (!cached) {
throw new WorkspaceImagePrefetchError({ uri: key.uri });
}
return key.uri;
},
catch: (cause) =>
cause instanceof WorkspaceImagePrefetchError
? cause
: new WorkspaceImagePrefetchError({ uri: key.uri, cause }),
const family = Atom.family((key: WorkspaceImageCacheKey) => {
const uriContext = describeWorkspaceImageUri(key.uri);
return Atom.make(
Effect.gen(function* () {
const cached = yield* Effect.tryPromise({
try: () => prefetch(key.uri),
catch: (cause) => new WorkspaceImagePrefetchFailedError({ ...uriContext, cause }),
});
if (!cached) {
return yield* new WorkspaceImagePrefetchUnavailableError(uriContext);
}
return key.uri;
}),
).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)),
);
).pipe(
Atom.setIdleTTL(idleTtlMs),
Atom.withLabel(`mobile:workspace-image:${uriContext.uriHash.toString(36)}`),
);
});

return (uri: string) => family(new WorkspaceImageCacheKey({ uri }));
}
Expand Down
Loading