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
20 changes: 20 additions & 0 deletions frontend/app/view/webview/webview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { getWebPreviewDisplayUrl, WebViewPreviewFallback } from "./webview";

describe("webview preview fallback", () => {
it("shows the requested URL", () => {
const markup = renderToStaticMarkup(<WebViewPreviewFallback url="https://waveterm.dev/docs" />);

expect(markup).toContain("electron webview unavailable");
expect(markup).toContain("https://waveterm.dev/docs");
});

it("falls back to about:blank when no URL is available", () => {
expect(getWebPreviewDisplayUrl("")).toBe("about:blank");
expect(getWebPreviewDisplayUrl(null)).toBe("about:blank");
});
});
61 changes: 43 additions & 18 deletions frontend/app/view/webview/webview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SuggestionControlNoData,
SuggestionControlNoResults,
} from "@/app/suggestion/suggestion";
import { MockBoundary } from "@/app/waveenv/mockboundary";
import { WOS, globalStore } from "@/store/global";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget, useAtomValueSafe } from "@/util/util";
Expand Down Expand Up @@ -83,7 +84,7 @@ export class WebViewModel implements ViewModel {
const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl");
this.homepageUrl = atom((get) => {
const defaultUrl = get(defaultUrlAtom);
const pinnedUrl = get(this.blockAtom).meta.pinnedurl;
const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl;
return pinnedUrl ?? defaultUrl;
});
this.urlWrapperClassName = atom("");
Expand Down Expand Up @@ -112,7 +113,7 @@ export class WebViewModel implements ViewModel {
const refreshIcon = get(this.refreshIcon);
const mediaPlaying = get(this.mediaPlaying);
const mediaMuted = get(this.mediaMuted);
const url = currUrl ?? metaUrl ?? homepageUrl;
const url = currUrl ?? metaUrl ?? homepageUrl ?? "";
const rtn: HeaderElem[] = [];
if (get(this.hideNav)) {
return rtn;
Expand Down Expand Up @@ -802,13 +803,35 @@ interface WebViewProps {
initialSrc?: string;
}

function getWebPreviewDisplayUrl(url?: string | null): string {
return url?.trim() || "about:blank";
}

function WebViewPreviewFallback({ url }: { url?: string | null }) {
const displayUrl = getWebPreviewDisplayUrl(url);

return (
<div className="flex h-full w-full items-center justify-center bg-panel">
<div className="mx-6 flex max-w-[720px] flex-col gap-3 rounded-lg border border-dashed border-border bg-background px-6 py-5 shadow-sm">
<div className="text-xs font-mono text-muted">preview mock · electron webview unavailable</div>
<div className="text-sm text-foreground">web widget placeholder</div>
<div className="rounded-md border border-border bg-panel px-3 py-2 font-mono text-xs text-foreground break-all">
{displayUrl}
</div>
</div>
</div>
);
}

const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => {
const blockData = useAtomValue(model.blockAtom);
const defaultUrl = useAtomValue(model.homepageUrl);
const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch");
const defaultSearch = useAtomValue(defaultSearchAtom);
let metaUrl = blockData?.meta?.url || defaultUrl;
metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
let metaUrl = blockData?.meta?.url || defaultUrl || "";
if (metaUrl) {
metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
}
const metaUrlRef = useRef(metaUrl);
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
const partitionOverride = useAtomValueSafe(model.partitionOverride);
Expand Down Expand Up @@ -1055,19 +1078,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)

return (
<Fragment>
<webview
id="webview"
className="webview"
ref={model.webviewRef}
src={metaUrlInitial}
data-blockid={model.blockId}
data-webcontentsid={webContentsId} // needed for emain
preload={getWebviewPreloadUrl()}
// @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups="true"
partition={webPartition}
useragent={userAgent}
/>
<MockBoundary fallback={<WebViewPreviewFallback url={metaUrl} />}>
<webview
id="webview"
className="webview"
ref={model.webviewRef}
src={metaUrlInitial}
data-blockid={model.blockId}
data-webcontentsid={webContentsId} // needed for emain
preload={getWebviewPreloadUrl()}
// @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
allowpopups="true"
partition={webPartition}
useragent={userAgent}
/>
</MockBoundary>
{errorText && (
<div className="webview-error">
<div>{errorText}</div>
Expand All @@ -1079,4 +1104,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
);
});

export { WebView };
export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback };
135 changes: 135 additions & 0 deletions frontend/preview/previews/web.preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { Block } from "@/app/block/block";
import { globalStore } from "@/app/store/jotaiStore";
import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model";
import { mockObjectForPreview } from "@/app/store/wos";
import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
import type { NodeModel } from "@/layout/index";
import { atom } from "jotai";
import * as React from "react";
import { applyMockEnvOverrides, MockWaveEnv } from "../mock/mockwaveenv";

const PreviewWorkspaceId = "preview-web-workspace";
const PreviewTabId = "preview-web-tab";
const PreviewNodeId = "preview-web-node";
const PreviewBlockId = "preview-web-block";
const PreviewUrl = "https://waveterm.dev";

function makeMockWorkspace(): Workspace {
return {
otype: "workspace",
oid: PreviewWorkspaceId,
version: 1,
name: "Preview Workspace",
tabids: [PreviewTabId],
activetabid: PreviewTabId,
meta: {},
} as Workspace;
}

function makeMockTab(): Tab {
return {
otype: "tab",
oid: PreviewTabId,
version: 1,
name: "Web Preview",
blockids: [PreviewBlockId],
meta: {},
} as Tab;
}

function makeMockBlock(): Block {
return {
otype: "block",
oid: PreviewBlockId,
version: 1,
meta: {
view: "web",
url: PreviewUrl,
},
} as Block;
}

const previewWaveObjs: Record<string, WaveObj> = {
[`workspace:${PreviewWorkspaceId}`]: makeMockWorkspace(),
[`tab:${PreviewTabId}`]: makeMockTab(),
[`block:${PreviewBlockId}`]: makeMockBlock(),
};

for (const [oref, obj] of Object.entries(previewWaveObjs)) {
mockObjectForPreview(oref, obj);
}

function makePreviewNodeModel(): NodeModel {
const isFocusedAtom = atom(true);
const isMagnifiedAtom = atom(false);

return {
additionalProps: atom({} as any),
innerRect: atom({ width: "1040px", height: "620px" }),
blockNum: atom(1),
numLeafs: atom(1),
nodeId: PreviewNodeId,
blockId: PreviewBlockId,
addEphemeralNodeToLayout: () => {},
animationTimeS: atom(0),
isResizing: atom(false),
isFocused: isFocusedAtom,
isMagnified: isMagnifiedAtom,
anyMagnified: atom(false),
isEphemeral: atom(false),
ready: atom(true),
disablePointerEvents: atom(false),
toggleMagnify: () => {
globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom));
},
focusNode: () => {
globalStore.set(isFocusedAtom, true);
},
onClose: () => {},
dragHandleRef: { current: null },
displayContainerRef: { current: null },
};
}

function WebPreviewInner() {
const baseEnv = useWaveEnv();
const nodeModel = React.useMemo(() => makePreviewNodeModel(), []);

const env = React.useMemo<MockWaveEnv>(() => {
return applyMockEnvOverrides(baseEnv, {
tabId: PreviewTabId,
mockWaveObjs: previewWaveObjs,
atoms: {
workspaceId: atom(PreviewWorkspaceId),
staticTabId: atom(PreviewTabId),
},
settings: {
"web:defaultsearch": "https://www.google.com/search?q=%s",
},
});
}, [baseEnv]);

const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]);

return (
<WaveEnvContext.Provider value={env}>
<TabModelContext.Provider value={tabModel}>
<div className="flex w-full max-w-[1100px] flex-col gap-2 px-6 py-6">
<div className="text-xs text-muted font-mono">full web block using preview mock fallback</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="h-[680px]">
<Block preview={false} nodeModel={nodeModel} />
</div>
</div>
</div>
</TabModelContext.Provider>
</WaveEnvContext.Provider>
);
}

export function WebPreview() {
return <WebPreviewInner />;
}
Loading