diff --git a/desktop/src/components/browser/BrowserAddressBar.test.tsx b/desktop/src/components/browser/BrowserAddressBar.test.tsx index e6c6cc3a..7db901b0 100644 --- a/desktop/src/components/browser/BrowserAddressBar.test.tsx +++ b/desktop/src/components/browser/BrowserAddressBar.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' import { BrowserAddressBar } from './BrowserAddressBar' @@ -8,6 +8,10 @@ const baseProps = { onNavigate: vi.fn(), onBack: vi.fn(), onForward: vi.fn(), onReload: vi.fn(), } +afterEach(() => { + cleanup() +}) + describe('BrowserAddressBar', () => { it('shows the current url in the input', () => { render() @@ -23,6 +27,36 @@ describe('BrowserAddressBar', () => { expect(onNavigate).toHaveBeenCalledWith('http://localhost:3000/x') }) + it('submits an empty address as empty', () => { + const onNavigate = vi.fn() + render() + fireEvent.submit(screen.getByRole('textbox').closest('form')!) + expect(onNavigate).toHaveBeenCalledWith('') + }) + + it('normalizes localhost and bare domains before navigating', () => { + const onNavigate = vi.fn() + render() + const input = screen.getByRole('textbox') + + fireEvent.change(input, { target: { value: 'localhost:3000' } }) + fireEvent.submit(input.closest('form')!) + fireEvent.change(input, { target: { value: 'example.com' } }) + fireEvent.submit(input.closest('form')!) + + expect(onNavigate).toHaveBeenNthCalledWith(1, 'http://localhost:3000') + expect(onNavigate).toHaveBeenNthCalledWith(2, 'https://example.com') + }) + + it('preserves explicit URL schemes', () => { + const onNavigate = vi.fn() + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'about:blank' } }) + fireEvent.submit(input.closest('form')!) + expect(onNavigate).toHaveBeenCalledWith('about:blank') + }) + it('disables back when cannot go back', () => { render() expect(screen.getByLabelText('后退')).toBeDisabled() diff --git a/desktop/src/components/browser/BrowserAddressBar.tsx b/desktop/src/components/browser/BrowserAddressBar.tsx index 05b34095..4b9676e1 100644 --- a/desktop/src/components/browser/BrowserAddressBar.tsx +++ b/desktop/src/components/browser/BrowserAddressBar.tsx @@ -23,11 +23,12 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals -
{ e.preventDefault(); onNavigate(draft.trim()) }}> + { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}> setDraft(e.target.value)} + placeholder="输入网址..." spellCheck={false} />
@@ -42,3 +43,13 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals ) } + +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 (/^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)(?::\d+)?(?:[/?#].*)?$/i.test(value)) { + return `http://${value}` + } + return `https://${value}` +} diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index c92e0175..62426573 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -1,9 +1,12 @@ import '@testing-library/jest-dom' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, 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 + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: class { observe() {} unobserve() {} disconnect() {} }, + }) }) const { bridge } = vi.hoisted(() => ({ @@ -18,6 +21,7 @@ import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useOverlayStore } from '../../stores/overlayStore' afterEach(() => { + cleanup() Object.values(bridge).forEach((f) => f.mockReset()) useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) // browserPanelStore.open() now also opens the unified workbench; keep it isolated. @@ -32,6 +36,23 @@ describe('BrowserSurface', () => { expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) })) }) + it('renders an empty address bar without opening a preview for a blank session', () => { + useBrowserPanelStore.getState().ensureBlank('s1') + render() + expect(screen.getByRole('textbox')).toHaveValue('') + expect(bridge.open).not.toHaveBeenCalled() + }) + + it('first navigation from a blank session opens the native preview', () => { + useBrowserPanelStore.getState().ensureBlank('s1') + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'localhost:3000' } }) + fireEvent.submit(input.closest('form')!) + expect(bridge.open).toHaveBeenCalledWith('http://localhost:3000', expect.objectContaining({ width: expect.any(Number) })) + expect(bridge.navigate).not.toHaveBeenCalled() + }) + it('navigating via address bar calls store + bridge', () => { useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') render() diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index 71473801..73221b56 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -21,7 +21,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { useLayoutEffect(() => { const el = hostRef.current - if (!el || !session) return + if (!el || !session?.url) return previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect())) // The visibility-sync effect below owns setVisible() — including the // initial reveal — so it always factors in overlayCount. @@ -70,6 +70,22 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { if (!session) return null + const openOrNavigate = (url: string) => { + if (!url) return + const current = useBrowserPanelStore.getState().bySession[sessionId] + store.navigate(sessionId, url) + if (current?.url) { + previewBridge.navigate(url) + return + } + const el = hostRef.current + if (el) { + previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect())) + } else { + previewBridge.navigate(url) + } + } + return (
{ store.navigate(sessionId, url); previewBridge.navigate(url) }} + 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) }} - onReload={() => { store.setLoading(sessionId, true); previewBridge.navigate(session.url) }} + onReload={() => { + if (!session.url) return + store.setLoading(sessionId, true) + previewBridge.navigate(session.url) + }} />
{previewable && (
{isTauri && ( diff --git a/desktop/src/components/workbench/WorkbenchPanel.test.tsx b/desktop/src/components/workbench/WorkbenchPanel.test.tsx index d61f02ba..a3695da3 100644 --- a/desktop/src/components/workbench/WorkbenchPanel.test.tsx +++ b/desktop/src/components/workbench/WorkbenchPanel.test.tsx @@ -19,11 +19,13 @@ vi.mock('../browser/BrowserSurface', () => ({ import { WorkbenchPanel } from './WorkbenchPanel' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useSettingsStore } from '../../stores/settingsStore' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' const SESSION_ID = 'workbench-session' beforeEach(() => { useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) useSettingsStore.setState({ locale: 'en' }) useWorkspacePanelStore.getState().openPanel(SESSION_ID) }) @@ -31,6 +33,7 @@ beforeEach(() => { afterEach(() => { cleanup() useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) }) describe('WorkbenchPanel', () => { @@ -66,6 +69,13 @@ describe('WorkbenchPanel', () => { fireEvent.click(screen.getByRole('tab', { name: 'Browser' })) expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('browser') + expect(useBrowserPanelStore.getState().bySession[SESSION_ID]).toMatchObject({ + isOpen: true, + url: '', + history: [], + historyIndex: -1, + loading: false, + }) }) it('switching to the files tab calls setMode("workspace")', () => { diff --git a/desktop/src/components/workbench/WorkbenchPanel.tsx b/desktop/src/components/workbench/WorkbenchPanel.tsx index ef6b6d4a..c348ed86 100644 --- a/desktop/src/components/workbench/WorkbenchPanel.tsx +++ b/desktop/src/components/workbench/WorkbenchPanel.tsx @@ -4,6 +4,7 @@ import { useWorkspacePanelStore, type WorkbenchMode, } from '../../stores/workspacePanelStore' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' import { WorkspacePanel } from '../workspace/WorkspacePanel' import { BrowserSurface } from '../browser/BrowserSurface' @@ -30,6 +31,14 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) { const mode = useWorkspacePanelStore((state) => state.getMode(sessionId)) const setMode = useWorkspacePanelStore((state) => state.setMode) const closePanel = useWorkspacePanelStore((state) => state.closePanel) + const ensureBlankBrowser = useBrowserPanelStore((state) => state.ensureBlank) + + const handleModeSelect = (nextMode: WorkbenchMode) => { + if (nextMode === 'browser') { + ensureBlankBrowser(sessionId) + } + setMode(sessionId, nextMode) + } return (
@@ -47,7 +56,7 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) { type="button" role="tab" aria-selected={isActive} - onClick={() => setMode(sessionId, itemMode)} + onClick={() => handleModeSelect(itemMode)} className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${ isActive ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]' diff --git a/desktop/src/stores/browserPanelStore.test.ts b/desktop/src/stores/browserPanelStore.test.ts index f82ea943..5354a8b2 100644 --- a/desktop/src/stores/browserPanelStore.test.ts +++ b/desktop/src/stores/browserPanelStore.test.ts @@ -20,6 +20,16 @@ describe('browserPanelStore', () => { expect(s.canGoBack).toBe(false) }) + it('creates a blank browser session without navigating', () => { + useBrowserPanelStore.getState().ensureBlank('s1') + const s = useBrowserPanelStore.getState().bySession['s1']! + expect(s.url).toBe('') + expect(s.isOpen).toBe(true) + expect(s.history).toEqual([]) + expect(s.historyIndex).toBe(-1) + expect(s.loading).toBe(false) + }) + it('navigate pushes history and truncates forward entries', () => { const st = useBrowserPanelStore.getState() st.open('s1', 'http://localhost/a') @@ -67,6 +77,17 @@ describe('browserPanelStore', () => { expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) }) + it('navigate from a blank session records the first URL as the first history entry', () => { + const st = useBrowserPanelStore.getState() + st.ensureBlank('s1') + st.navigate('s1', 'http://localhost/a') + const s = useBrowserPanelStore.getState().bySession['s1']! + expect(s.url).toBe('http://localhost/a') + expect(s.history).toEqual(['http://localhost/a']) + expect(s.historyIndex).toBe(0) + expect(s.canGoBack).toBe(false) + }) + it('setNavigated clears loading and updates url/title without growing history', () => { const st = useBrowserPanelStore.getState() st.open('s1', 'http://x/a') diff --git a/desktop/src/stores/browserPanelStore.ts b/desktop/src/stores/browserPanelStore.ts index f075604e..55a9e014 100644 --- a/desktop/src/stores/browserPanelStore.ts +++ b/desktop/src/stores/browserPanelStore.ts @@ -16,6 +16,7 @@ export type BrowserSessionState = { type BrowserPanelState = { bySession: Record open: (sessionId: string, url: string) => void + ensureBlank: (sessionId: string) => void navigate: (sessionId: string, url: string) => void goBack: (sessionId: string) => void goForward: (sessionId: string) => void @@ -26,14 +27,21 @@ type BrowserPanelState = { setReady: (sessionId: string) => void } -const empty = (url: string): BrowserSessionState => ({ - isOpen: true, url, title: '', history: [url], historyIndex: 0, - loading: false, pickerActive: false, canGoBack: false, canGoForward: false, +const empty = (url = ''): BrowserSessionState => ({ + isOpen: true, + url, + title: '', + history: url ? [url] : [], + historyIndex: url ? 0 : -1, + loading: false, + pickerActive: false, + canGoBack: false, + canGoForward: false, }) const withNav = (s: BrowserSessionState): BrowserSessionState => ({ ...s, - url: s.history[s.historyIndex]!, + url: s.history[s.historyIndex] ?? s.url, canGoBack: s.historyIndex > 0, canGoForward: s.historyIndex < s.history.length - 1, }) @@ -51,9 +59,16 @@ export const useBrowserPanelStore = create((set) => ({ workspacePanel.openPanel(sessionId) workspacePanel.setMode(sessionId, 'browser') }, + ensureBlank: (sessionId) => set((st) => { + const cur = st.bySession[sessionId] + if (cur) { + return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: true } } } + } + return { bySession: { ...st.bySession, [sessionId]: empty() } } + }), navigate: (sessionId, url) => set((st) => { const cur = st.bySession[sessionId] ?? empty(url) - const history = [...cur.history.slice(0, cur.historyIndex + 1), url] + const history = [...cur.history.slice(0, Math.max(0, cur.historyIndex + 1)), url] return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, isOpen: true, loading: true, history, historyIndex: history.length - 1 }) } } }), goBack: (sessionId) => set((st) => {