mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): simplify workbench open affordances
Previewable changed-file rows no longer show a redundant workspace chevron when the row already has an Open With control. The unified Workbench now owns the Browser entry point, and opening Browser mode creates a blank address bar instead of navigating to a hard-coded localhost port. Constraint: Keep explicit preview links and localhost output cards able to open the browser directly with their target URL. Rejected: Keep the top toolbar browser button | it duplicated the Workbench Browser tab and silently opened localhost:5173. Rejected: Use localhost:5173 as the blank browser default | it makes arbitrary Browser mode look tied to a dev server. Confidence: high Scope-risk: moderate Tested: cd desktop && /Users/nanmi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/typescript/bin/tsc --noEmit Tested: cd desktop && /Users/nanmi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/vitest/vitest.mjs run src/components/chat/CurrentTurnChangeCard.test.tsx src/stores/browserPanelStore.test.ts src/components/browser/BrowserAddressBar.test.tsx src/components/workbench/WorkbenchPanel.test.tsx src/components/browser/BrowserSurface.test.tsx src/components/layout/TabBar.test.tsx --pool=forks --poolOptions.forks.singleFork=true Tested: git diff --check -- desktop/src/components/chat/CurrentTurnChangeCard.tsx desktop/src/components/chat/CurrentTurnChangeCard.test.tsx desktop/src/components/browser/BrowserAddressBar.tsx desktop/src/components/browser/BrowserAddressBar.test.tsx desktop/src/components/browser/BrowserSurface.tsx desktop/src/components/browser/BrowserSurface.test.tsx desktop/src/components/layout/TabBar.tsx desktop/src/components/layout/TabBar.test.tsx desktop/src/components/workbench/WorkbenchPanel.tsx desktop/src/components/workbench/WorkbenchPanel.test.tsx desktop/src/stores/browserPanelStore.ts desktop/src/stores/browserPanelStore.test.ts Not-tested: Full desktop check:desktop gate.
This commit is contained in:
parent
fac8ffb0f2
commit
8e5e86353a
@ -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(<BrowserAddressBar {...baseProps} />)
|
||||
@ -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(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
|
||||
fireEvent.submit(screen.getByRole('textbox').closest('form')!)
|
||||
expect(onNavigate).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('normalizes localhost and bare domains before navigating', () => {
|
||||
const onNavigate = vi.fn()
|
||||
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
|
||||
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(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
|
||||
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(<BrowserAddressBar {...baseProps} canGoBack={false} />)
|
||||
expect(screen.getByLabelText('后退')).toBeDisabled()
|
||||
|
||||
@ -23,11 +23,12 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
|
||||
<button aria-label="刷新" aria-busy={loading} onClick={onReload} className="p-1">
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||
</button>
|
||||
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(draft.trim()) }}>
|
||||
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
|
||||
<input
|
||||
className="w-full rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="输入网址..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
@ -42,3 +43,13 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
@ -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(<BrowserSurface sessionId="s1" />)
|
||||
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(<BrowserSurface sessionId="s1" />)
|
||||
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(<BrowserSurface sessionId="s1" />)
|
||||
|
||||
@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<BrowserAddressBar
|
||||
@ -77,10 +93,14 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
canGoBack={session.canGoBack}
|
||||
canGoForward={session.canGoForward}
|
||||
loading={session.loading}
|
||||
onNavigate={(url) => { 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)
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1 border-b px-2 py-1">
|
||||
<button
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
@ -126,6 +126,10 @@ function renderCard(filesChanged: string[]) {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -222,6 +226,13 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('hides the workspace chevron on rows that already show an open-with button', () => {
|
||||
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
|
||||
expect(screen.getAllByText('chevron_right')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clicking README.md open-with opens menu with workspace preview item', async () => {
|
||||
renderCard(['/w/proj/README.md'])
|
||||
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
|
||||
|
||||
@ -157,7 +157,9 @@ export function CurrentTurnChangeCard({
|
||||
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
|
||||
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
|
||||
</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
|
||||
{!previewable && (
|
||||
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
|
||||
)}
|
||||
</button>
|
||||
{previewable && (
|
||||
<button
|
||||
|
||||
@ -784,12 +784,10 @@ describe('TabBar', () => {
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the unified workbench in browser mode for the active session from the toolbar', async () => {
|
||||
it('does not render a browser toolbar button for session tabs', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useBrowserPanelStore } = await import('../../stores/browserPanelStore')
|
||||
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
@ -806,16 +804,8 @@ describe('TabBar', () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
// Opening the browser surfaces the shared workbench in browser mode.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Browser' }))
|
||||
expect(useBrowserPanelStore.getState().bySession['tab-1']?.isOpen).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().getMode('tab-1')).toBe('browser')
|
||||
|
||||
// Toggling it off closes the unified panel (browser state is retained in its store).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide Browser' }))
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Show Browser' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Show Browser' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Hide Browser' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the browser toolbar button for non-session tabs', async () => {
|
||||
|
||||
@ -11,11 +11,10 @@ import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WindowControls, showWindowControls } from './WindowControls'
|
||||
import { OpenProjectMenu } from './OpenProjectMenu'
|
||||
import { Folder, FolderOpen, Globe, SquareTerminal } from 'lucide-react'
|
||||
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
|
||||
import { ActionDialog } from '../shared/ActionDialog'
|
||||
|
||||
const TAB_WIDTH = 180
|
||||
@ -73,7 +72,6 @@ export function TabBar() {
|
||||
activeTabId && isActiveSessionTab ? state.getMode(activeTabId) : 'workspace',
|
||||
)
|
||||
const isWorkspacePanelOpen = isWorkbenchOpen && workbenchMode === 'workspace'
|
||||
const isBrowserPanelOpen = isWorkbenchOpen && workbenchMode === 'browser'
|
||||
const isTerminalPanelOpen = useTerminalPanelStore((state) =>
|
||||
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
||||
)
|
||||
@ -406,30 +404,6 @@ export function TabBar() {
|
||||
active={isWorkspacePanelOpen}
|
||||
/>
|
||||
)}
|
||||
{isActiveSessionTab && activeTabId && (
|
||||
<ToolbarIconButton
|
||||
icon={<Globe size={17} strokeWidth={1.9} />}
|
||||
label={t(isBrowserPanelOpen ? 'tabs.hideBrowser' : 'tabs.showBrowser')}
|
||||
onClick={() => {
|
||||
const workbench = useWorkspacePanelStore.getState()
|
||||
if (workbench.isPanelOpen(activeTabId) && workbench.getMode(activeTabId) === 'browser') {
|
||||
workbench.closePanel(activeTabId)
|
||||
return
|
||||
}
|
||||
const browser = useBrowserPanelStore.getState()
|
||||
const existing = browser.bySession[activeTabId]
|
||||
if (existing) {
|
||||
// Re-surface the workbench in browser mode at the last url
|
||||
// without resetting history. (browser.open resets history.)
|
||||
workbench.setMode(activeTabId, 'browser')
|
||||
workbench.openPanel(activeTabId)
|
||||
} else {
|
||||
browser.open(activeTabId, 'http://localhost:5173/')
|
||||
}
|
||||
}}
|
||||
active={isBrowserPanelOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTauri && (
|
||||
|
||||
@ -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")', () => {
|
||||
|
||||
@ -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 (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
|
||||
@ -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)]'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -16,6 +16,7 @@ export type BrowserSessionState = {
|
||||
type BrowserPanelState = {
|
||||
bySession: Record<string, BrowserSessionState>
|
||||
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<BrowserPanelState>((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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user