From 0ac56a6b111f8c03277c0f4cbb60b7ea9f4cef84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 12:00:11 +0800 Subject: [PATCH] feat(desktop): add BrowserSurface with bounds sync and address bar wiring Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/BrowserSurface.test.tsx | 45 ++++++++++++++++ .../src/components/browser/BrowserSurface.tsx | 54 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 desktop/src/components/browser/BrowserSurface.test.tsx create mode 100644 desktop/src/components/browser/BrowserSurface.tsx diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx new file mode 100644 index 00000000..6e44289f --- /dev/null +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver +}) + +const { bridge } = vi.hoisted(() => ({ + bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn() }, +})) +vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge })) + +import { BrowserSurface } from './BrowserSurface' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' + +afterEach(() => { + Object.values(bridge).forEach((f) => f.mockReset()) + useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) +}) + +describe('BrowserSurface', () => { + it('opens the preview at the session url on mount when surface is open', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) })) + }) + + it('navigating via address bar calls store + bridge', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'http://localhost:3000/' } }) + fireEvent.submit(input.closest('form')!) + expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:3000/') + expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost:3000/') + }) + + it('hides the native webview on unmount', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + const { unmount } = render() + unmount() + expect(bridge.setVisible).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx new file mode 100644 index 00000000..f51d39aa --- /dev/null +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -0,0 +1,54 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { BrowserAddressBar } from './BrowserAddressBar' +import { computeWebviewBounds } from './computeWebviewBounds' +import { previewBridge } from '../../lib/previewBridge' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' + +export function BrowserSurface({ sessionId }: { sessionId: string }) { + const hostRef = useRef(null) + const session = useBrowserPanelStore((s) => s.bySession[sessionId]) + const store = useBrowserPanelStore.getState() + + const reportBounds = () => { + const el = hostRef.current + if (!el) return + previewBridge.setBounds(computeWebviewBounds(el.getBoundingClientRect())) + } + + useLayoutEffect(() => { + const el = hostRef.current + if (!el || !session) return + previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect())) + previewBridge.setVisible(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + useEffect(() => { + const el = hostRef.current + if (!el) return + const ro = new ResizeObserver(() => reportBounds()) + ro.observe(el) + window.addEventListener('resize', reportBounds) + return () => { ro.disconnect(); window.removeEventListener('resize', reportBounds) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + useEffect(() => () => { previewBridge.setVisible(false) }, []) + + if (!session) return null + + return ( +
+ { store.navigate(sessionId, url); previewBridge.navigate(url) }} + onBack={() => { store.goBack(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} + onForward={() => { store.goForward(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} + onReload={() => previewBridge.navigate(session.url)} + /> +
+
+ ) +}