fix(desktop): route typed local html in browser panel

Tested:
- cd desktop && bun run test -- src/components/browser/BrowserAddressBar.test.tsx src/components/browser/BrowserSurface.test.tsx --run --reporter=verbose --pool=forks --maxWorkers=1 --minWorkers=1
- bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 22:44:52 +08:00
parent b70f25e62b
commit f9575b9749
4 changed files with 59 additions and 2 deletions

View File

@ -48,6 +48,20 @@ describe('BrowserAddressBar', () => {
expect(onNavigate).toHaveBeenNthCalledWith(2, 'https://example.com')
})
it('preserves typed local html paths for the browser surface to route', () => {
const onNavigate = vi.fn()
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '/private/tmp/report.html' } })
fireEvent.submit(input.closest('form')!)
fireEvent.change(input, { target: { value: 'out/index.html' } })
fireEvent.submit(input.closest('form')!)
expect(onNavigate).toHaveBeenNthCalledWith(1, '/private/tmp/report.html')
expect(onNavigate).toHaveBeenNthCalledWith(2, 'out/index.html')
})
it('preserves explicit URL schemes', () => {
const onNavigate = vi.fn()
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowLeft, ArrowRight, Loader2, RotateCw } from 'lucide-react'
import { isHtmlFilePath } from '../../lib/htmlPreviewPolicy'
type Props = {
url: string
@ -58,6 +59,7 @@ export function normalizeBrowserAddress(input: string): string {
const value = input.trim()
if (!value) return ''
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value) || /^(about|data|file):/i.test(value)) return value
if (isHtmlFilePath(value)) return value
if (/^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)(?::\d+)?(?:[/?#].*)?$/i.test(value)) {
return `http://${value}`
}

View File

@ -16,6 +16,7 @@ vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
import { BrowserSurface } from './BrowserSurface'
import { getDefaultBaseUrl, setBaseUrl } from '../../api/client'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
@ -28,6 +29,7 @@ afterEach(() => {
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
setBaseUrl(getDefaultBaseUrl())
})
describe('BrowserSurface', () => {
@ -76,6 +78,27 @@ describe('BrowserSurface', () => {
expect(bridge.navigate).not.toHaveBeenCalled()
})
it('opens a typed file URL for local html through the local-file preview route', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
setBaseUrl('http://127.0.0.1:8787')
useBrowserPanelStore.getState().ensureBlank('s1')
render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'file:///private/tmp/report.html' } })
fireEvent.submit(input.closest('form')!)
await waitFor(() => {
expect(bridge.open).toHaveBeenCalledWith(
'http://127.0.0.1:8787/local-file/private/tmp/report.html',
expect.objectContaining({ width: expect.any(Number) }),
)
})
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe(
'http://127.0.0.1:8787/local-file/private/tmp/report.html',
)
})
it('navigating via address bar calls store + bridge', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)

View File

@ -2,7 +2,9 @@ import { useEffect, useLayoutEffect, useRef } from 'react'
import { Camera, Loader2, MousePointer2 } from 'lucide-react'
import { BrowserAddressBar } from './BrowserAddressBar'
import { computeWebviewBounds } from './computeWebviewBounds'
import { isLoopbackHostname } from '../../lib/desktopRuntime'
import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime'
import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { previewBridge } from '../../lib/previewBridge'
import { subscribePreviewEvents } from '../../lib/previewEvents'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
@ -38,6 +40,21 @@ async function waitForLocalPreview(url: string): Promise<void> {
}
}
function resolveBrowserNavigationUrl(input: string, sessionId: string): string {
const value = input.trim()
if (!value) return ''
const classified = classifyPreviewLink(value)
if (classified.kind === 'browser-file' && classified.path) {
const serverBaseUrl = getServerBaseUrl()
return isAbsoluteLocalPath(classified.path)
? localFileUrl(serverBaseUrl, classified.path)
: previewFsUrl(serverBaseUrl, sessionId, classified.path)
}
return value
}
export function BrowserSurface({ sessionId }: { sessionId: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const loadSeqRef = useRef(0)
@ -126,7 +143,8 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
if (!session) return null
const openOrNavigate = (url: string) => {
const openOrNavigate = (inputUrl: string) => {
const url = resolveBrowserNavigationUrl(inputUrl, sessionId)
if (!url) return
const current = useBrowserPanelStore.getState().bySession[sessionId]
store.navigate(sessionId, url)