| 1 | import type { |
| 2 | AssetCreateUrlResult, |
| 3 | AssetResource, |
| 4 | EnvironmentId, |
| 5 | PreviewOpenInput, |
| 6 | PreviewSessionSnapshot, |
| 7 | ScopedThreadRef, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 10 | import { |
| 11 | type AtomCommandResult, |
| 12 | mapAtomCommandResult, |
| 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 14 | import * as Cause from "effect/Cause"; |
| 15 | import * as Data from "effect/Data"; |
| 16 | import { AsyncResult } from "effect/reactivity"; |
| 17 | |
| 18 | import { resolveAssetUrl } from "~/assets/assetUrls"; |
| 19 | import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime"; |
| 20 | import { |
| 21 | applyPreviewServerSnapshot, |
| 22 | readThreadPreviewState, |
| 23 | rememberPreviewUrl, |
| 24 | setActivePreviewTab, |
| 25 | updatePreviewServerSnapshot, |
| 26 | } from "~/previewStateStore"; |
| 27 | import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; |
| 28 | |
| 29 | import { |
| 30 | browserDefaultOpenProfileId, |
| 31 | browserDefaultOpenViewport, |
| 32 | resolveBrowserDefaults, |
| 33 | } from "./browserDefaults"; |
| 34 | |
| 35 | export const isBrowserPreviewFile = (path: string): boolean => |
| 36 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 37 | |
| 38 | export class BrowserPreviewUnavailableError extends Data.TaggedError( |
| 39 | "BrowserPreviewUnavailableError", |
| 40 | )<{ |
| 41 | readonly message: string; |
| 42 | }> {} |
| 43 | |
| 44 | export 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 | |
| 52 | export type OpenPreviewMutation<E = unknown> = (input: { |
| 53 | readonly environmentId: EnvironmentId; |
| 54 | readonly input: PreviewOpenInput; |
| 55 | }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>; |
| 56 | |
| 57 | export 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 | */ |
| 117 | export 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 | } |