1import type {
2 AssetCreateUrlResult,
3 AssetResource,
4 EnvironmentId,
5 PreviewOpenInput,
6 PreviewSessionSnapshot,
7 ScopedThreadRef,
8} from "@t3tools/contracts";
9import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
10import {
11 type AtomCommandResult,
12 mapAtomCommandResult,
13} from "@t3tools/client-runtime/state/runtime";
14import * as Cause from "effect/Cause";
15import * as Data from "effect/Data";
16import { AsyncResult } from "effect/reactivity";
17
18import { resolveAssetUrl } from "~/assets/assetUrls";
19import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
20import {
21 applyPreviewServerSnapshot,
22 readThreadPreviewState,
23 rememberPreviewUrl,
24 setActivePreviewTab,
25 updatePreviewServerSnapshot,
26} from "~/previewStateStore";
27import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore";
28
29import {
30 browserDefaultOpenProfileId,
31 browserDefaultOpenViewport,
32 resolveBrowserDefaults,
33} from "./browserDefaults";
34
35export const isBrowserPreviewFile = (path: string): boolean =>
36 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
37
38export class BrowserPreviewUnavailableError extends Data.TaggedError(
39 "BrowserPreviewUnavailableError",
40)<{
41 readonly message: string;
42}> {}
43
44export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{
45 readonly cause: unknown;
46}> {
47 override get message(): string {
48 return "Saved browser settings could not be loaded.";
49 }
50}
51
52export type OpenPreviewMutation<E = unknown> = (input: {
53 readonly environmentId: EnvironmentId;
54 readonly input: PreviewOpenInput;
55}) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
56
57export async function openUrlInPreview<E>(input: {
58 readonly threadRef: ScopedThreadRef;
59 readonly url: string;
60 readonly openPreview: OpenPreviewMutation<E>;
61 /** Profile to open under; omit for the configured default. */
62 readonly profileId?: PreviewOpenInput["profileId"];
63 /** Open the tab without switching the thread to it. */
64 readonly background?: boolean;
65}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
66 const defaults = await resolveBrowserDefaults().catch(
67 (cause: unknown) => new BrowserSettingsReadError({ cause }),
68 );
69 if (defaults instanceof BrowserSettingsReadError) {
70 return AsyncResult.failure(Cause.fail(defaults));
71 }
72 const runtime = previewRuntimeFor(input.threadRef.environmentId);
73 const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId;
74 // The server's "opened" event switches the preview tab but not the panel's
75 // selection, so a changed selection means the user picked a tab themselves.
76 const selectedSurface = () =>
77 selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef)
78 ?.id ?? null;
79 const surfaceBeforeOpen = selectedSurface();
80 const result = await input.openPreview({
81 environmentId: input.threadRef.environmentId,
82 input: {
83 threadId: input.threadRef.threadId,
84 url: input.url,
85 // Built here rather than via `openPreviewSession` because this path
86 // maps the result differently, so the configured defaults have to be
87 // applied explicitly or file/link opens would ignore them.
88 viewport: browserDefaultOpenViewport(defaults),
89 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
90 ...(runtime === undefined ? {} : { runtime }),
91 },
92 });
93 return mapAtomCommandResult(result, (snapshot) => {
94 rememberPreviewUrl(input.threadRef, input.url);
95 if (input.background) {
96 updatePreviewServerSnapshot(input.threadRef, snapshot);
97 // The server's "opened" event activates the new tab; hand focus back,
98 // unless the user picked a tab, this one included, while the open was in flight.
99 if (
100 previousActiveTabId &&
101 readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId &&
102 selectedSurface() === surfaceBeforeOpen
103 ) {
104 setActivePreviewTab(input.threadRef, previousActiveTabId);
105 }
106 return;
107 }
108 applyPreviewServerSnapshot(input.threadRef, snapshot);
109 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
110 });
111}
112
113/**
114 * Opens a browser document in the integrated browser. Inside the workspace the
115 * page may load sibling assets; a file outside it is served on its own.
116 */
117export async function openFileInPreview<AssetError, PreviewError>(input: {
118 readonly threadRef: ScopedThreadRef;
119 readonly filePath: string;
120 readonly workspaceRoot: string | undefined;
121 readonly httpBaseUrl: string;
122 readonly createAssetUrl: (input: {
123 readonly environmentId: EnvironmentId;
124 readonly input: { readonly resource: AssetResource };
125 }) => Promise<AtomCommandResult<AssetCreateUrlResult, AssetError>>;
126 readonly openPreview: OpenPreviewMutation<PreviewError>;
127}): Promise<
128 AtomCommandResult<
129 void,
130 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
131 >
132> {
133 if (!isPreviewAvailableFor(input.threadRef.environmentId)) {
134 return AsyncResult.failure(
135 Cause.fail(
136 new BrowserPreviewUnavailableError({
137 message: "The integrated browser is unavailable in this runtime.",
138 }),
139 ),
140 );
141 }
142 const insideWorkspace =
143 mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined;
144 const assetResult = await input.createAssetUrl({
145 environmentId: input.threadRef.environmentId,
146 input: {
147 resource: {
148 _tag: insideWorkspace ? "workspace-file" : "media-file",
149 threadId: input.threadRef.threadId,
150 path: input.filePath,
151 },
152 },
153 });
154 if (assetResult._tag === "Failure") {
155 return AsyncResult.failure(assetResult.cause);
156 }
157 const assetUrl = resolveAssetUrl(input.httpBaseUrl, assetResult.value.relativeUrl);
158 if (assetUrl === null) {
159 return AsyncResult.failure(
160 Cause.die(new Error("The environment returned an invalid asset URL.")),
161 );
162 }
163 return openUrlInPreview({
164 threadRef: input.threadRef,
165 url: assetUrl,
166 openPreview: input.openPreview,
167 });
168}