From 98638b1ba8e47129e59da6efd6089bcc1efe7325 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:15:39 +0000 Subject: [PATCH 1/3] Initial plan From 99228e555f9f65074d20f6054ae8efd8b550c57b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:28:45 +0000 Subject: [PATCH 2/3] Add WebViewEnv for preview mock Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/view/webview/webview.test.tsx | 41 ++++++++- frontend/app/view/webview/webview.tsx | 100 ++++++++++++--------- frontend/app/view/webview/webviewenv.ts | 25 ++++++ frontend/preview/mock/mockwaveenv.ts | 12 +-- frontend/preview/previews/web.preview.tsx | 2 +- 5 files changed, 127 insertions(+), 53 deletions(-) create mode 100644 frontend/app/view/webview/webviewenv.ts diff --git a/frontend/app/view/webview/webview.test.tsx b/frontend/app/view/webview/webview.test.tsx index 99302dd2f0..6114160218 100644 --- a/frontend/app/view/webview/webview.test.tsx +++ b/frontend/app/view/webview/webview.test.tsx @@ -1,9 +1,12 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { globalStore } from "@/app/store/jotaiStore"; +import { makeMockWaveEnv } from "@/preview/mock/mockwaveenv"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { getWebPreviewDisplayUrl, WebViewPreviewFallback } from "./webview"; +import { atom } from "jotai"; +import { getWebPreviewDisplayUrl, WebViewModel, WebViewPreviewFallback } from "./webview"; describe("webview preview fallback", () => { it("shows the requested URL", () => { @@ -17,4 +20,40 @@ describe("webview preview fallback", () => { expect(getWebPreviewDisplayUrl("")).toBe("about:blank"); expect(getWebPreviewDisplayUrl(null)).toBe("about:blank"); }); + + it("uses the supplied env for homepage atoms and config updates", async () => { + const blockId = "webview-env-block"; + const env = makeMockWaveEnv({ + settings: { + "web:defaulturl": "https://default.example", + }, + mockWaveObjs: { + [`block:${blockId}`]: { + otype: "block", + oid: blockId, + version: 1, + meta: { + pinnedurl: "https://block.example", + }, + } as Block, + }, + }); + const model = new WebViewModel({ + blockId, + nodeModel: { + isFocused: atom(true), + focusNode: () => {}, + } as any, + tabModel: {} as any, + waveEnv: env, + }); + + expect(globalStore.get(model.homepageUrl)).toBe("https://block.example"); + + await model.setHomepageUrl("https://global.example", "global"); + + expect(globalStore.get(model.homepageUrl)).toBe("https://global.example"); + expect(globalStore.get(env.getSettingsKeyAtom("web:defaulturl"))).toBe("https://global.example"); + expect(globalStore.get(env.wos.getWaveObjectAtom(`block:${blockId}`))?.meta?.pinnedurl).toBeUndefined(); + }); }); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 3b10d4cce9..f0f8c67c27 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -3,25 +3,25 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import { Search, useSearch } from "@/app/element/search"; -import { createBlock, getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; -import { ObjectService } from "@/app/store/services"; import type { TabModel } from "@/app/store/tab-model"; -import { RpcApi } from "@/app/store/wshclientapi"; +import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { BlockHeaderSuggestionControl, SuggestionControlNoData, SuggestionControlNoResults, } from "@/app/suggestion/suggestion"; import { MockBoundary } from "@/app/waveenv/mockboundary"; -import { WOS, globalStore } from "@/store/global"; +import { globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; +import type { WebViewEnv } from "./webviewenv"; import "./webview.scss"; // User agent strings for mobile emulation @@ -32,9 +32,9 @@ const USER_AGENT_ANDROID = let webviewPreloadUrl = null; -function getWebviewPreloadUrl() { +function getWebviewPreloadUrl(env: WebViewEnv) { if (webviewPreloadUrl == null) { - webviewPreloadUrl = getApi().getWebviewPreload(); + webviewPreloadUrl = env.electron.getWebviewPreload(); console.log("webviewPreloadUrl", webviewPreloadUrl); } if (webviewPreloadUrl == null) { @@ -72,16 +72,18 @@ export class WebViewModel implements ViewModel { typeaheadOpen: PrimitiveAtom; partitionOverride: PrimitiveAtom | null; userAgentType: Atom; + env: WebViewEnv; - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { this.nodeModel = nodeModel; this.tabModel = tabModel; this.viewType = "web"; this.blockId = blockId; + this.env = waveEnv; this.noPadding = atom(true); - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.blockAtom = this.env.wos.getWaveObjectAtom(`block:${blockId}`); this.url = atom(); - const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl"); + const defaultUrlAtom = this.env.getSettingsKeyAtom("web:defaulturl"); this.homepageUrl = atom((get) => { const defaultUrl = get(defaultUrlAtom); const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl; @@ -97,10 +99,10 @@ export class WebViewModel implements ViewModel { this.urlInputRef = createRef(); this.webviewRef = createRef(); this.domReady = atom(false); - this.hideNav = getBlockMetaKeyAtom(blockId, "web:hidenav"); + this.hideNav = this.env.getBlockMetaKeyAtom(blockId, "web:hidenav"); this.typeaheadOpen = atom(false); this.partitionOverride = null; - this.userAgentType = getBlockMetaKeyAtom(blockId, "web:useragenttype"); + this.userAgentType = this.env.getBlockMetaKeyAtom(blockId, "web:useragenttype"); this.mediaPlaying = atom(false); this.mediaMuted = atom(false); @@ -199,7 +201,7 @@ export class WebViewModel implements ViewModel { console.log("open external", url); if (url != null && url != "") { const externalUrl = this.modifyExternalUrl?.(url) ?? url; - return getApi().openExternal(externalUrl); + return this.env.electron.openExternal(externalUrl); } }, }); @@ -280,7 +282,7 @@ export class WebViewModel implements ViewModel { query: string, reqContext: SuggestionRequestContext ): Promise { - const result = await RpcApi.FetchSuggestionsCommand(TabRpcClient, { + const result = await this.env.rpc.FetchSuggestionsCommand(TabRpcClient, { suggestiontype: "bookmark", query, widgetid: reqContext.widgetid, @@ -369,7 +371,12 @@ export class WebViewModel implements ViewModel { * @param url The URL that has been navigated to. */ handleNavigate(url: string) { - fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url })); + fireAndForget(() => + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), + meta: { url }, + }) + ); globalStore.set(this.url, url); if (this.searchAtoms) { globalStore.set(this.searchAtoms.isOpen, false); @@ -415,7 +422,7 @@ export class WebViewModel implements ViewModel { * @param newUrl The new URL to load in the webview. */ loadUrl(newUrl: string, reason: string) { - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrl", reason, nextUrl, "cur=", this.webviewRef.current.getURL()); @@ -437,7 +444,7 @@ export class WebViewModel implements ViewModel { * @returns Promise that resolves when the URL is loaded. */ loadUrlPromise(newUrl: string, reason: string): Promise { - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = this.env.getSettingsKeyAtom("web:defaultsearch"); const searchTemplate = globalStore.get(defaultSearchAtom); const nextUrl = this.ensureUrlScheme(newUrl, searchTemplate); console.log("webview loadUrlPromise", reason, nextUrl, "cur=", this.webviewRef.current?.getURL()); @@ -477,17 +484,17 @@ export class WebViewModel implements ViewModel { if (url != null && url != "") { switch (scope) { case "block": - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { pinnedurl: url }, }); break; case "global": - await RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { pinnedurl: "" }, + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), + meta: { pinnedurl: null }, }); - await RpcApi.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); + await this.env.rpc.SetConfigCommand(TabRpcClient, { "web:defaulturl": url }); break; } } @@ -537,7 +544,7 @@ export class WebViewModel implements ViewModel { try { const webContentsId = this.webviewRef.current?.getWebContentsId(); if (webContentsId) { - await getApi().clearWebviewStorage(webContentsId); + await this.env.electron.clearWebviewStorage(webContentsId); } } catch (e) { console.error("Failed to clear cookies and storage", e); @@ -583,8 +590,8 @@ export class WebViewModel implements ViewModel { return; } this.webviewRef.current?.setZoomFactor(factor || 1); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:zoom": factor }, // allow null so we can remove the zoom factor here }); } @@ -632,8 +639,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": null }, }); }); @@ -645,8 +652,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:iphone" }, }); }); @@ -658,8 +665,8 @@ export class WebViewModel implements ViewModel { type: "checkbox", click: () => { fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:useragenttype": "mobile:android" }, }); }); @@ -696,8 +703,8 @@ export class WebViewModel implements ViewModel { label: isNavHidden ? "Un-Hide Navigation" : "Hide Navigation", click: () => fireAndForget(() => { - return RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), + return this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), meta: { "web:hidenav": !isNavHidden }, }); }), @@ -735,16 +742,17 @@ export class WebViewModel implements ViewModel { const BookmarkTypeahead = memo( ({ model, blockRef }: { model: WebViewModel; blockRef: React.RefObject }) => { + const env = useWaveEnv(); const openBookmarksJson = () => { fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/presets/bookmarks.json`; + const path = `${env.electron.getConfigDir()}/presets/bookmarks.json`; const blockDef: BlockDef = { meta: { view: "preview", file: path, }, }; - await createBlock(blockDef, false, true); + await env.createBlock(blockDef, false, true); model.setTypeaheadOpen(false); }); }; @@ -824,18 +832,19 @@ function WebViewPreviewFallback({ url }: { url?: string | null }) { } const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => { + const env = useWaveEnv(); const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); - const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); + const defaultSearchAtom = env.getSettingsKeyAtom("web:defaultsearch"); const defaultSearch = useAtomValue(defaultSearchAtom); 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 zoomFactor = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; const partitionOverride = useAtomValueSafe(model.partitionOverride); - const metaPartition = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:partition")); + const metaPartition = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:partition")); const webPartition = partitionOverride || metaPartition || undefined; const userAgentType = useAtomValue(model.userAgentType) || "default"; @@ -1001,7 +1010,14 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) const newWindowHandler = (e: any) => { e.preventDefault(); const newUrl = e.detail.url; - fireAndForget(() => openLink(newUrl, true)); + fireAndForget(() => + env.createBlock({ + meta: { + view: "web", + url: newUrl, + }, + }) + ); }; const startLoadingHandler = () => { model.setRefreshIcon("xmark-large"); @@ -1027,11 +1043,11 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) } }; const webviewFocus = () => { - getApi().setWebviewFocus(webview.getWebContentsId()); + env.electron.setWebviewFocus(webview.getWebContentsId()); model.nodeModel.focusNode(); }; const webviewBlur = () => { - getApi().setWebviewFocus(null); + env.electron.setWebviewFocus(null); }; const handleDomReady = () => { globalStore.set(model.domReady, true); @@ -1086,7 +1102,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) src={metaUrlInitial} data-blockid={model.blockId} data-webcontentsid={webContentsId} // needed for emain - preload={getWebviewPreloadUrl()} + preload={getWebviewPreloadUrl(env)} // @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} diff --git a/frontend/app/view/webview/webviewenv.ts b/frontend/app/view/webview/webviewenv.ts new file mode 100644 index 0000000000..268e4c9e5d --- /dev/null +++ b/frontend/app/view/webview/webviewenv.ts @@ -0,0 +1,25 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { BlockMetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type WebViewEnv = WaveEnvSubset<{ + electron: { + openExternal: WaveEnv["electron"]["openExternal"]; + getWebviewPreload: WaveEnv["electron"]["getWebviewPreload"]; + clearWebviewStorage: WaveEnv["electron"]["clearWebviewStorage"]; + getConfigDir: WaveEnv["electron"]["getConfigDir"]; + setWebviewFocus: WaveEnv["electron"]["setWebviewFocus"]; + }; + rpc: { + FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + }; + wos: WaveEnv["wos"]; + createBlock: WaveEnv["createBlock"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"web:defaulturl" | "web:defaultsearch">; + getBlockMetaKeyAtom: BlockMetaKeyAtomFnType< + "web:hidenav" | "web:useragenttype" | "web:zoom" | "web:partition" + >; +}>; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 5e787610e5..7d6eac8880 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -99,17 +99,11 @@ export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv { }; } -function makeMockSettingsKeyAtom( - settingsAtom: Atom, - overrides?: Partial -): WaveEnv["getSettingsKeyAtom"] { +function makeMockSettingsKeyAtom(settingsAtom: Atom): WaveEnv["getSettingsKeyAtom"] { const keyAtomCache = new Map>(); return (key: T) => { if (!keyAtomCache.has(key)) { - keyAtomCache.set( - key, - atom((get) => (overrides?.[key] !== undefined ? overrides[key] : get(settingsAtom)?.[key])) - ); + keyAtomCache.set(key, atom((get) => get(settingsAtom)?.[key])); } return keyAtomCache.get(key) as Atom; }; @@ -345,7 +339,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }, rpc: makeMockRpc(overrides.rpc, mockWosFns), atoms, - getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings), + getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom), platform, isDev: () => overrides.isDev ?? true, isWindows: () => platform === PlatformWindows, diff --git a/frontend/preview/previews/web.preview.tsx b/frontend/preview/previews/web.preview.tsx index 56f9c215a4..27cd91bb6b 100644 --- a/frontend/preview/previews/web.preview.tsx +++ b/frontend/preview/previews/web.preview.tsx @@ -107,7 +107,7 @@ function WebPreviewInner() { staticTabId: atom(PreviewTabId), }, settings: { - "web:defaultsearch": "https://www.google.com/search?q=%s", + "web:defaultsearch": "https://www.google.com/search?q={query}", }, }); }, [baseEnv]); From c2fe9d43bb758c2a8710e605664cbd3b154e4149 Mon Sep 17 00:00:00 2001 From: sawka Date: Sat, 14 Mar 2026 17:26:35 -0700 Subject: [PATCH 3/3] revert openLinkChange --- frontend/app/view/webview/webview.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index f0f8c67c27..116d0ef0b3 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -7,22 +7,22 @@ import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import type { TabModel } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { useWaveEnv } from "@/app/waveenv/waveenv"; import { BlockHeaderSuggestionControl, SuggestionControlNoData, SuggestionControlNoResults, } from "@/app/suggestion/suggestion"; import { MockBoundary } from "@/app/waveenv/mockboundary"; -import { globalStore } from "@/store/global"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { globalStore, openLink } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; -import type { WebViewEnv } from "./webviewenv"; import "./webview.scss"; +import type { WebViewEnv } from "./webviewenv"; // User agent strings for mobile emulation const USER_AGENT_IPHONE = @@ -1010,14 +1010,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) const newWindowHandler = (e: any) => { e.preventDefault(); const newUrl = e.detail.url; - fireAndForget(() => - env.createBlock({ - meta: { - view: "web", - url: newUrl, - }, - }) - ); + fireAndForget(() => openLink(newUrl, true)); }; const startLoadingHandler = () => { model.setRefreshIcon("xmark-large"); @@ -1120,4 +1113,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) ); }); -export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback }; +export { WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl };