From 1842175e5b10ce709464cb83c30ecfad5982672d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 10 Apr 2026 16:54:45 -0700 Subject: [PATCH 1/4] feat(desktop): port browser pane to v2 workspaces with global persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port v1 browser pane (webview, URL toolbar, history, error overlay, new-window handler) into v2 workspace tree as a native implementation. Uses the v2 Workspace component's renderToolbar slot for a Chrome-style URL bar in the pane header. - Float-over webview: each lives at document.body under a fixed #browser-runtime-root, positioned via ResizeObserver on a per-pane placeholder div. Elements never reparent, so Electron's guest WebContents survives tab switches, pane drags, and workspace route remounts — no page reloads. - Keep-alive tab rendering in @superset/panes Workspace.tsx: tabs that have been visited stay mounted behind visibility:hidden so their React state (and persistent DOM like webviews) survives tab switches. Also benefits terminal scrollback, chat subscriptions, editor state. - BrowserRuntimeRegistry: module-level singleton keyed by paneId, modeled on terminalRuntimeRegistry. Owns webview lifecycle, state subscriptions via useSyncExternalStore, drag-passthrough listeners, equality-guarded setState to deduplicate webview event spam. - Single-pane-browser tabs show the page favicon + live page title in the tab bar via a new optional renderTabLabel prop on the panes library. - BrowserPane subscribes to the workspace store for activeTabId to drive webview visibility, so inactive-tab webviews don't paint on top of the active tab's content. - Destroy path: diff the set of browser pane ids across all tabs each render, destroying any that disappeared. Correctly handles tab closes, intra-tab split closes, and pane drags between tabs (moved panes stay in the set). Known limitation: dropping panes onto another browser pane's body doesn't register — the webview compositor layer intercepts drag events even with pointer-events:none/visibility:hidden. Drops on pane headers work. Deferred. --- .../useDefaultContextMenuActions.tsx | 3 +- .../components/BrowserPane/BrowserPane.tsx | 142 +++++ .../BrowserPane/browserRuntimeRegistry.ts | 483 ++++++++++++++++++ .../BrowserErrorOverlay.tsx | 109 ++++ .../components/BrowserErrorOverlay/index.ts | 1 + .../BrowserOverflowMenu.tsx | 115 +++++ .../components/BrowserOverflowMenu/index.ts | 1 + .../BrowserTabLabel/BrowserTabLabel.tsx | 59 +++ .../components/BrowserTabLabel/index.ts | 1 + .../BrowserToolbar/BrowserToolbar.tsx | 215 ++++++++ .../UrlSuggestions/UrlSuggestions.tsx | 74 +++ .../components/UrlSuggestions/index.ts | 1 + .../hooks/useUrlAutocomplete/index.ts | 4 + .../useUrlAutocomplete/useUrlAutocomplete.ts | 129 +++++ .../components/BrowserToolbar/index.ts | 1 + .../components/BrowserPane/constants.ts | 1 + .../hooks/usePersistentWebview/index.ts | 1 + .../usePersistentWebview.ts | 109 ++++ .../components/BrowserPane/index.ts | 1 + .../components/BrowserPane/sanitizeUrl.ts | 12 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 19 +- .../useWorkspaceHotkeys.ts | 6 +- .../v2-workspace/$workspaceId/page.tsx | 67 ++- .../v2-workspace/$workspaceId/types.ts | 2 +- .../react/components/Workspace/Workspace.tsx | 80 ++- .../Workspace/components/TabBar/TabBar.tsx | 3 + .../TabBar/components/TabItem/TabItem.tsx | 4 +- packages/panes/src/react/types.ts | 2 + 28 files changed, 1608 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/BrowserPane.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/BrowserTabLabel.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx index 01b09b6edca..22da979ff64 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -87,8 +87,7 @@ export function useDefaultContextMenuActions(): ContextMenuActionConfig; +} + +function useBrowserState(paneId: string) { + return useSyncExternalStore( + useCallback( + (cb) => browserRuntimeRegistry.onStateChange(paneId, cb), + [paneId], + ), + useCallback(() => browserRuntimeRegistry.getState(paneId), [paneId]), + ); +} + +export function BrowserPane({ ctx }: BrowserPaneProps) { + const paneId = ctx.pane.id; + const tabId = ctx.tab.id; + const state = useBrowserState(paneId); + const { placeholderRef, reload } = usePersistentWebview({ paneId, ctx }); + + const isTabActive = useStore(ctx.store, (s) => s.activeTabId === tabId); + + useEffect(() => { + browserRuntimeRegistry.setVisibility(paneId, isTabActive); + }, [paneId, isTabActive]); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + + return ( +
+
+ {state.error && !state.isLoading && ( + + )} + {isBlankPage && !state.isLoading && !state.error && ( +
+ +
+

+ Browser +

+

+ Enter a URL above, or instruct an agent to navigate +
+ and use the browser +

+
+
+ )} +
+ ); +} + +interface BrowserPaneToolbarProps { + ctx: RendererContext; +} + +export function BrowserPaneToolbar({ ctx }: BrowserPaneToolbarProps) { + const paneId = ctx.pane.id; + const state = useBrowserState(paneId); + + const handleOpenDevTools = useCallback(() => { + electronTrpcClient.browser.openDevTools.mutate({ paneId }).catch(() => {}); + }, [paneId]); + + const handleGoBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const handleGoForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const handleReload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const handleNavigate = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + const PaneHeaderActions = ctx.components.PaneHeaderActions; + + return ( +
+ +
+
+ + + + + + Open DevTools + + + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts new file mode 100644 index 00000000000..90e993ac9f0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -0,0 +1,483 @@ +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { BrowserHistoryEntry, BrowserLoadError } from "shared/tabs-types"; +import { sanitizeUrl } from "./sanitizeUrl"; + +export interface BrowserRuntimeState { + currentUrl: string; + pageTitle: string; + faviconUrl: string | null; + isLoading: boolean; + error: BrowserLoadError | null; + history: BrowserHistoryEntry[]; + historyIndex: number; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface DidStopLoadingInfo { + url: string; + title: string; +} + +interface RegistryEntry { + webview: Electron.WebviewTag; + state: BrowserRuntimeState; + onDidStopLoading: ((info: DidStopLoadingInfo) => void) | null; + webContentsId: number | null; + detachHandlers: () => void; + isHistoryNavigation: boolean; + placeholder: HTMLElement | null; + resizeObserver: ResizeObserver | null; + visible: boolean; +} + +const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ + currentUrl: "about:blank", + pageTitle: "", + faviconUrl: null, + isLoading: false, + error: null, + history: [], + historyIndex: -1, + canGoBack: false, + canGoForward: false, +}); + +const ROOT_CONTAINER_ID = "browser-runtime-root"; + +class BrowserRuntimeRegistryImpl { + private entries = new Map(); + private listenersByPaneId = new Map void>>(); + private rootContainer: HTMLDivElement | null = null; + private globalListenersInstalled = false; + + private getListeners(paneId: string): Set<() => void> { + let set = this.listenersByPaneId.get(paneId); + if (!set) { + set = new Set(); + this.listenersByPaneId.set(paneId, set); + } + return set; + } + + private ensureRootContainer(): HTMLDivElement { + if (this.rootContainer?.isConnected) return this.rootContainer; + const existing = document.getElementById( + ROOT_CONTAINER_ID, + ) as HTMLDivElement | null; + if (existing) { + this.rootContainer = existing; + return existing; + } + const root = document.createElement("div"); + root.id = ROOT_CONTAINER_ID; + root.style.position = "fixed"; + root.style.top = "0"; + root.style.left = "0"; + root.style.width = "0"; + root.style.height = "0"; + root.style.pointerEvents = "none"; + root.style.zIndex = "0"; + document.body.appendChild(root); + this.rootContainer = root; + this.installGlobalListeners(); + return root; + } + + private installGlobalListeners() { + if (this.globalListenersInstalled) return; + this.globalListenersInstalled = true; + + const setPassthrough = (passthrough: boolean) => { + for (const entry of this.entries.values()) { + if (!entry.visible) continue; + entry.webview.style.pointerEvents = passthrough ? "none" : "auto"; + } + }; + window.addEventListener("dragstart", () => setPassthrough(true), true); + window.addEventListener("dragend", () => setPassthrough(false), true); + window.addEventListener("drop", () => setPassthrough(false), true); + + window.addEventListener("resize", () => { + for (const entry of this.entries.values()) { + if (entry.placeholder) this.updateLayout(entry); + } + }); + } + + private updateLayout(entry: RegistryEntry) { + if (!entry.placeholder) return; + const rect = entry.placeholder.getBoundingClientRect(); + const w = entry.webview; + w.style.top = `${rect.top}px`; + w.style.left = `${rect.left}px`; + w.style.width = `${rect.width}px`; + w.style.height = `${rect.height}px`; + } + + private notify(paneId: string) { + const listeners = this.listenersByPaneId.get(paneId); + if (!listeners) return; + for (const listener of listeners) listener(); + } + + private setState(paneId: string, patch: Partial) { + const entry = this.entries.get(paneId); + if (!entry) return; + let changed = false; + for (const key in patch) { + const k = key as keyof BrowserRuntimeState; + if (entry.state[k] !== patch[k]) { + changed = true; + break; + } + } + if (!changed) return; + entry.state = { ...entry.state, ...patch }; + this.notify(paneId); + } + + private pushHistory(paneId: string, url: string, title: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + const { history, historyIndex } = entry.state; + const current = history[historyIndex]; + if (current?.url === url) { + if (current.title !== title) { + const next = history.slice(); + next[historyIndex] = { ...current, title }; + entry.state = { ...entry.state, history: next }; + this.notify(paneId); + } + return; + } + const truncated = history.slice(0, historyIndex + 1); + const next: BrowserHistoryEntry[] = [ + ...truncated, + { url, title, timestamp: Date.now() }, + ]; + const nextIndex = next.length - 1; + entry.state = { + ...entry.state, + history: next, + historyIndex: nextIndex, + canGoBack: nextIndex > 0, + canGoForward: false, + }; + this.notify(paneId); + } + + private createEntry(paneId: string, initialUrl: string): RegistryEntry { + const webview = document.createElement("webview") as Electron.WebviewTag; + webview.setAttribute("partition", "persist:superset"); + webview.setAttribute("allowpopups", ""); + webview.style.position = "fixed"; + webview.style.top = "0"; + webview.style.left = "0"; + webview.style.width = "0"; + webview.style.height = "0"; + webview.style.margin = "0"; + webview.style.padding = "0"; + webview.style.border = "none"; + webview.style.visibility = "hidden"; + webview.style.pointerEvents = "auto"; + webview.src = sanitizeUrl(initialUrl); + + const entry: RegistryEntry = { + webview, + state: { ...EMPTY_STATE, currentUrl: initialUrl }, + onDidStopLoading: null, + webContentsId: null, + detachHandlers: () => {}, + isHistoryNavigation: false, + placeholder: null, + resizeObserver: null, + visible: false, + }; + + const handleDomReady = () => { + const webContentsId = webview.getWebContentsId(); + if (entry.webContentsId !== webContentsId) { + entry.webContentsId = webContentsId; + electronTrpcClient.browser.register + .mutate({ paneId, webContentsId }) + .catch((err) => { + console.error("[browserRuntimeRegistry] register failed:", err); + }); + } + }; + + const handleDidStartLoading = () => { + this.setState(paneId, { isLoading: true, error: null }); + }; + + const handleDidStopLoading = () => { + if (entry.isHistoryNavigation) { + entry.isHistoryNavigation = false; + this.setState(paneId, { isLoading: false }); + return; + } + const url = webview.getURL() ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + isLoading: false, + currentUrl: url, + pageTitle: title, + }); + if (url && url !== "about:blank") { + this.pushHistory(paneId, url, title); + electronTrpcClient.browserHistory.upsert + .mutate({ url, title, faviconUrl: entry.state.faviconUrl }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert history:", err); + }); + } + entry.onDidStopLoading?.({ url, title }); + }; + + const handleDidNavigate = (e: Electron.DidNavigateEvent) => { + if (entry.isHistoryNavigation) { + entry.isHistoryNavigation = false; + return; + } + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + currentUrl: url, + pageTitle: title, + isLoading: false, + }); + }; + + const handleDidNavigateInPage = (e: Electron.DidNavigateInPageEvent) => { + if (entry.isHistoryNavigation) { + entry.isHistoryNavigation = false; + return; + } + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { currentUrl: url, pageTitle: title }); + }; + + const handlePageTitleUpdated = (e: Electron.PageTitleUpdatedEvent) => { + this.setState(paneId, { pageTitle: e.title ?? "" }); + }; + + const handlePageFaviconUpdated = (e: Electron.PageFaviconUpdatedEvent) => { + const favicon = e.favicons?.[0]; + if (!favicon || favicon === entry.state.faviconUrl) return; + this.setState(paneId, { faviconUrl: favicon }); + const { currentUrl, pageTitle } = entry.state; + if (currentUrl && currentUrl !== "about:blank") { + electronTrpcClient.browserHistory.upsert + .mutate({ url: currentUrl, title: pageTitle, faviconUrl: favicon }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert favicon:", err); + }); + } + }; + + const handleDidFailLoad = (e: Electron.DidFailLoadEvent) => { + if (e.errorCode === -3) return; // ERR_ABORTED + this.setState(paneId, { + isLoading: false, + error: { + code: e.errorCode ?? 0, + description: e.errorDescription ?? "", + url: e.validatedURL ?? "", + }, + }); + }; + + webview.addEventListener("dom-ready", handleDomReady); + webview.addEventListener("did-start-loading", handleDidStartLoading); + webview.addEventListener("did-stop-loading", handleDidStopLoading); + webview.addEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.addEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.addEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.addEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.addEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + + entry.detachHandlers = () => { + webview.removeEventListener("dom-ready", handleDomReady); + webview.removeEventListener("did-start-loading", handleDidStartLoading); + webview.removeEventListener("did-stop-loading", handleDidStopLoading); + webview.removeEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.removeEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.removeEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.removeEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.removeEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + }; + + return entry; + } + + attach( + paneId: string, + placeholder: HTMLElement, + initialUrl: string, + onDidStopLoading: (info: DidStopLoadingInfo) => void, + ): void { + const root = this.ensureRootContainer(); + let entry = this.entries.get(paneId); + if (!entry) { + entry = this.createEntry(paneId, initialUrl); + this.entries.set(paneId, entry); + root.appendChild(entry.webview); + } + entry.onDidStopLoading = onDidStopLoading; + entry.placeholder = placeholder; + + entry.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + if (entry) this.updateLayout(entry); + }); + observer.observe(placeholder); + entry.resizeObserver = observer; + + requestAnimationFrame(() => { + if (entry && entry.placeholder === placeholder) { + this.updateLayout(entry); + } + }); + } + + detach(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.onDidStopLoading = null; + entry.placeholder = null; + entry.resizeObserver?.disconnect(); + entry.resizeObserver = null; + entry.visible = false; + entry.webview.style.visibility = "hidden"; + } + + setVisibility(paneId: string, visible: boolean): void { + const entry = this.entries.get(paneId); + if (!entry) return; + if (entry.visible === visible) return; + entry.visible = visible; + if (visible) { + this.updateLayout(entry); + entry.webview.style.visibility = "visible"; + } else { + entry.webview.style.visibility = "hidden"; + } + } + + destroy(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.resizeObserver?.disconnect(); + entry.detachHandlers(); + entry.webview.remove(); + this.entries.delete(paneId); + this.listenersByPaneId.delete(paneId); + electronTrpcClient.browser.unregister.mutate({ paneId }).catch(() => {}); + } + + navigate(paneId: string, url: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.webview.loadURL(sanitizeUrl(url)).catch((err) => { + console.error("[browserRuntimeRegistry] loadURL failed:", err); + }); + } + + goBack(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + const { history, historyIndex } = entry.state; + if (historyIndex <= 0) return; + const nextIndex = historyIndex - 1; + const target = history[nextIndex]; + entry.state = { + ...entry.state, + historyIndex: nextIndex, + canGoBack: nextIndex > 0, + canGoForward: true, + currentUrl: target.url, + pageTitle: target.title, + }; + this.notify(paneId); + entry.isHistoryNavigation = true; + entry.webview.loadURL(sanitizeUrl(target.url)).catch(() => {}); + } + + goForward(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + const { history, historyIndex } = entry.state; + if (historyIndex >= history.length - 1) return; + const nextIndex = historyIndex + 1; + const target = history[nextIndex]; + entry.state = { + ...entry.state, + historyIndex: nextIndex, + canGoBack: true, + canGoForward: nextIndex < history.length - 1, + currentUrl: target.url, + pageTitle: target.title, + }; + this.notify(paneId); + entry.isHistoryNavigation = true; + entry.webview.loadURL(sanitizeUrl(target.url)).catch(() => {}); + } + + reload(paneId: string): void { + const entry = this.entries.get(paneId); + entry?.webview.reload(); + } + + getState(paneId: string): BrowserRuntimeState { + return this.entries.get(paneId)?.state ?? EMPTY_STATE; + } + + onStateChange(paneId: string, listener: () => void): () => void { + const listeners = this.getListeners(paneId); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } +} + +export const browserRuntimeRegistry: BrowserRuntimeRegistryImpl = + (import.meta.hot?.data?.browserRegistry as + | BrowserRuntimeRegistryImpl + | undefined) ?? new BrowserRuntimeRegistryImpl(); + +if (import.meta.hot) { + import.meta.hot.data.browserRegistry = browserRuntimeRegistry; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx new file mode 100644 index 00000000000..ff97cc277bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx @@ -0,0 +1,109 @@ +import { Button } from "@superset/ui/button"; +import { GlobeIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { TbCopy } from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import type { BrowserLoadError } from "shared/tabs-types"; + +const ERROR_LABELS: Record = { + [-2]: "Network Changed", + [-6]: "Connection Refused", + [-7]: "Connection Timed Out", + [-21]: "Network Changed", + [-100]: "Connection Closed", + [-102]: "Connection Refused", + [-105]: "Name Not Resolved", + [-106]: "Internet Disconnected", + [-109]: "Address Unreachable", + [-118]: "Connection Timed Out", + [-137]: "Name Not Resolved", + [-200]: "Certificate Error", + [-201]: "Certificate Date Invalid", + [-202]: "Certificate Authority Invalid", +}; + +const FRIENDLY_MESSAGES: Record = { + [-2]: "The network connection changed", + [-6]: "Browser Connection was refused", + [-7]: "The connection timed out", + [-21]: "The network connection changed", + [-100]: "The connection was closed", + [-102]: "Browser Connection was refused", + [-105]: "The server could not be found", + [-106]: "You appear to be offline", + [-109]: "The address is unreachable", + [-118]: "The connection timed out", + [-137]: "The server could not be found", + [-200]: "The site's certificate is invalid", + [-201]: "The site's certificate has expired", + [-202]: "The site's certificate authority is not trusted", +}; + +interface BrowserErrorOverlayProps { + error: BrowserLoadError; + onRetry: () => void; +} + +export function BrowserErrorOverlay({ + error, + onRetry, +}: BrowserErrorOverlayProps) { + const [showDetails, setShowDetails] = useState(false); + const label = ERROR_LABELS[error.code] ?? "Page Load Failed"; + const friendlyMessage = + FRIENDLY_MESSAGES[error.code] ?? "The page could not be loaded"; + const detailsText = `Error Code: ${error.code} URL: ${error.url}`; + + const toggleDetails = useCallback(() => { + setShowDetails((prev) => !prev); + }, []); + + const { copyToClipboard } = useCopyToClipboard(); + const copyDetails = useCallback(() => { + copyToClipboard(detailsText); + }, [detailsText, copyToClipboard]); + + return ( +
+
+ +
+

+ {label} +

+

+ {friendlyMessage} +

+

+ {error.description} + {" · "} + +

+
+ {showDetails && ( +
+ + {detailsText} + + +
+ )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts new file mode 100644 index 00000000000..3d3140770b4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts @@ -0,0 +1 @@ +export { BrowserErrorOverlay } from "./BrowserErrorOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx new file mode 100644 index 00000000000..596aef63d65 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -0,0 +1,115 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { + TbCamera, + TbClock, + TbCopy, + TbDots, + TbReload, + TbTrash, +} from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface BrowserOverflowMenuProps { + paneId: string; + currentUrl: string; + hasPage: boolean; +} + +export function BrowserOverflowMenu({ + paneId, + currentUrl, + hasPage, +}: BrowserOverflowMenuProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleScreenshot = () => { + electronTrpcClient.browser.screenshot.mutate({ paneId }).catch(() => {}); + }; + + const handleHardReload = () => { + electronTrpcClient.browser.reload + .mutate({ paneId, hard: true }) + .catch(() => {}); + }; + + const handleCopyUrl = () => { + if (currentUrl) { + copyToClipboard(currentUrl); + } + }; + + const handleClearCookies = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "cookies" }) + .catch(() => {}); + }; + + const handleClearHistory = () => { + electronTrpcClient.browserHistory.clear.mutate().catch(() => {}); + }; + + const handleClearAllData = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "all" }) + .catch(() => {}); + }; + + return ( + + + + + + + + Take Screenshot + + + + Hard Reload + + + + Copy URL + + + + + Clear Browsing History + + + + Clear Cookies + + + + Clear All Data + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts new file mode 100644 index 00000000000..abd17b406e1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts @@ -0,0 +1 @@ +export { BrowserOverflowMenu } from "./BrowserOverflowMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/BrowserTabLabel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/BrowserTabLabel.tsx new file mode 100644 index 00000000000..253150aad35 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/BrowserTabLabel.tsx @@ -0,0 +1,59 @@ +import { memo, useCallback, useState, useSyncExternalStore } from "react"; +import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; + +interface BrowserTabLabelProps { + paneId: string; + fallbackTitle: string; +} + +function deriveDisplayTitle( + pageTitle: string, + currentUrl: string, + fallbackTitle: string, +): string { + if (pageTitle) return pageTitle; + if (currentUrl && currentUrl !== "about:blank") { + try { + return new URL(currentUrl).hostname || fallbackTitle; + } catch { + return fallbackTitle; + } + } + return fallbackTitle; +} + +function BrowserTabLabelImpl({ paneId, fallbackTitle }: BrowserTabLabelProps) { + const state = useSyncExternalStore( + useCallback( + (cb) => browserRuntimeRegistry.onStateChange(paneId, cb), + [paneId], + ), + useCallback(() => browserRuntimeRegistry.getState(paneId), [paneId]), + ); + + const [brokenFaviconUrl, setBrokenFaviconUrl] = useState(null); + const faviconUrl = state.faviconUrl; + const showFavicon = !!faviconUrl && faviconUrl !== brokenFaviconUrl; + + const title = deriveDisplayTitle( + state.pageTitle, + state.currentUrl, + fallbackTitle, + ); + + return ( + + {showFavicon && ( + setBrokenFaviconUrl(faviconUrl ?? null)} + /> + )} + {title} + + ); +} + +export const BrowserTabLabel = memo(BrowserTabLabelImpl); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/index.ts new file mode 100644 index 00000000000..9d91b19a6eb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserTabLabel/index.ts @@ -0,0 +1 @@ +export { BrowserTabLabel } from "./BrowserTabLabel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx new file mode 100644 index 00000000000..1c6cff146d0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx @@ -0,0 +1,215 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + TbArrowLeft, + TbArrowRight, + TbLoader2, + TbRefresh, +} from "react-icons/tb"; +import { UrlSuggestions } from "./components/UrlSuggestions"; +import { useUrlAutocomplete } from "./hooks/useUrlAutocomplete"; + +function displayUrl(url: string): string { + if (url === "about:blank") return ""; + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +interface BrowserToolbarProps { + currentUrl: string; + pageTitle: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + onGoBack: () => void; + onGoForward: () => void; + onReload: () => void; + onNavigate: (url: string) => void; +} + +export function BrowserToolbar({ + currentUrl, + pageTitle, + isLoading, + canGoBack, + canGoForward, + onGoBack, + onGoForward, + onReload, + onNavigate, +}: BrowserToolbarProps) { + const [isEditing, setIsEditing] = useState(false); + const [urlInputValue, setUrlInputValue] = useState(""); + const inputRef = useRef(null); + + const url = displayUrl(currentUrl); + const isBlank = !url; + + const autocomplete = useUrlAutocomplete({ + onSelect: (selectedUrl) => { + onNavigate(selectedUrl); + setIsEditing(false); + }, + }); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditing]); + + const enterEditMode = useCallback(() => { + setUrlInputValue(url); + setIsEditing(true); + autocomplete.open(); + autocomplete.updateQuery(url); + }, [url, autocomplete]); + + const exitEditMode = useCallback(() => { + setIsEditing(false); + autocomplete.close(); + }, [autocomplete]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = urlInputValue.trim(); + if (trimmed) { + onNavigate(trimmed); + setIsEditing(false); + autocomplete.close(); + } + }, + [urlInputValue, onNavigate, autocomplete], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setUrlInputValue(value); + autocomplete.updateQuery(value); + if (!autocomplete.isOpen) { + autocomplete.open(); + } + }, + [autocomplete], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const handled = autocomplete.handleKeyDown(e); + if (handled) return; + if (e.key === "Escape") { + setIsEditing(false); + } + }, + [autocomplete], + ); + + return ( +
+
+ + + + + + Go Back + + + + + + + + Go Forward + + + + + + + + {isLoading ? "Loading..." : "Reload"} + + +
+
+
+ {isEditing ? ( +
+ +
+ ) : ( + + )} + {isEditing && autocomplete.isOpen && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx new file mode 100644 index 00000000000..af335676392 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { TbGlobe } from "react-icons/tb"; +import type { HistorySuggestion } from "../../hooks/useUrlAutocomplete"; + +interface UrlSuggestionsProps { + suggestions: HistorySuggestion[]; + highlightedIndex: number; + onSelect: (url: string) => void; +} + +export function UrlSuggestions({ + suggestions, + highlightedIndex, + onSelect, +}: UrlSuggestionsProps) { + const listRef = useRef(null); + + useEffect(() => { + if (highlightedIndex < 0 || !listRef.current) return; + const items = listRef.current.children; + const item = items[highlightedIndex] as HTMLElement | undefined; + item?.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex]); + + if (suggestions.length === 0) return null; + + return ( +
+ {suggestions.map((item, index) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts new file mode 100644 index 00000000000..482fccd3641 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts @@ -0,0 +1 @@ +export { UrlSuggestions } from "./UrlSuggestions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts new file mode 100644 index 00000000000..0530813da53 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts @@ -0,0 +1,4 @@ +export { + type HistorySuggestion, + useUrlAutocomplete, +} from "./useUrlAutocomplete"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts new file mode 100644 index 00000000000..185f39af077 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +export interface HistorySuggestion { + url: string; + title: string; + faviconUrl: string | null; + lastVisitedAt: number; + visitCount: number; +} + +interface UseUrlAutocompleteOptions { + onSelect: (url: string) => void; +} + +export function useUrlAutocomplete({ onSelect }: UseUrlAutocompleteOptions) { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [query, setQuery] = useState(""); + const [allHistory, setAllHistory] = useState([]); + const suggestionsRef = useRef([]); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + electronTrpcClient.browserHistory.getAll + .query() + .then((items) => { + if (!cancelled) setAllHistory(items as HistorySuggestion[]); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [isOpen]); + + const suggestions = useMemo(() => { + if (!query.trim()) { + return allHistory.slice(0, 15); + } + const lower = query.toLowerCase(); + return allHistory + .filter( + (item) => + item.url.toLowerCase().includes(lower) || + item.title.toLowerCase().includes(lower), + ) + .slice(0, 8); + }, [allHistory, query]); + + suggestionsRef.current = suggestions; + + const open = useCallback(() => { + setIsOpen(true); + setHighlightedIndex(-1); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + setHighlightedIndex(-1); + }, []); + + const updateQuery = useCallback((value: string) => { + setQuery(value); + setHighlightedIndex(-1); + }, []); + + const selectSuggestion = useCallback( + (url: string) => { + onSelect(url); + close(); + }, + [onSelect, close], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!isOpen || suggestions.length === 0) return false; + + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : 0, + ); + return true; + } + case "ArrowUp": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : suggestions.length - 1, + ); + return true; + } + case "Enter": { + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[highlightedIndex].url); + return true; + } + return false; + } + case "Escape": { + if (isOpen) { + e.preventDefault(); + e.stopPropagation(); + close(); + return true; + } + return false; + } + default: + return false; + } + }, + [isOpen, suggestions, highlightedIndex, selectSuggestion, close], + ); + + return { + isOpen, + suggestions, + highlightedIndex, + open, + close, + updateQuery, + selectSuggestion, + handleKeyDown, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts new file mode 100644 index 00000000000..787341a48a8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts @@ -0,0 +1 @@ +export { BrowserToolbar } from "./BrowserToolbar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts new file mode 100644 index 00000000000..8ff6c1ff18a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_BROWSER_URL = "about:blank"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts new file mode 100644 index 00000000000..e7521359cee --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts @@ -0,0 +1 @@ +export { usePersistentWebview } from "./usePersistentWebview"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts new file mode 100644 index 00000000000..10a721a54df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -0,0 +1,109 @@ +import type { RendererContext } from "@superset/panes"; +import { useCallback, useEffect, useRef } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { + BrowserPaneData, + PaneViewerData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; +import { DEFAULT_BROWSER_URL } from "../../constants"; + +interface UsePersistentWebviewOptions { + paneId: string; + ctx: RendererContext; +} + +export function usePersistentWebview({ + paneId, + ctx, +}: UsePersistentWebviewOptions) { + const placeholderRef = useRef(null); + const ctxRef = useRef(ctx); + ctxRef.current = ctx; + + const paneData = ctx.pane.data as BrowserPaneData; + const initialUrlRef = useRef(paneData.url || DEFAULT_BROWSER_URL); + + useEffect(() => { + const placeholder = placeholderRef.current; + if (!placeholder) return; + + browserRuntimeRegistry.attach( + paneId, + placeholder, + initialUrlRef.current, + ({ url, title }) => { + const current = ctxRef.current.pane.data as BrowserPaneData; + if (current.url === url && current.pageTitle === title) return; + ctxRef.current.actions.updateData({ + ...current, + url, + pageTitle: title, + }); + }, + ); + + return () => { + browserRuntimeRegistry.detach(paneId); + }; + }, [paneId]); + + useEffect(() => { + const newWindowSub = electronTrpcClient.browser.onNewWindow.subscribe( + { paneId }, + { + onData: ({ url }: { url: string }) => { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + }, + }, + ); + const contextMenuSub = + electronTrpcClient.browser.onContextMenuAction.subscribe( + { paneId }, + { + onData: ({ action, url }: { action: string; url: string }) => { + if (action === "open-in-split") { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + } + }, + }, + ); + return () => { + newWindowSub.unsubscribe(); + contextMenuSub.unsubscribe(); + }; + }, [paneId]); + + const goBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const goForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const reload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const navigateTo = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + return { + placeholderRef, + goBack, + goForward, + reload, + navigateTo, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts new file mode 100644 index 00000000000..c458d6aa8f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts @@ -0,0 +1 @@ +export { BrowserPane, BrowserPaneToolbar } from "./BrowserPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts new file mode 100644 index 00000000000..74b87732568 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts @@ -0,0 +1,12 @@ +export function sanitizeUrl(url: string): string { + if (/^https?:\/\//i.test(url) || url.startsWith("about:")) { + return url; + } + if (url.startsWith("localhost") || url.startsWith("127.0.0.1")) { + return `http://${url}`; + } + if (url.includes(".")) { + return `https://${url}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(url)}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 02f30ef979e..326ed91d150 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -23,6 +23,7 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; +import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { FilePane } from "./components/FilePane"; import { TerminalPane } from "./components/TerminalPane"; @@ -181,18 +182,14 @@ export function usePaneRegistry( getIcon: () => , getTitle: (ctx: RendererContext) => { const data = ctx.pane.data as BrowserPaneData; - return data.url; - }, - renderPane: (ctx: RendererContext) => { - const data = ctx.pane.data as BrowserPaneData; - return ( -