mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
feat(desktop): open workbench pages in system browser
This commit is contained in:
@@ -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)]">
|
||||
|
||||
Reference in New Issue
Block a user