feat(desktop): open workbench pages in system browser

This commit is contained in:
程序员阿江(Relakkes)
2026-08-04 18:35:41 +08:00
parent b5bf3f8dd1
commit dfc04c2403
4 changed files with 42 additions and 6 deletions
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
const baseProps = {
url: 'http://localhost:5173/', canGoBack: false, canGoForward: false,
onNavigate: vi.fn(), onBack: vi.fn(), onForward: vi.fn(), onReload: vi.fn(),
onNavigate: vi.fn(), onBack: vi.fn(), onForward: vi.fn(), onReload: vi.fn(), onOpenExternal: vi.fn(),
}
beforeEach(() => {
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowLeft, ArrowRight, RotateCw } from 'lucide-react'
import { ArrowLeft, ArrowRight, ArrowUpRight, RotateCw } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { Spinner } from '@/components/ui/Spinner'
import { useTranslation } from '../../i18n'
@@ -15,10 +15,11 @@ type Props = {
onBack: () => void
onForward: () => void
onReload: () => void
onOpenExternal: () => void
rightActions?: ReactNode
}
export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = false, onNavigate, onBack, onForward, onReload, rightActions }: Props) {
export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = false, onNavigate, onBack, onForward, onReload, onOpenExternal, rightActions }: Props) {
const t = useTranslation()
const [draft, setDraft] = useState(url)
useEffect(() => { setDraft(url) }, [url])
@@ -39,14 +40,23 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
aria-busy={loading}
onClick={onReload}
/>
<form className="min-w-0 flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
<form className="relative min-w-0 flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
<input
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 font-mono text-xs text-[var(--color-text-primary)] outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:font-[var(--font-body)] placeholder:text-[var(--color-text-tertiary)] hover:border-[var(--color-outline)] focus-visible:border-[var(--color-border-focus)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-2.5 pr-8 font-mono text-xs text-[var(--color-text-primary)] outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:font-[var(--font-body)] placeholder:text-[var(--color-text-tertiary)] hover:border-[var(--color-outline)] focus-visible:border-[var(--color-border-focus)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('browser.addressPlaceholder')}
spellCheck={false}
/>
<IconButton
icon={<ArrowUpRight size={15} strokeWidth={1.9} aria-hidden="true" />}
label={t('openWith.systemBrowser')}
size="xs"
tone="muted"
disabled={!url}
onClick={onOpenExternal}
className="absolute right-0.5 top-1/2 -translate-y-1/2"
/>
</form>
{rightActions && (
<div data-testid="browser-toolbar-actions" className="ml-1 flex shrink-0 items-center gap-1">
@@ -9,7 +9,7 @@ beforeAll(() => {
})
})
const { bridge, sendMessage } = vi.hoisted(() => ({
const { bridge, openExternal, sendMessage } = vi.hoisted(() => ({
bridge: {
open: vi.fn(),
navigate: vi.fn(),
@@ -19,9 +19,20 @@ const { bridge, sendMessage } = vi.hoisted(() => ({
close: vi.fn(),
message: vi.fn(),
},
openExternal: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn(),
}))
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('../../lib/desktopHost', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/desktopHost')>()
return {
...actual,
getDesktopHost: () => {
const host = actual.getDesktopHost()
return { ...host, shell: { ...host.shell, open: openExternal } }
},
}
})
vi.mock('../../stores/chatStore', () => ({
useChatStore: { getState: () => ({ sendMessage }) },
}))
@@ -49,6 +60,7 @@ afterEach(() => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
usePreviewSelectionStore.setState({ bySession: {} })
useSettingsStore.setState({ uiZoom: 1 })
openExternal.mockClear()
sendMessage.mockReset()
setBaseUrl(getDefaultBaseUrl())
})
@@ -218,6 +230,15 @@ describe('BrowserSurface', () => {
expect(screen.getByRole('textbox').closest('form')!.compareDocumentPosition(actions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('opens the current preview URL in the system browser', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByLabelText('系统浏览器'))
expect(openExternal).toHaveBeenCalledWith('http://localhost:5173/')
})
it('选择元素 button toggles pickerActive and signals the bridge', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
@@ -8,6 +8,7 @@ import { Spinner } from '@/components/ui/Spinner'
import { useTranslation } from '../../i18n'
import { BrowserAddressBar } from './BrowserAddressBar'
import { computeWebviewBounds } from './computeWebviewBounds'
import { getDesktopHost } from '../../lib/desktopHost'
import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime'
import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
@@ -401,6 +402,10 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
requestNativePreview(session.url, { force: true })
})
}}
onOpenExternal={() => {
if (!session.url) return
void getDesktopHost().shell.open(session.url)
}}
rightActions={previewActions}
/>
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">