mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(desktop): unify right panel into Workbench with file/browser mode switch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41d1cb5c00
commit
1d9f4c9cb7
@ -14,10 +14,13 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}
|
||||
|
||||
import { BrowserSurface } from './BrowserSurface'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
|
||||
afterEach(() => {
|
||||
Object.values(bridge).forEach((f) => f.mockReset())
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('BrowserSurface', () => {
|
||||
|
||||
@ -784,11 +784,12 @@ describe('TabBar', () => {
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the browser panel for the active session from the toolbar', async () => {
|
||||
it('toggles the unified workbench in browser mode for the active session from the toolbar', 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: [
|
||||
@ -805,11 +806,16 @@ 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(useBrowserPanelStore.getState().bySession['tab-1']?.isOpen).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Show Browser' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the browser toolbar button for non-session tabs', async () => {
|
||||
|
||||
@ -62,15 +62,20 @@ export function TabBar() {
|
||||
const openProjectPath = isActiveSessionTab && activeSession?.workDirExists !== false
|
||||
? activeSession?.workDir ?? null
|
||||
: null
|
||||
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
|
||||
// The right-side panel is now a single unified "workbench" with a per-session
|
||||
// mode (file ↔ browser). The folder/browser toolbar buttons reflect whether
|
||||
// the panel is open in their respective mode.
|
||||
const isWorkbenchOpen = useWorkspacePanelStore((state) =>
|
||||
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
||||
)
|
||||
const workbenchMode = useWorkspacePanelStore((state) =>
|
||||
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,
|
||||
)
|
||||
const isBrowserPanelOpen = useBrowserPanelStore((state) =>
|
||||
activeTabId && isActiveSessionTab ? Boolean(state.bySession[activeTabId]?.isOpen) : false,
|
||||
)
|
||||
|
||||
const moveTab = useTabStore((s) => s.moveTab)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@ -388,7 +393,15 @@ export function TabBar() {
|
||||
<ToolbarIconButton
|
||||
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
|
||||
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
|
||||
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
|
||||
onClick={() => {
|
||||
const workbench = useWorkspacePanelStore.getState()
|
||||
if (workbench.isPanelOpen(activeTabId) && workbench.getMode(activeTabId) === 'workspace') {
|
||||
workbench.closePanel(activeTabId)
|
||||
} else {
|
||||
workbench.setMode(activeTabId, 'workspace')
|
||||
workbench.openPanel(activeTabId)
|
||||
}
|
||||
}}
|
||||
active={isWorkspacePanelOpen}
|
||||
/>
|
||||
)}
|
||||
@ -397,9 +410,18 @@ export function TabBar() {
|
||||
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()
|
||||
if (browser.bySession[activeTabId]?.isOpen) {
|
||||
browser.close(activeTabId)
|
||||
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/')
|
||||
}
|
||||
|
||||
88
desktop/src/components/workbench/WorkbenchPanel.test.tsx
Normal file
88
desktop/src/components/workbench/WorkbenchPanel.test.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../workspace/WorkspacePanel', () => ({
|
||||
WorkspacePanel: ({ sessionId, embedded }: { sessionId: string; embedded?: boolean }) => (
|
||||
<div data-testid="workspace-panel" data-embedded={embedded ? 'true' : 'false'}>
|
||||
workspace:{sessionId}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../browser/BrowserSurface', () => ({
|
||||
BrowserSurface: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="browser-surface">browser:{sessionId}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { WorkbenchPanel } from './WorkbenchPanel'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
const SESSION_ID = 'workbench-session'
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useWorkspacePanelStore.getState().openPanel(SESSION_ID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('WorkbenchPanel', () => {
|
||||
it('renders the file workspace (embedded) in the default workspace mode', () => {
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
const workspace = screen.getByTestId('workspace-panel')
|
||||
expect(workspace).toHaveTextContent(`workspace:${SESSION_ID}`)
|
||||
expect(workspace).toHaveAttribute('data-embedded', 'true')
|
||||
expect(screen.queryByTestId('browser-surface')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the native BrowserSurface in browser mode', () => {
|
||||
useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser')
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
expect(screen.getByTestId('browser-surface')).toHaveTextContent(`browser:${SESSION_ID}`)
|
||||
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reflects the active mode on the segmented control tabs', () => {
|
||||
useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser')
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('switching to the browser tab calls setMode("browser")', () => {
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace')
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Browser' }))
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('browser')
|
||||
})
|
||||
|
||||
it('switching to the files tab calls setMode("workspace")', () => {
|
||||
useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser')
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Files' }))
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace')
|
||||
})
|
||||
|
||||
it('the close button closes the unified panel', () => {
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
83
desktop/src/components/workbench/WorkbenchPanel.tsx
Normal file
83
desktop/src/components/workbench/WorkbenchPanel.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { FolderOpen, Globe, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
useWorkspacePanelStore,
|
||||
type WorkbenchMode,
|
||||
} from '../../stores/workspacePanelStore'
|
||||
import { WorkspacePanel } from '../workspace/WorkspacePanel'
|
||||
import { BrowserSurface } from '../browser/BrowserSurface'
|
||||
|
||||
type WorkbenchPanelProps = {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const MODE_ITEMS: ReadonlyArray<{
|
||||
mode: WorkbenchMode
|
||||
labelKey: 'workbench.modeWorkspace' | 'workbench.modeBrowser'
|
||||
Icon: typeof FolderOpen
|
||||
}> = [
|
||||
{ mode: 'workspace', labelKey: 'workbench.modeWorkspace', Icon: FolderOpen },
|
||||
{ mode: 'browser', labelKey: 'workbench.modeBrowser', Icon: Globe },
|
||||
]
|
||||
|
||||
/**
|
||||
* Unified right-side "Workbench" panel. Hosts the file workspace and the native
|
||||
* browser surface behind a single per-session mode switch (file ↔ browser),
|
||||
* sharing the panel's open state and width via {@link useWorkspacePanelStore}.
|
||||
*/
|
||||
export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
|
||||
const t = useTranslation()
|
||||
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
|
||||
const setMode = useWorkspacePanelStore((state) => state.setMode)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('workbench.modeSwitch')}
|
||||
className="inline-flex items-center gap-0.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5"
|
||||
>
|
||||
{MODE_ITEMS.map(({ mode: itemMode, labelKey, Icon }) => {
|
||||
const isActive = mode === itemMode
|
||||
return (
|
||||
<button
|
||||
key={itemMode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => setMode(sessionId, 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)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workbench.close')}
|
||||
onClick={() => closePanel(sessionId)}
|
||||
className="ml-auto inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<X size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{mode === 'browser' ? (
|
||||
<BrowserSurface sessionId={sessionId} />
|
||||
) : (
|
||||
<WorkspacePanel sessionId={sessionId} embedded />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
// Integration test: proves the native child webview is torn down (hidden) when
|
||||
// the unified workbench switches from browser mode to file mode. Uses the REAL
|
||||
// BrowserSurface so the unmount-cleanup path that hides the webview is exercised.
|
||||
import '@testing-library/jest-dom'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
})
|
||||
|
||||
const { bridge } = vi.hoisted(() => ({
|
||||
bridge: {
|
||||
open: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
setVisible: vi.fn(),
|
||||
close: vi.fn(),
|
||||
eval: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
|
||||
|
||||
// Keep the file workspace a lightweight stub — this test is about the webview.
|
||||
vi.mock('../workspace/WorkspacePanel', () => ({
|
||||
WorkspacePanel: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="workspace-panel">workspace:{sessionId}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { WorkbenchPanel } from './WorkbenchPanel'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
const SESSION_ID = 'webview-session'
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
// open() opens the workbench in browser mode (and records the url for BrowserSurface).
|
||||
useBrowserPanelStore.getState().open(SESSION_ID, 'http://localhost:5173/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Object.values(bridge).forEach((f) => f.mockReset())
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('WorkbenchPanel native webview lifecycle', () => {
|
||||
it('shows the webview in browser mode and hides it when switching to file mode', () => {
|
||||
render(<WorkbenchPanel sessionId={SESSION_ID} />)
|
||||
|
||||
// Browser mode mounts BrowserSurface, which opens + shows the native webview.
|
||||
expect(screen.getByTestId('preview-host')).toBeInTheDocument()
|
||||
expect(bridge.open).toHaveBeenCalledWith(
|
||||
'http://localhost:5173/',
|
||||
expect.objectContaining({ width: expect.any(Number) }),
|
||||
)
|
||||
expect(bridge.setVisible).toHaveBeenCalledWith(true)
|
||||
|
||||
bridge.setVisible.mockClear()
|
||||
|
||||
// Switch to the file tab: BrowserSurface unmounts, hiding the webview overlay.
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Files' }))
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('preview-host')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workspace-panel')).toBeInTheDocument()
|
||||
expect(bridge.setVisible).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -32,6 +32,12 @@ import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||
|
||||
type WorkspacePanelProps = {
|
||||
sessionId: string
|
||||
/**
|
||||
* When hosted inside the unified WorkbenchPanel, the close action lives in the
|
||||
* shared workbench mode strip. Set this to drop WorkspacePanel's own close
|
||||
* button so the panel header doesn't render a duplicate close control.
|
||||
*/
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
type TreeNodeProps = {
|
||||
@ -906,7 +912,7 @@ function TreeNode({
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelProps) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
@ -1386,8 +1392,12 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
return (
|
||||
<aside
|
||||
data-testid="workspace-panel"
|
||||
className="flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full min-w-0 bg-[var(--color-surface)]'
|
||||
: 'flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}
|
||||
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
|
||||
>
|
||||
{hasPreviewTabs && (
|
||||
<div className="flex min-w-0 flex-1 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
@ -1450,11 +1460,13 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
label={t('workspace.refresh')}
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
<ToolbarIconButton
|
||||
icon="close"
|
||||
label={t('workspace.closePanel')}
|
||||
onClick={() => closePanel(sessionId)}
|
||||
/>
|
||||
{!embedded && (
|
||||
<ToolbarIconButton
|
||||
icon="close"
|
||||
label={t('workspace.closePanel')}
|
||||
onClick={() => closePanel(sessionId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -124,6 +124,10 @@ export const en = {
|
||||
'workspace.refresh': 'Refresh workspace',
|
||||
'workspace.closePanel': 'Close workspace panel',
|
||||
'workspace.resizePanel': 'Resize workspace panel',
|
||||
'workbench.modeSwitch': 'Workbench mode',
|
||||
'workbench.modeWorkspace': 'Files',
|
||||
'workbench.modeBrowser': 'Browser',
|
||||
'workbench.close': 'Close',
|
||||
'workspace.closeTab': 'Close tab',
|
||||
'workspace.preview': 'Preview',
|
||||
'workspace.previewEmpty': 'Select a file to preview.',
|
||||
|
||||
@ -126,6 +126,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.refresh': '刷新工作区',
|
||||
'workspace.closePanel': '关闭工作区面板',
|
||||
'workspace.resizePanel': '调整工作区宽度',
|
||||
'workbench.modeSwitch': '工作台模式',
|
||||
'workbench.modeWorkspace': '文件',
|
||||
'workbench.modeBrowser': '浏览器',
|
||||
'workbench.close': '关闭',
|
||||
'workspace.closeTab': '关闭标签',
|
||||
'workspace.preview': '预览',
|
||||
'workspace.previewEmpty': '选择一个文件进行预览。',
|
||||
|
||||
@ -31,8 +31,8 @@ vi.mock('../components/chat/SessionTaskBar', () => ({
|
||||
SessionTaskBar: () => <div data-testid="session-task-bar" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/workspace/WorkspacePanel', () => ({
|
||||
WorkspacePanel: ({ sessionId }: { sessionId: string }) => (
|
||||
vi.mock('../components/workbench/WorkbenchPanel', () => ({
|
||||
WorkbenchPanel: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="workspace-panel">workspace:{sessionId}</div>
|
||||
),
|
||||
}))
|
||||
@ -613,16 +613,18 @@ describe('ActiveSession task polling', () => {
|
||||
const chatColumn = screen.getByTestId('active-session-chat-column')
|
||||
const resizeHandle = screen.getByTestId('workspace-resize-handle')
|
||||
|
||||
const workbenchPanel = screen.getByTestId('workbench-panel')
|
||||
|
||||
expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument()
|
||||
expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
|
||||
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
|
||||
expect(within(workbenchPanel).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
|
||||
expect(chatColumn).toHaveClass('flex-1')
|
||||
expect(chatColumn).not.toHaveClass('shrink-0')
|
||||
expect(contentRow.children[0]).toBe(chatColumn)
|
||||
expect(contentRow.children[1]).toBe(resizeHandle)
|
||||
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))
|
||||
expect(contentRow.children[2]).toBe(workbenchPanel)
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' })
|
||||
|
||||
@ -12,7 +12,6 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
|
||||
import { useBrowserPanelStore } from '../stores/browserPanelStore'
|
||||
import {
|
||||
TERMINAL_PANEL_DEFAULT_HEIGHT,
|
||||
TERMINAL_PANEL_MAX_HEIGHT,
|
||||
@ -24,8 +23,7 @@ import { MessageList } from '../components/chat/MessageList'
|
||||
import { ChatInput } from '../components/chat/ChatInput'
|
||||
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||
import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
|
||||
import { BrowserSurface } from '../components/browser/BrowserSurface'
|
||||
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
|
||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
import { TerminalSettings } from './TerminalSettings'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
@ -278,17 +276,12 @@ export function ActiveSession() {
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const activeTeam = useTeamStore((s) => s.activeTeam)
|
||||
const isMemberSession = !!memberInfo
|
||||
const showWorkspacePanel = useWorkspacePanelStore((state) =>
|
||||
const showWorkbench = useWorkspacePanelStore((state) =>
|
||||
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
|
||||
? state.isPanelOpen(activeTabId)
|
||||
: false,
|
||||
)
|
||||
const showBrowserPanel = useBrowserPanelStore((state) =>
|
||||
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
|
||||
? Boolean(state.bySession[activeTabId]?.isOpen)
|
||||
: false,
|
||||
)
|
||||
const showRightPanel = showWorkspacePanel || showBrowserPanel
|
||||
const showRightPanel = showWorkbench
|
||||
const rightPanelWidth = useWorkspacePanelStore((state) => state.width)
|
||||
const showTerminalPanel = useTerminalPanelStore((state) =>
|
||||
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
|
||||
@ -543,22 +536,17 @@ export function ActiveSession() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showBrowserPanel ? (
|
||||
{showWorkbench ? (
|
||||
<>
|
||||
<WorkspaceResizeHandle />
|
||||
<aside
|
||||
data-testid="browser-panel"
|
||||
data-testid="workbench-panel"
|
||||
className="flex h-full shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
style={{ width: rightPanelWidth, maxWidth: '62%', minWidth: 'min(420px, 54%)' }}
|
||||
>
|
||||
<BrowserSurface sessionId={activeTabId} />
|
||||
<WorkbenchPanel sessionId={activeTabId} />
|
||||
</aside>
|
||||
</>
|
||||
) : showWorkspacePanel ? (
|
||||
<>
|
||||
<WorkspaceResizeHandle />
|
||||
<WorkspacePanel sessionId={activeTabId} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useBrowserPanelStore } from './browserPanelStore'
|
||||
import { useWorkspacePanelStore } from './workspacePanelStore'
|
||||
|
||||
const reset = () => useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
const reset = () => {
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
}
|
||||
|
||||
describe('browserPanelStore', () => {
|
||||
beforeEach(reset)
|
||||
@ -82,4 +86,16 @@ describe('browserPanelStore', () => {
|
||||
st.setReady('s1')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('open surfaces the unified workbench in browser mode', () => {
|
||||
// Panel starts closed and defaults to the workspace (file) mode.
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('s1')).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().getMode('s1')).toBe('workspace')
|
||||
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
|
||||
// Opening a browser target opens the shared workbench in browser mode.
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('s1')).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().getMode('s1')).toBe('browser')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { useWorkspacePanelStore } from './workspacePanelStore'
|
||||
|
||||
export type BrowserSessionState = {
|
||||
isOpen: boolean
|
||||
@ -39,9 +40,17 @@ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
|
||||
|
||||
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
|
||||
bySession: {},
|
||||
open: (sessionId, url) => set((st) => ({
|
||||
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
|
||||
})),
|
||||
open: (sessionId, url) => {
|
||||
set((st) => ({
|
||||
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
|
||||
}))
|
||||
// The browser is one mode of the unified workbench: opening a previewable
|
||||
// link / localhost url surfaces the workbench in BROWSER mode. Import is
|
||||
// one-directional (workspacePanelStore never imports this store), so no cycle.
|
||||
const workspacePanel = useWorkspacePanelStore.getState()
|
||||
workspacePanel.openPanel(sessionId)
|
||||
workspacePanel.setMode(sessionId, 'browser')
|
||||
},
|
||||
navigate: (sessionId, url) => set((st) => {
|
||||
const cur = st.bySession[sessionId] ?? empty(url)
|
||||
const history = [...cur.history.slice(0, cur.historyIndex + 1), url]
|
||||
|
||||
@ -314,6 +314,34 @@ describe('workspacePanelStore', () => {
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults the workbench mode to "workspace"', () => {
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-no-mode')).toBe('workspace')
|
||||
})
|
||||
|
||||
it('setMode stores the workbench mode per session', () => {
|
||||
const store = useWorkspacePanelStore.getState()
|
||||
store.setMode('session-a', 'browser')
|
||||
store.setMode('session-b', 'workspace')
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-a')).toBe('browser')
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-b')).toBe('workspace')
|
||||
// Unrelated sessions still fall back to the default.
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-c')).toBe('workspace')
|
||||
})
|
||||
|
||||
it('openPreview opens the panel and forces "workspace" mode', async () => {
|
||||
mocks.getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok', path: 'src/a.ts', content: 'export const a = 1', language: 'typescript', size: 18,
|
||||
})
|
||||
// Start in browser mode (panel could already be showing the browser).
|
||||
useWorkspacePanelStore.getState().setMode('session-preview-mode', 'browser')
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-preview-mode')).toBe('browser')
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-preview-mode', 'src/a.ts', 'file')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-preview-mode')).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().getMode('session-preview-mode')).toBe('workspace')
|
||||
})
|
||||
|
||||
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
|
||||
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
|
||||
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
|
||||
@ -600,6 +628,10 @@ describe('workspacePanelStore', () => {
|
||||
'session-clear': { isOpen: true, activeView: 'all' },
|
||||
'session-reset': { isOpen: true, activeView: 'all' },
|
||||
},
|
||||
modeBySession: {
|
||||
'session-clear': 'browser',
|
||||
'session-reset': 'browser',
|
||||
},
|
||||
statusBySession: {
|
||||
'session-clear': {
|
||||
state: 'ok',
|
||||
@ -678,6 +710,8 @@ describe('workspacePanelStore', () => {
|
||||
const state = useWorkspacePanelStore.getState()
|
||||
expect(state.panelBySession['session-clear']).toBeUndefined()
|
||||
expect(state.panelBySession['session-reset']).toBeUndefined()
|
||||
expect(state.modeBySession['session-clear']).toBeUndefined()
|
||||
expect(state.modeBySession['session-reset']).toBeUndefined()
|
||||
expect(state.statusBySession['session-clear']).toBeUndefined()
|
||||
expect(state.statusBySession['session-reset']).toBeUndefined()
|
||||
expect(state.expandedPathsBySession['session-clear']).toBeUndefined()
|
||||
|
||||
@ -12,6 +12,7 @@ export const WORKSPACE_PANEL_MIN_WIDTH = 640
|
||||
export const WORKSPACE_PANEL_MAX_WIDTH = 1120
|
||||
|
||||
export type WorkspacePanelView = 'changed' | 'all'
|
||||
export type WorkbenchMode = 'workspace' | 'browser'
|
||||
export type WorkspacePreviewKind = 'file' | 'diff'
|
||||
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
|
||||
export type WorkspacePreviewState =
|
||||
@ -55,6 +56,7 @@ type WorkspacePanelErrorState = {
|
||||
|
||||
type WorkspacePanelStore = {
|
||||
panelBySession: Record<string, WorkspacePanelSessionState | undefined>
|
||||
modeBySession: Record<string, WorkbenchMode | undefined>
|
||||
width: number
|
||||
statusBySession: Record<string, WorkspaceStatusResult | undefined>
|
||||
expandedPathsBySession: Record<string, string[] | undefined>
|
||||
@ -66,6 +68,8 @@ type WorkspacePanelStore = {
|
||||
|
||||
isPanelOpen: (sessionId: string) => boolean
|
||||
getActiveView: (sessionId: string) => WorkspacePanelView
|
||||
getMode: (sessionId: string) => WorkbenchMode
|
||||
setMode: (sessionId: string, mode: WorkbenchMode) => void
|
||||
openPanel: (sessionId: string) => void
|
||||
closePanel: (sessionId: string) => void
|
||||
togglePanel: (sessionId: string) => void
|
||||
@ -86,6 +90,8 @@ const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = {
|
||||
activeView: 'changed',
|
||||
}
|
||||
|
||||
const DEFAULT_WORKBENCH_MODE: WorkbenchMode = 'workspace'
|
||||
|
||||
const statusRequestIds = new Map<string, number>()
|
||||
const treeRequestIds = new Map<string, number>()
|
||||
const previewRequestIds = new Map<string, number>()
|
||||
@ -182,6 +188,7 @@ function upsertPreviewTab(
|
||||
|
||||
export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) => ({
|
||||
panelBySession: {},
|
||||
modeBySession: {},
|
||||
width: WORKSPACE_PANEL_DEFAULT_WIDTH,
|
||||
statusBySession: {},
|
||||
expandedPathsBySession: {},
|
||||
@ -201,6 +208,15 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
|
||||
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
|
||||
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
|
||||
getMode: (sessionId) => get().modeBySession[sessionId] ?? DEFAULT_WORKBENCH_MODE,
|
||||
|
||||
setMode: (sessionId, mode) =>
|
||||
set((state) => ({
|
||||
modeBySession: {
|
||||
...state.modeBySession,
|
||||
[sessionId]: mode,
|
||||
},
|
||||
})),
|
||||
|
||||
openPanel: (sessionId) =>
|
||||
set((state) => ({
|
||||
@ -433,8 +449,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
openPreview: async (sessionId, path, kind) => {
|
||||
// Ensure the workspace panel is visible — openPreview is now triggered from places
|
||||
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
|
||||
// not only from inside the already-open file tree.
|
||||
// not only from inside the already-open file tree. Opening a file always switches the
|
||||
// unified workbench into file ("workspace") mode.
|
||||
get().openPanel(sessionId)
|
||||
get().setMode(sessionId, 'workspace')
|
||||
const tabId = getWorkspacePreviewTabId(path, kind)
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
|
||||
@ -695,6 +713,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
|
||||
set((state) => ({
|
||||
panelBySession: removeRecordKey(state.panelBySession, sessionId),
|
||||
modeBySession: removeRecordKey(state.modeBySession, sessionId),
|
||||
statusBySession: removeRecordKey(state.statusBySession, sessionId),
|
||||
expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId),
|
||||
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user