diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index a7cbcf65..bc4feb4d 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' beforeAll(() => { @@ -22,6 +22,7 @@ import { useOverlayStore } from '../../stores/overlayStore' afterEach(() => { cleanup() + vi.restoreAllMocks() Object.values(bridge).forEach((f) => f.mockReset()) useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) // browserPanelStore.open() now also opens the unified workbench; keep it isolated. @@ -36,6 +37,28 @@ describe('BrowserSurface', () => { expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) })) }) + it('waits for local preview URLs before opening the native preview', async () => { + const url = 'http://127.0.0.1:59028/preview-fs/s1/66estmutl_files/index.html' + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })) + useBrowserPanelStore.getState().open('s1', url) + render() + + expect(within(screen.getByTestId('preview-host')).getByLabelText('加载中')).toBeInTheDocument() + expect(bridge.open).not.toHaveBeenCalled() + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith(url, expect.objectContaining({ + method: 'HEAD', + cache: 'no-store', + })) + }) + await waitFor(() => { + expect(bridge.open).toHaveBeenCalledWith(url, expect.objectContaining({ width: expect.any(Number) })) + }) + + fetchSpy.mockRestore() + }) + it('renders an empty address bar without opening a preview for a blank session', () => { useBrowserPanelStore.getState().ensureBlank('s1') render() @@ -131,7 +154,9 @@ describe('BrowserSurface', () => { useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') render() expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) - vi.advanceTimersByTime(15000) + act(() => { + vi.advanceTimersByTime(15000) + }) expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false) } finally { vi.useRealTimers() diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index 57afdd52..f3586613 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -1,14 +1,46 @@ import { useEffect, useLayoutEffect, useRef } from 'react' -import { Camera, MousePointer2 } from 'lucide-react' +import { Camera, Loader2, MousePointer2 } from 'lucide-react' import { BrowserAddressBar } from './BrowserAddressBar' import { computeWebviewBounds } from './computeWebviewBounds' +import { isLoopbackHostname } from '../../lib/desktopRuntime' import { previewBridge } from '../../lib/previewBridge' import { subscribePreviewEvents } from '../../lib/previewEvents' import { useBrowserPanelStore } from '../../stores/browserPanelStore' import { useOverlayStore } from '../../stores/overlayStore' +const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/'] +const LOCAL_PREVIEW_READY_TIMEOUT_MS = 2500 + +function shouldWaitForLocalPreview(url: string): boolean { + try { + const parsed = new URL(url) + return isLoopbackHostname(parsed.hostname) && + LOCAL_PREVIEW_PATH_PREFIXES.some((prefix) => parsed.pathname.startsWith(prefix)) + } catch { + return false + } +} + +async function waitForLocalPreview(url: string): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), LOCAL_PREVIEW_READY_TIMEOUT_MS) + try { + await fetch(url, { + method: 'HEAD', + cache: 'no-store', + signal: controller.signal, + }) + } catch { + // Best-effort warmup only. The native webview still navigates so users can + // see the server's own error page or use Reload if the first probe raced. + } finally { + window.clearTimeout(timeout) + } +} + export function BrowserSurface({ sessionId }: { sessionId: string }) { const hostRef = useRef(null) + const loadSeqRef = useRef(0) const session = useBrowserPanelStore((s) => s.bySession[sessionId]) const store = useBrowserPanelStore.getState() const overlayCount = useOverlayStore((s) => s.count) @@ -19,12 +51,36 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { previewBridge.setBounds(computeWebviewBounds(el.getBoundingClientRect())) } + const loadNativePreview = ( + url: string, + action: () => Promise, + ) => { + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq + void (async () => { + if (shouldWaitForLocalPreview(url)) { + await waitForLocalPreview(url) + } + if (loadSeqRef.current !== seq) return + await action() + })().catch(() => { + if (loadSeqRef.current === seq) { + useBrowserPanelStore.getState().setLoading(sessionId, false) + } + }) + } + useLayoutEffect(() => { const el = hostRef.current if (el && session?.url) { - previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect())) + const bounds = computeWebviewBounds(el.getBoundingClientRect()) + const url = session.url + loadNativePreview(url, () => previewBridge.open(url, bounds)) + } + return () => { + loadSeqRef.current += 1 + previewBridge.close() } - return () => { previewBridge.close() } // The visibility-sync effect below owns setVisible() — including the // initial reveal — so it always factors in overlayCount. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -75,14 +131,15 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { const current = useBrowserPanelStore.getState().bySession[sessionId] store.navigate(sessionId, url) if (current?.url) { - previewBridge.navigate(url) + loadNativePreview(url, () => previewBridge.navigate(url)) return } const el = hostRef.current if (el) { - previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect())) + const bounds = computeWebviewBounds(el.getBoundingClientRect()) + loadNativePreview(url, () => previewBridge.open(url, bounds)) } else { - previewBridge.navigate(url) + loadNativePreview(url, () => previewBridge.navigate(url)) } } @@ -135,16 +192,32 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { canGoForward={session.canGoForward} loading={session.loading} onNavigate={openOrNavigate} - onBack={() => { store.goBack(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} - onForward={() => { store.goForward(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} + onBack={() => { + store.goBack(sessionId) + store.setLoading(sessionId, true) + const url = useBrowserPanelStore.getState().bySession[sessionId]!.url + loadNativePreview(url, () => previewBridge.navigate(url)) + }} + onForward={() => { + store.goForward(sessionId) + store.setLoading(sessionId, true) + const url = useBrowserPanelStore.getState().bySession[sessionId]!.url + loadNativePreview(url, () => previewBridge.navigate(url)) + }} onReload={() => { if (!session.url) return store.setLoading(sessionId, true) - previewBridge.navigate(session.url) + loadNativePreview(session.url, () => previewBridge.navigate(session.url)) }} rightActions={previewActions} /> -
+
+ {session.loading && ( +
+ +
+ )} +
) } diff --git a/desktop/src/components/common/OpenWithMenu.test.tsx b/desktop/src/components/common/OpenWithMenu.test.tsx index a2481163..0a8103c4 100644 --- a/desktop/src/components/common/OpenWithMenu.test.tsx +++ b/desktop/src/components/common/OpenWithMenu.test.tsx @@ -69,6 +69,13 @@ describe('OpenWithMenu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('scrolling the viewport calls onClose', () => { + const onClose = vi.fn() + render() + fireEvent.scroll(window) + expect(onClose).toHaveBeenCalledTimes(1) + }) + describe('triggerEl exclusion (re-click-trigger toggle support)', () => { it('does NOT call onClose when mousedown lands inside triggerEl', () => { // Set up a trigger element + a child within it in the document. diff --git a/desktop/src/components/common/OpenWithMenu.tsx b/desktop/src/components/common/OpenWithMenu.tsx index ae1f75de..340dd6a8 100644 --- a/desktop/src/components/common/OpenWithMenu.tsx +++ b/desktop/src/components/common/OpenWithMenu.tsx @@ -61,9 +61,17 @@ export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) { onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + const onViewportMove = () => onClose() document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) - return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) } + window.addEventListener('scroll', onViewportMove, true) + window.addEventListener('resize', onViewportMove) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + window.removeEventListener('scroll', onViewportMove, true) + window.removeEventListener('resize', onViewportMove) + } }, [onClose, triggerEl]) return createPortal( diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx index 000284bb..0d7300cb 100644 --- a/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx @@ -7,6 +7,8 @@ import { browserHost } from '../../lib/desktopHost/browserHost' const openTarget = vi.hoisted(() => vi.fn()) const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) const hostOpenPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const browserOpen = vi.hoisted(() => vi.fn()) +const openPreview = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) vi.mock('../../stores/openTargetStore', () => ({ useOpenTargetStore: (sel: (s: unknown) => unknown) => @@ -27,6 +29,18 @@ vi.mock('../../i18n', () => ({ v?.target ? `${k}:${v.target}` : k, })) +vi.mock('../../stores/browserPanelStore', () => ({ + useBrowserPanelStore: { + getState: () => ({ open: browserOpen }), + }, +})) + +vi.mock('../../stores/workspacePanelStore', () => ({ + useWorkspacePanelStore: { + getState: () => ({ openPreview }), + }, +})) + import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith' describe('WorkspaceFileOpenWith', () => { @@ -82,4 +96,60 @@ describe('WorkspaceFileOpenWith', () => { expect(shellOpen).not.toHaveBeenCalled() expect(hostOpenPath).not.toHaveBeenCalled() }) + + it('offers workspace preview and in-app browser for generated html files with session context', () => { + const { getAllByRole } = render( + , + ) + + const menuItems = getAllByRole('menuitem') + const labels = menuItems.map((el) => el.textContent) + expect(labels.some((l) => l?.includes('openWith.workspacePreview'))).toBe(true) + expect(labels.some((l) => l?.includes('openWith.inAppBrowser'))).toBe(true) + expect(labels.some((l) => l?.includes('VS Code'))).toBe(true) + expect(labels.some((l) => l?.includes('Finder'))).toBe(true) + }) + + it('opens the in-app browser preview URL from workspace html files', () => { + const { getAllByRole } = render( + , + ) + + const menuItems = getAllByRole('menuitem') + const inAppItem = menuItems.find((el) => el.textContent?.includes('openWith.inAppBrowser')) + if (!inAppItem) throw new Error('In-app browser menu item not found') + + fireEvent.click(inAppItem) + + expect(browserOpen).toHaveBeenCalledWith( + 's1', + 'http://127.0.0.1:3456/preview-fs/s1/66estmutl_files/index.html', + ) + }) + + it('opens the workspace preview for workspace files with session context', () => { + const { getAllByRole } = render( + , + ) + + const menuItems = getAllByRole('menuitem') + const previewItem = menuItems.find((el) => el.textContent?.includes('openWith.workspacePreview')) + if (!previewItem) throw new Error('Workspace preview menu item not found') + + fireEvent.click(previewItem) + + expect(openPreview).toHaveBeenCalledWith('s1', 'report.md', 'file') + }) }) diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx index 8a481d4b..cabcdbb2 100644 --- a/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx @@ -4,6 +4,10 @@ import { useTranslation, type TranslationKey } from '../../i18n' import { useOpenTargetStore } from '../../stores/openTargetStore' import { buildOpenWithItems, type OpenWithItem, type OpenWithDeps } from '../../lib/openWithItems' import { getDesktopHost } from '../../lib/desktopHost' +import { getServerBaseUrl } from '../../lib/desktopRuntime' +import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { TargetIcon } from '../common/TargetIcon' function openExternal(path: string) { @@ -12,9 +16,13 @@ function openExternal(path: string) { export function WorkspaceFileOpenWith({ absolutePath, + sessionId, + workspacePath, onAfterSelect, }: { absolutePath: string + sessionId?: string + workspacePath?: string onAfterSelect?: () => void }) { const t = useTranslation() @@ -30,8 +38,14 @@ export function WorkspaceFileOpenWith({ // but OpenWithDeps.t expects (key: string, vars?: Record) => string. // All keys buildOpenWithItems calls are valid TranslationKeys, so the cast is safe. const deps: OpenWithDeps = { - openInAppBrowser: () => {}, - openWorkspacePreview: () => {}, + openInAppBrowser: (url) => { + if (!sessionId) return + useBrowserPanelStore.getState().open(sessionId, url) + }, + openWorkspacePreview: (relPath) => { + if (!sessionId) return + void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file') + }, openSystem: (p) => openExternal(p), openTarget: (id, p) => { void openTarget(id, p) @@ -39,7 +53,12 @@ export function WorkspaceFileOpenWith({ t: (key, vars) => t(key as TranslationKey, vars), } const items: OpenWithItem[] = buildOpenWithItems( - { kind: 'file', absolutePath, previewable: false }, + sessionId && workspacePath + ? openWithContextForWorkspaceFile(workspacePath, absolutePath, { + sessionId, + serverBaseUrl: getServerBaseUrl(), + }) + : { kind: 'file', absolutePath, previewable: false }, targets, deps, ) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index 7939528f..5e42bb08 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -1516,6 +1516,8 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr setFileContextMenu(null)} /> diff --git a/desktop/src/lib/handlePreviewLink.test.ts b/desktop/src/lib/handlePreviewLink.test.ts index 78d55496..46c25f2a 100644 --- a/desktop/src/lib/handlePreviewLink.test.ts +++ b/desktop/src/lib/handlePreviewLink.test.ts @@ -64,6 +64,17 @@ describe('handlePreviewLink', () => { expect(deps.openFilePreview).not.toHaveBeenCalled() }) + it('routes generated *_files index.html to openBrowser with the preview-fs url', () => { + const deps = makeDeps() + const handled = handlePreviewLink('66estmutl_files/index.html', deps) + expect(handled).toBe(true) + expect(deps.openBrowser).toHaveBeenCalledWith( + 's1', + 'http://127.0.0.1:8787/preview-fs/s1/66estmutl_files/index.html', + ) + expect(deps.openFilePreview).not.toHaveBeenCalled() + }) + it('routes a frontend project index.html to workspace preview instead of static browser preview', () => { const deps = makeDeps() const handled = handlePreviewLink('todo-app/index.html', deps) diff --git a/desktop/src/lib/htmlPreviewPolicy.ts b/desktop/src/lib/htmlPreviewPolicy.ts index fa81dd1e..773b5741 100644 --- a/desktop/src/lib/htmlPreviewPolicy.ts +++ b/desktop/src/lib/htmlPreviewPolicy.ts @@ -1,5 +1,6 @@ const HTML_EXT = /\.(html?|xhtml)$/i const INDEX_HTML_RE = /(^|\/)index\.html?$/i +const GENERATED_STATIC_FILES_DIR_RE = /(^|\/)[^/]+_files\/index\.html?$/i const STATIC_OUTPUT_DIRS = new Set([ 'build', @@ -28,6 +29,7 @@ export function shouldOfferStaticHtmlPreview(filePath: string): boolean { const normalized = normalizePathForPolicy(filePath) if (!HTML_EXT.test(normalized)) return false if (!INDEX_HTML_RE.test(normalized)) return true + if (GENERATED_STATIC_FILES_DIR_RE.test(normalized)) return true return normalized .split('/') diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index 436e01ca..b294c0f6 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -68,6 +68,17 @@ describe('openWithContextForWorkspaceFile', () => { }) }) + it('generated *_files index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => { + const result = openWithContextForWorkspaceFile('66estmutl_files/index.html', '/w/proj/66estmutl_files/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) + expect(result).toEqual({ + kind: 'file', + absolutePath: '/w/proj/66estmutl_files/index.html', + relPath: '66estmutl_files/index.html', + previewable: true, + inAppBrowserUrl: previewFsUrl(BASE, SESSION, '66estmutl_files/index.html'), + }) + }) + it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => { const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) expect(result).toEqual({