diff --git a/frontend/app/view/webview/webview.test.tsx b/frontend/app/view/webview/webview.test.tsx new file mode 100644 index 0000000000..99302dd2f0 --- /dev/null +++ b/frontend/app/view/webview/webview.test.tsx @@ -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(); + + 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"); + }); +}); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index df50221764..3b10d4cce9 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -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"; @@ -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(""); @@ -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; @@ -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 ( +
+
+
preview mock ยท electron webview unavailable
+
web widget placeholder
+
+ {displayUrl} +
+
+
+ ); +} + 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); @@ -1055,19 +1078,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) return ( - + }> + + {errorText && (
{errorText}
@@ -1079,4 +1104,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) ); }); -export { WebView }; +export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback }; diff --git a/frontend/preview/previews/web.preview.tsx b/frontend/preview/previews/web.preview.tsx new file mode 100644 index 0000000000..56f9c215a4 --- /dev/null +++ b/frontend/preview/previews/web.preview.tsx @@ -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 = { + [`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(() => { + 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 ( + + +
+
full web block using preview mock fallback
+
+
+ +
+
+
+
+
+ ); +} + +export function WebPreview() { + return ; +}