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
130 changes: 130 additions & 0 deletions apps/web/src/components/preview/openTerminalLinkInPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import {
openTerminalLinkInPreview,
TerminalLinkContextMenuShowError,
TerminalLinkPreviewOpenError,
} from "./openTerminalLinkInPreview";

vi.mock("~/previewStateStore", () => ({
applyPreviewServerSnapshot: vi.fn(),
isPreviewSupportedInRuntime: () => true,
}));

vi.mock("~/rightPanelStore", () => ({
useRightPanelStore: {
getState: () => ({ openBrowser: vi.fn() }),
},
}));

const threadRef = {
environmentId: "local" as ScopedThreadRef["environmentId"],
threadId: "thread-1" as ScopedThreadRef["threadId"],
};

const snapshot: PreviewSessionSnapshot = {
threadId: threadRef.threadId,
tabId: "tab-1",
navStatus: { _tag: "Idle" },
canGoBack: false,
canGoForward: false,
updatedAt: "2026-06-20T00:00:00.000Z",
};

afterEach(() => {
vi.restoreAllMocks();
});

describe("openTerminalLinkInPreview", () => {
it("preserves context-menu failures with terminal link context before falling back", async () => {
const cause = new Error("menu unavailable");
const fallbackToBrowser = vi.fn();
const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);

await openTerminalLinkInPreview({
url: "http://localhost:3000/path?token=secret",
position: { x: 12, y: 34 },
threadRef,
openPreview,
localApi: {
contextMenu: {
show: vi.fn(async () => {
throw cause;
}),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
expect(openPreview).not.toHaveBeenCalled();
expect(reportError).toHaveBeenCalledOnce();
const error = reportError.mock.calls[0]?.[0];
expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError);
expect(error).toMatchObject({
environmentId: "local",
threadId: "thread-1",
targetOrigin: "http://localhost:3000",
cause,
});
expect(error.message).not.toContain("menu unavailable");
expect(error.targetOrigin).not.toContain("secret");
});

it("preserves the complete preview failure cause before falling back", async () => {
const rpcError = new Error("preview unavailable");
const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect"));
const fallbackToBrowser = vi.fn();
const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);

await openTerminalLinkInPreview({
url: "http://127.0.0.1:5173/",
position: { x: 12, y: 34 },
threadRef,
openPreview: async () => AsyncResult.failure(cause),
localApi: {
contextMenu: {
show: vi.fn(async () => "open-in-preview"),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

expect(fallbackToBrowser).toHaveBeenCalledOnce();
expect(reportError).toHaveBeenCalledOnce();
const error = reportError.mock.calls[0]?.[0];
expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError);
expect(error).toMatchObject({
environmentId: "local",
threadId: "thread-1",
targetOrigin: "http://127.0.0.1:5173",
cause,
});
expect(error.message).not.toContain("preview unavailable");
});

it("does not report or fall back when opening the preview is interrupted", async () => {
const fallbackToBrowser = vi.fn();
const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);

await openTerminalLinkInPreview({
url: "http://localhost:5173/",
position: { x: 12, y: 34 },
threadRef,
openPreview: async () => AsyncResult.failure(Cause.interrupt()),
localApi: {
contextMenu: {
show: vi.fn(async () => "open-in-preview"),
},
} as unknown as LocalApi,
fallbackToBrowser,
});

expect(reportError).not.toHaveBeenCalled();
expect(fallbackToBrowser).not.toHaveBeenCalled();
});
});
50 changes: 49 additions & 1 deletion apps/web/src/components/preview/openTerminalLinkInPreview.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import { isPreviewableUrl } from "@t3tools/shared/preview";
import * as Schema from "effect/Schema";

import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
import { useRightPanelStore } from "~/rightPanelStore";

const terminalLinkErrorContext = {
environmentId: Schema.String,
threadId: Schema.String,
targetOrigin: Schema.String,
cause: Schema.Defect(),
};

export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass<TerminalLinkContextMenuShowError>()(
"TerminalLinkContextMenuShowError",
terminalLinkErrorContext,
) {
override get message(): string {
return `Failed to show the context menu for terminal link ${this.targetOrigin}.`;
}
}

export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(
"TerminalLinkPreviewOpenError",
terminalLinkErrorContext,
) {
override get message(): string {
return `Failed to open terminal link ${this.targetOrigin} in preview for thread ${this.threadId}.`;
}
}

interface OpenTerminalLinkInPreviewInput<E> {
readonly url: string;
readonly position: { x: number; y: number };
Expand All @@ -27,6 +54,12 @@ export async function openTerminalLinkInPreview<E>(
return;
}

const errorContext = {
environmentId: input.threadRef.environmentId,
threadId: input.threadRef.threadId,
targetOrigin: new URL(input.url).origin,
};

let choice: "open-in-preview" | "open-in-browser" | null;
try {
choice = await input.localApi.contextMenu.show(
Expand All @@ -36,7 +69,13 @@ export async function openTerminalLinkInPreview<E>(
],
input.position,
);
} catch {
} catch (cause) {
console.error(
new TerminalLinkContextMenuShowError({
...errorContext,
cause,
}),
);
input.fallbackToBrowser();
return;
}
Expand All @@ -47,6 +86,15 @@ export async function openTerminalLinkInPreview<E>(
input: { threadId: input.threadRef.threadId, url: input.url },
});
if (result._tag === "Failure") {
if (isAtomCommandInterrupted(result)) {
return;
}
console.error(
new TerminalLinkPreviewOpenError({
...errorContext,
cause: result.cause,
}),
);
Comment thread
juliusmarminge marked this conversation as resolved.
input.fallbackToBrowser();
return;
}
Expand Down
Loading