Download desktop/src/components/workspace/WorkspacePanel.test.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 51.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.test.tsx
- Command line
-
hf download hf://chenbhao/codev@e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.test.tsx
-
curl -L -o WorkspacePanel.test.tsx https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.test.tsx
51.5 kB
| // @vitest-environment jsdom | |
| // @ts-expect-error jsdom is installed in this workspace without local type declarations | |
| import { JSDOM } from 'jsdom' | |
| import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' | |
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | |
| if (typeof document === 'undefined') { | |
| const dom = new JSDOM('<!doctype html><html><body></body></html>', { | |
| url: 'http://localhost/', | |
| }) | |
| const { window } = dom | |
| Object.assign(globalThis, { | |
| window, | |
| document: window.document, | |
| navigator: window.navigator, | |
| localStorage: window.localStorage, | |
| HTMLElement: window.HTMLElement, | |
| HTMLButtonElement: window.HTMLButtonElement, | |
| MutationObserver: window.MutationObserver, | |
| Node: window.Node, | |
| Event: window.Event, | |
| MouseEvent: window.MouseEvent, | |
| KeyboardEvent: window.KeyboardEvent, | |
| getComputedStyle: window.getComputedStyle.bind(window), | |
| IS_REACT_ACT_ENVIRONMENT: true, | |
| }) | |
| } | |
| type WorkspaceApiMocks = { | |
| getWorkspaceStatusMock: ReturnType<typeof vi.fn> | |
| getWorkspaceTreeMock: ReturnType<typeof vi.fn> | |
| getWorkspaceFileMock: ReturnType<typeof vi.fn> | |
| getWorkspaceDiffMock: ReturnType<typeof vi.fn> | |
| } | |
| var mocks: WorkspaceApiMocks | undefined | |
| function getMocks() { | |
| if (!mocks) { | |
| throw new Error('Workspace API mocks were not initialized') | |
| } | |
| return mocks | |
| } | |
| function deferred<T>() { | |
| let resolve!: (value: T) => void | |
| let reject!: (reason?: unknown) => void | |
| const promise = new Promise<T>((res, rej) => { | |
| resolve = res | |
| reject = rej | |
| }) | |
| return { promise, resolve, reject } | |
| } | |
| async function setWorkspaceState( | |
| updater: | |
| | ReturnType<typeof useWorkspacePanelStore.getInitialState> | |
| | Parameters<typeof useWorkspacePanelStore.setState>[0], | |
| ) { | |
| await act(() => { | |
| useWorkspacePanelStore.setState(updater as Parameters<typeof useWorkspacePanelStore.setState>[0], true) | |
| }) | |
| } | |
| async function setSettingsState( | |
| updater: | |
| | ReturnType<typeof useSettingsStore.getInitialState> | |
| | Parameters<typeof useSettingsStore.setState>[0], | |
| ) { | |
| await act(() => { | |
| useSettingsStore.setState(updater as Parameters<typeof useSettingsStore.setState>[0], true) | |
| }) | |
| } | |
| async function flushReactWork() { | |
| await act(async () => { | |
| await Promise.resolve() | |
| await Promise.resolve() | |
| }) | |
| } | |
| async function renderPanel(sessionId: string) { | |
| let view!: ReturnType<typeof render> | |
| await act(async () => { | |
| view = render(<WorkspacePanel sessionId={sessionId} />) | |
| await Promise.resolve() | |
| }) | |
| return view | |
| } | |
| async function clickElement(element: Element) { | |
| await act(async () => { | |
| fireEvent.click(element) | |
| await Promise.resolve() | |
| }) | |
| await flushReactWork() | |
| } | |
| function findTextNodeContaining(container: Element, text: string) { | |
| const walker = document.createTreeWalker(container, 4) | |
| let current = walker.nextNode() | |
| while (current) { | |
| if (current.textContent?.includes(text)) return current | |
| current = walker.nextNode() | |
| } | |
| throw new Error(`Unable to find text node containing ${text}`) | |
| } | |
| async function selectWorkspaceCodeText( | |
| view: Awaited<ReturnType<typeof renderPanel>>, | |
| startLine: number, | |
| startText: string, | |
| endLine: number, | |
| endText: string, | |
| ) { | |
| const code = view.getByTestId('workspace-code') | |
| const startRow = code.querySelector(`[data-workspace-line-number="${startLine}"]`) | |
| const endRow = code.querySelector(`[data-workspace-line-number="${endLine}"]`) | |
| if (!startRow || !endRow) throw new Error('Selection rows were not rendered') | |
| Object.assign(code.parentElement?.parentElement ?? code, { | |
| getBoundingClientRect: () => ({ | |
| left: 100, | |
| top: 24, | |
| right: 520, | |
| bottom: 420, | |
| width: 420, | |
| height: 396, | |
| x: 100, | |
| y: 24, | |
| toJSON: () => ({}), | |
| }), | |
| }) | |
| const startNode = findTextNodeContaining(startRow, startText) | |
| const endNode = findTextNodeContaining(endRow, endText) | |
| const startOffset = startNode.textContent?.indexOf(startText) ?? -1 | |
| const endOffset = (endNode.textContent?.indexOf(endText) ?? -1) + endText.length | |
| const range = document.createRange() | |
| range.setStart(startNode, startOffset) | |
| range.setEnd(endNode, endOffset) | |
| Object.assign(range, { | |
| getBoundingClientRect: () => ({ | |
| left: 120, | |
| top: 100, | |
| right: 240, | |
| bottom: 118, | |
| width: 120, | |
| height: 18, | |
| x: 120, | |
| y: 100, | |
| toJSON: () => ({}), | |
| }), | |
| }) | |
| const selection = window.getSelection() | |
| selection?.removeAllRanges() | |
| selection?.addRange(range) | |
| await act(async () => { | |
| fireEvent.mouseUp(code, { clientX: 180, clientY: 122 }) | |
| await Promise.resolve() | |
| }) | |
| await flushReactWork() | |
| } | |
| async function selectRenderedText(container: Element, text: string, target?: Element) { | |
| const textNode = findTextNodeContaining(container, text) | |
| const startOffset = textNode.textContent?.indexOf(text) ?? -1 | |
| const range = document.createRange() | |
| range.setStart(textNode, startOffset) | |
| range.setEnd(textNode, startOffset + text.length) | |
| Object.assign(range, { | |
| getBoundingClientRect: () => ({ | |
| left: 130, | |
| top: 60, | |
| right: 260, | |
| bottom: 78, | |
| width: 130, | |
| height: 18, | |
| x: 130, | |
| y: 60, | |
| toJSON: () => ({}), | |
| }), | |
| }) | |
| Object.assign(target ?? container, { | |
| getBoundingClientRect: () => ({ | |
| left: 100, | |
| top: 24, | |
| right: 520, | |
| bottom: 420, | |
| width: 420, | |
| height: 396, | |
| x: 100, | |
| y: 24, | |
| toJSON: () => ({}), | |
| }), | |
| }) | |
| const selection = window.getSelection() | |
| selection?.removeAllRanges() | |
| selection?.addRange(range) | |
| await act(async () => { | |
| fireEvent.mouseUp(target ?? container, { clientX: 190, clientY: 80 }) | |
| await Promise.resolve() | |
| }) | |
| await flushReactWork() | |
| } | |
| function classNameContains(element: Element | null, needle: string) { | |
| let current = element | |
| while (current) { | |
| if (typeof current.className === 'string' && current.className.includes(needle)) { | |
| return true | |
| } | |
| current = current.parentElement | |
| } | |
| return false | |
| } | |
| vi.mock('../../api/sessions', () => ({ | |
| sessionsApi: (() => { | |
| if (!mocks) { | |
| mocks = { | |
| getWorkspaceStatusMock: vi.fn(), | |
| getWorkspaceTreeMock: vi.fn(), | |
| getWorkspaceFileMock: vi.fn(), | |
| getWorkspaceDiffMock: vi.fn(), | |
| } | |
| } | |
| return { | |
| getWorkspaceStatus: mocks.getWorkspaceStatusMock, | |
| getWorkspaceTree: mocks.getWorkspaceTreeMock, | |
| getWorkspaceFile: mocks.getWorkspaceFileMock, | |
| getWorkspaceDiff: mocks.getWorkspaceDiffMock, | |
| } | |
| })(), | |
| })) | |
| import { useSettingsStore } from '../../stores/settingsStore' | |
| import { useChatStore } from '../../stores/chatStore' | |
| import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' | |
| import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' | |
| import { useUIStore } from '../../stores/uiStore' | |
| import { WorkspacePanel } from './WorkspacePanel' | |
| describe('WorkspacePanel', () => { | |
| const workspaceInitialState = useWorkspacePanelStore.getInitialState() | |
| const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState() | |
| const settingsInitialState = useSettingsStore.getInitialState() | |
| const chatInitialState = useChatStore.getInitialState() | |
| beforeEach(async () => { | |
| vi.clearAllMocks() | |
| await setWorkspaceState(workspaceInitialState) | |
| useChatStore.setState(chatInitialState, true) | |
| useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) | |
| await setSettingsState({ ...settingsInitialState, locale: 'en' }) | |
| getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => | |
| useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| ) | |
| getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') => | |
| useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? { | |
| state: 'ok', | |
| path, | |
| entries: [], | |
| }, | |
| ) | |
| }) | |
| afterEach(async () => { | |
| cleanup() | |
| await setWorkspaceState(workspaceInitialState) | |
| useChatStore.setState(chatInitialState, true) | |
| useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) | |
| await setSettingsState(settingsInitialState) | |
| vi.restoreAllMocks() | |
| }) | |
| it('stays hidden when the panel is closed', async () => { | |
| const view = await renderPanel('session-hidden') | |
| expect(view.queryByTestId('workspace-panel')).toBeNull() | |
| }) | |
| it('loads changed status on open and opens a diff preview from the changed view', async () => { | |
| const statusRequest = deferred<{ | |
| state: 'ok' | |
| workDir: string | |
| repoName: string | |
| branch: string | |
| isGitRepo: true | |
| changedFiles: Array<{ | |
| path: string | |
| status: 'modified' | |
| additions: number | |
| deletions: number | |
| }> | |
| }>() | |
| const diffRequest = deferred<{ | |
| state: 'ok' | |
| path: string | |
| diff: string | |
| }>() | |
| getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) | |
| getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise) | |
| await act(() => { | |
| useWorkspacePanelStore.getState().openPanel('session-changed') | |
| }) | |
| const view = await renderPanel('session-changed') | |
| const compactPanel = view.getByTestId('workspace-panel') | |
| expect(compactPanel.style.width).toBe('520px') | |
| expect(compactPanel.style.maxWidth).toBe('36%') | |
| expect(compactPanel.style.minWidth).toBe('min(340px, 40%)') | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed') | |
| }) | |
| await act(async () => { | |
| statusRequest.resolve({ | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [ | |
| { | |
| path: 'src/app.ts', | |
| status: 'modified', | |
| additions: 4, | |
| deletions: 1, | |
| }, | |
| ], | |
| }) | |
| await statusRequest.promise | |
| }) | |
| expect(view.getByPlaceholderText('Filter files...')).toBeTruthy() | |
| await clickElement(await view.findByText('src/app.ts')) | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts') | |
| }) | |
| await act(async () => { | |
| diffRequest.resolve({ | |
| state: 'ok', | |
| path: 'src/app.ts', | |
| diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")', | |
| }) | |
| await diffRequest.promise | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")') | |
| }) | |
| const expandedPanel = view.getByTestId('workspace-panel') | |
| expect(expandedPanel.style.width).toBe('860px') | |
| expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))') | |
| expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)') | |
| expect(view.getAllByText('Diff').length).toBeGreaterThan(0) | |
| }) | |
| it('refreshes status on open and switches back to changed files when new changes exist', async () => { | |
| getMocks().getWorkspaceStatusMock.mockResolvedValue({ | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [ | |
| { | |
| path: 'src/Fresh.ts', | |
| status: 'modified', | |
| additions: 4, | |
| deletions: 1, | |
| }, | |
| ], | |
| }) | |
| getMocks().getWorkspaceTreeMock.mockResolvedValue({ | |
| state: 'ok', | |
| path: '', | |
| entries: [{ name: 'src', path: 'src', isDirectory: true }], | |
| }) | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-stale-all': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-stale-all': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-stale-all') | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all') | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() | |
| }) | |
| expect(view.getByText('src/Fresh.ts')).toBeTruthy() | |
| }) | |
| it('loads workspace status when opened while the chat is running', async () => { | |
| getMocks().getWorkspaceStatusMock.mockResolvedValue({ | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [ | |
| { | |
| path: 'src/running.ts', | |
| status: 'modified', | |
| additions: 1, | |
| deletions: 0, | |
| }, | |
| ], | |
| }) | |
| useChatStore.setState({ | |
| sessions: { | |
| 'session-running-open': { | |
| chatState: 'thinking', | |
| } as never, | |
| }, | |
| }) | |
| await act(() => { | |
| useWorkspacePanelStore.getState().openPanel('session-running-open') | |
| }) | |
| const view = await renderPanel('session-running-open') | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open') | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByText('src/running.ts')).toBeTruthy() | |
| }) | |
| expect(view.queryByText('Loading...')).toBeNull() | |
| }) | |
| it('renders transcript-derived changed files for non-git sessions', async () => { | |
| getMocks().getWorkspaceStatusMock.mockResolvedValue({ | |
| state: 'ok', | |
| workDir: '/tmp/non-git-session', | |
| repoName: 'non-git-session', | |
| branch: null, | |
| isGitRepo: false, | |
| changedFiles: [ | |
| { | |
| path: 'src/app.ts', | |
| status: 'modified', | |
| additions: 1, | |
| deletions: 1, | |
| }, | |
| ], | |
| }) | |
| getMocks().getWorkspaceDiffMock.mockResolvedValue({ | |
| state: 'ok', | |
| path: 'src/app.ts', | |
| diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2', | |
| }) | |
| await act(() => { | |
| useWorkspacePanelStore.getState().openPanel('session-non-git') | |
| }) | |
| const view = await renderPanel('session-non-git') | |
| await waitFor(() => { | |
| expect(view.getByText('src/app.ts')).toBeTruthy() | |
| }) | |
| expect(view.queryByText('No matching files')).toBeNull() | |
| await clickElement(view.getByText('src/app.ts')) | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts') | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2') | |
| }) | |
| }) | |
| it('opens to all files when the current turn has no changed files', async () => { | |
| const statusRequest = deferred<{ | |
| state: 'ok' | |
| workDir: string | |
| repoName: string | |
| branch: string | |
| isGitRepo: true | |
| changedFiles: [] | |
| }>() | |
| const rootTreeRequest = deferred<{ | |
| state: 'ok' | |
| path: '' | |
| entries: Array<{ name: string; path: string; isDirectory: boolean }> | |
| }>() | |
| getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) | |
| getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise) | |
| await act(() => { | |
| useWorkspacePanelStore.getState().openPanel('session-empty-tree') | |
| }) | |
| const view = await renderPanel('session-empty-tree') | |
| expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() | |
| await act(async () => { | |
| statusRequest.resolve({ | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }) | |
| await statusRequest.promise | |
| }) | |
| await waitFor(() => { | |
| expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all') | |
| expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '') | |
| }) | |
| await act(async () => { | |
| rootTreeRequest.resolve({ | |
| state: 'ok', | |
| path: '', | |
| entries: [ | |
| { name: 'src', path: 'src', isDirectory: true }, | |
| { name: 'README.md', path: 'README.md', isDirectory: false }, | |
| ], | |
| }) | |
| await rootTreeRequest.promise | |
| }) | |
| expect(view.getByRole('button', { name: 'All files' })).toBeTruthy() | |
| expect(await view.findByText('src')).toBeTruthy() | |
| expect(await view.findByText('README.md')).toBeTruthy() | |
| expect(view.queryByText('No changes')).toBeNull() | |
| }) | |
| it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => { | |
| const statusRequest = deferred<{ | |
| state: 'ok' | |
| workDir: string | |
| repoName: string | |
| branch: string | |
| isGitRepo: true | |
| changedFiles: [] | |
| }>() | |
| const rootTreeRequest = deferred<{ | |
| state: 'ok' | |
| path: '' | |
| entries: Array<{ name: string; path: string; isDirectory: boolean }> | |
| }>() | |
| const childTreeRequest = deferred<{ | |
| state: 'ok' | |
| path: 'src' | |
| entries: Array<{ name: string; path: string; isDirectory: boolean }> | |
| }>() | |
| const fileRequest = deferred<{ | |
| state: 'ok' | |
| path: string | |
| content: string | |
| language: string | |
| size: number | |
| }>() | |
| getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise) | |
| getMocks().getWorkspaceTreeMock | |
| .mockReturnValueOnce(rootTreeRequest.promise) | |
| .mockReturnValueOnce(childTreeRequest.promise) | |
| getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise) | |
| await act(() => { | |
| useWorkspacePanelStore.getState().openPanel('session-tree') | |
| }) | |
| const view = await renderPanel('session-tree') | |
| expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy() | |
| await clickElement(view.getByRole('button', { name: 'Changed files' })) | |
| await clickElement(view.getByRole('menuitem', { name: 'All files' })) | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '') | |
| }) | |
| await act(async () => { | |
| statusRequest.resolve({ | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }) | |
| rootTreeRequest.resolve({ | |
| state: 'ok', | |
| path: '', | |
| entries: [ | |
| { name: 'src', path: 'src', isDirectory: true }, | |
| { name: 'README.md', path: 'README.md', isDirectory: false }, | |
| ], | |
| }) | |
| await Promise.all([statusRequest.promise, rootTreeRequest.promise]) | |
| }) | |
| const folderLabel = await view.findByText('src') | |
| const folderButton = folderLabel.closest('button') | |
| if (!folderButton) { | |
| throw new Error('Expected src label to be rendered inside a folder button') | |
| } | |
| expect(folderButton.getAttribute('aria-expanded')).toBe('false') | |
| await clickElement(folderButton) | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src') | |
| }) | |
| await act(async () => { | |
| childTreeRequest.resolve({ | |
| state: 'ok', | |
| path: 'src', | |
| entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }], | |
| }) | |
| await childTreeRequest.promise | |
| }) | |
| await waitFor(() => { | |
| expect(folderButton.getAttribute('aria-expanded')).toBe('true') | |
| }) | |
| await clickElement(await view.findByText('index.ts')) | |
| await waitFor(() => { | |
| expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts') | |
| }) | |
| await act(async () => { | |
| fileRequest.resolve({ | |
| state: 'ok', | |
| path: 'src/index.ts', | |
| content: 'export const ready = true', | |
| language: 'typescript', | |
| size: 25, | |
| }) | |
| await fileRequest.promise | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true') | |
| }) | |
| expect(view.getAllByText('File').length).toBeGreaterThan(0) | |
| }) | |
| it('renders multiple preview tabs and closes only the exact requested tab', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-tabs': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-tabs': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-tabs': [ | |
| { | |
| id: 'file:src/a.ts', | |
| path: 'src/a.ts', | |
| kind: 'file', | |
| title: 'a.ts', | |
| language: 'typescript', | |
| content: 'export const a = 1', | |
| state: 'ok', | |
| size: 18, | |
| }, | |
| { | |
| id: 'diff:src/a.ts', | |
| path: 'src/a.ts', | |
| kind: 'diff', | |
| title: 'a.ts', | |
| diff: '@@ -1 +1 @@', | |
| state: 'ok', | |
| }, | |
| { | |
| id: 'file:src/b.ts', | |
| path: 'src/b.ts', | |
| kind: 'file', | |
| title: 'b.ts', | |
| language: 'typescript', | |
| content: 'export const b = 1', | |
| state: 'ok', | |
| size: 18, | |
| }, | |
| ], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-tabs': 'diff:src/a.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-tabs') | |
| expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy() | |
| expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2) | |
| expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2) | |
| expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) | |
| await clickElement(view.getByLabelText('Close tab a.ts Diff')) | |
| expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull() | |
| expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy() | |
| expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1) | |
| }) | |
| it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => { | |
| await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' }) | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-dark-theme': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-dark-theme': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-dark-theme': [{ | |
| id: 'file:src/theme.ts', | |
| path: 'src/theme.ts', | |
| kind: 'file', | |
| title: 'theme.ts', | |
| language: 'typescript', | |
| content: 'export const theme = "dark"', | |
| state: 'ok', | |
| size: 27, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-dark-theme': 'file:src/theme.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-dark-theme') | |
| const panel = view.getByTestId('workspace-panel') | |
| const tabList = view.getByRole('tablist', { name: 'Preview tabs' }) | |
| const codeSurface = view.getByTestId('workspace-code') | |
| expect(panel.className).toContain('bg-[var(--color-surface)]') | |
| expect(panel.className).not.toContain('bg-white') | |
| expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]') | |
| expect(tabList.className).not.toContain('bg-white') | |
| expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true) | |
| expect(classNameContains(codeSurface, 'bg-white')).toBe(false) | |
| }) | |
| it('can expand long diff previews beyond the default rendered line cap', async () => { | |
| const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n') | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-large-preview': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-large-preview': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-large-preview': [{ | |
| id: 'diff:large.ts', | |
| path: 'large.ts', | |
| kind: 'diff', | |
| title: 'large.ts', | |
| diff: longDiff, | |
| state: 'ok', | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-large-preview': 'diff:large.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-large-preview') | |
| const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' | |
| expect(highlightedCode).toContain('+line 1') | |
| expect(highlightedCode).toContain('+line 2000') | |
| expect(highlightedCode).not.toContain('+line 2001') | |
| await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) | |
| await waitFor(() => { | |
| expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300') | |
| }) | |
| expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() | |
| }) | |
| it('keeps diff rows intrinsically wide so H5 users can scroll sideways', async () => { | |
| const longDiffLine = '+const label = "this is a very long generated line that should not be compressed into the phone viewport";' | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-wide-diff': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-wide-diff': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-wide-diff': [{ | |
| id: 'diff:wide.ts', | |
| path: 'wide.ts', | |
| kind: 'diff', | |
| title: 'wide.ts', | |
| diff: longDiffLine, | |
| state: 'ok', | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-wide-diff': 'diff:wide.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-wide-diff') | |
| const diffSurface = view.getByTestId('workspace-code') | |
| const firstRow = diffSurface.querySelector('div') | |
| expect(firstRow?.className).toContain('w-max') | |
| expect(firstRow?.className).toContain('min-w-full') | |
| expect(firstRow?.className).toContain('grid-cols-[48px_18px_max-content]') | |
| expect(diffSurface.textContent).toContain(longDiffLine) | |
| }) | |
| it('can expand long file previews beyond the default rendered line cap', async () => { | |
| const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n') | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-large-file-preview': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-large-file-preview': [{ | |
| id: 'file:large-file.ts', | |
| path: 'large-file.ts', | |
| kind: 'file', | |
| title: 'large-file.ts', | |
| content: longFile, | |
| language: 'typescript', | |
| previewType: 'text', | |
| state: 'ok', | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-large-file-preview': 'file:large-file.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-large-file-preview') | |
| const highlightedCode = view.getByTestId('workspace-code').textContent ?? '' | |
| expect(highlightedCode).toContain('const line1 = 1') | |
| expect(highlightedCode).toContain('const line2000 = 2000') | |
| expect(highlightedCode).not.toContain('const line2001 = 2001') | |
| await clickElement(view.getByRole('button', { name: 'Show all loaded lines' })) | |
| await waitFor(() => { | |
| expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300') | |
| }) | |
| expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() | |
| }, 20_000) | |
| it('renders image previews from workspace files', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-image-preview': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| treeBySessionPath: { | |
| ...state.treeBySessionPath, | |
| 'session-image-preview': { | |
| '': { | |
| state: 'ok', | |
| path: '', | |
| entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }], | |
| }, | |
| }, | |
| }, | |
| })) | |
| getMocks().getWorkspaceFileMock.mockResolvedValue({ | |
| state: 'ok', | |
| path: 'logo.png', | |
| previewType: 'image', | |
| dataUrl: 'data:image/png;base64,iVBORw0KGgo=', | |
| mimeType: 'image/png', | |
| language: 'image', | |
| size: 8, | |
| }) | |
| const view = await renderPanel('session-image-preview') | |
| await clickElement(await view.findByText('logo.png')) | |
| const image = await view.findByRole('img', { name: 'logo.png' }) | |
| expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=') | |
| }) | |
| it('renders markdown file previews as formatted documents', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-markdown-preview': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-markdown-preview': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-markdown-preview': [{ | |
| id: 'file:README.md', | |
| path: 'README.md', | |
| kind: 'file', | |
| title: 'README.md', | |
| language: 'markdown', | |
| content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```', | |
| state: 'ok', | |
| size: 70, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-markdown-preview': 'file:README.md', | |
| }, | |
| })) | |
| const view = await renderPanel('session-markdown-preview') | |
| expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy() | |
| expect(view.getByText('Done')).toBeTruthy() | |
| expect(view.container.textContent).toContain('export const ok = true') | |
| expect(view.queryByTestId('workspace-code')).toBeNull() | |
| }) | |
| it('opens a context menu for preview tabs and closes tabs to the right', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-preview-menu': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-preview-menu': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-preview-menu': [ | |
| { | |
| id: 'file:src/App.jsx', | |
| path: 'src/App.jsx', | |
| kind: 'file', | |
| title: 'App.jsx', | |
| language: 'jsx', | |
| content: 'app', | |
| state: 'ok', | |
| size: 3, | |
| }, | |
| { | |
| id: 'diff:vite.config.js', | |
| path: 'vite.config.js', | |
| kind: 'diff', | |
| title: 'vite.config.js', | |
| diff: '@@ -1 +1 @@', | |
| state: 'ok', | |
| size: 12, | |
| }, | |
| { | |
| id: 'file:src/index.css', | |
| path: 'src/index.css', | |
| kind: 'file', | |
| title: 'index.css', | |
| language: 'css', | |
| content: 'body{}', | |
| state: 'ok', | |
| size: 6, | |
| }, | |
| ], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-preview-menu': 'diff:vite.config.js', | |
| }, | |
| })) | |
| const view = await renderPanel('session-preview-menu') | |
| await act(() => { | |
| fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), { | |
| clientX: 320, | |
| clientY: 42, | |
| }) | |
| }) | |
| await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' })) | |
| expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([ | |
| { id: 'file:src/App.jsx' }, | |
| { id: 'diff:vite.config.js' }, | |
| ]) | |
| expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js') | |
| expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull() | |
| }) | |
| it('adds a workspace file to the chat context from the file tree menu', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-add-file': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-add-file': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| treeBySessionPath: { | |
| ...state.treeBySessionPath, | |
| 'session-add-file': { | |
| '': { | |
| state: 'ok', | |
| path: '', | |
| entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], | |
| }, | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-add-file') | |
| await act(() => { | |
| fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { | |
| clientX: 260, | |
| clientY: 80, | |
| }) | |
| }) | |
| await clickElement(view.getByRole('menuitem', { name: 'Add to chat' })) | |
| expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([ | |
| { | |
| kind: 'file', | |
| path: 'src/App.tsx', | |
| absolutePath: '/repo/src/App.tsx', | |
| name: 'App.tsx', | |
| }, | |
| ]) | |
| }) | |
| it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => { | |
| const originalClipboard = navigator.clipboard | |
| const originalExecCommand = document.execCommand | |
| Object.defineProperty(document, 'execCommand', { | |
| configurable: true, | |
| value: vi.fn().mockReturnValue(true), | |
| }) | |
| const execCommand = vi.mocked(document.execCommand) | |
| Object.defineProperty(navigator, 'clipboard', { | |
| configurable: true, | |
| value: { | |
| writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), | |
| }, | |
| }) | |
| const writeText = vi.mocked(navigator.clipboard.writeText) | |
| try { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-copy-file': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-copy-file': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| treeBySessionPath: { | |
| ...state.treeBySessionPath, | |
| 'session-copy-file': { | |
| '': { | |
| state: 'ok', | |
| path: '', | |
| entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], | |
| }, | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-copy-file') | |
| await act(() => { | |
| fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { | |
| clientX: 260, | |
| clientY: 80, | |
| }) | |
| }) | |
| await clickElement(view.getByRole('menuitem', { name: 'Copy path' })) | |
| await waitFor(() => { | |
| expect(execCommand).toHaveBeenCalledWith('copy') | |
| }) | |
| expect(writeText).toHaveBeenCalledWith('/repo/src/App.tsx') | |
| expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({ | |
| type: 'success', | |
| message: 'Path copied.', | |
| }) | |
| } finally { | |
| Object.defineProperty(document, 'execCommand', { | |
| configurable: true, | |
| value: originalExecCommand, | |
| }) | |
| Object.defineProperty(navigator, 'clipboard', { | |
| configurable: true, | |
| value: originalClipboard, | |
| }) | |
| } | |
| }) | |
| it('adds a line comment from a code preview to the chat context', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-line-comment': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-line-comment': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-line-comment': [{ | |
| id: 'file:src/App.tsx', | |
| path: 'src/App.tsx', | |
| kind: 'file', | |
| title: 'App.tsx', | |
| language: 'tsx', | |
| content: 'const title = "Todo"\nexport default title', | |
| state: 'ok', | |
| size: 42, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-line-comment': 'file:src/App.tsx', | |
| }, | |
| })) | |
| const view = await renderPanel('session-line-comment') | |
| await clickElement(view.getByRole('button', { name: 'Comment line 1' })) | |
| const textarea = view.getByPlaceholderText('Describe what should change here...') | |
| await act(() => { | |
| fireEvent.change(textarea, { target: { value: 'Rename this title' } }) | |
| }) | |
| await clickElement(view.getByRole('button', { name: 'Add comment' })) | |
| expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([ | |
| { | |
| kind: 'code-comment', | |
| path: 'src/App.tsx', | |
| absolutePath: '/repo/src/App.tsx', | |
| name: 'App.tsx', | |
| lineStart: 1, | |
| lineEnd: 1, | |
| note: 'Rename this title', | |
| quote: 'const title = "Todo"', | |
| }, | |
| ]) | |
| }) | |
| it('adds selected code from a preview to the chat context without requiring a note', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-code-selection': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-code-selection': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-code-selection': [{ | |
| id: 'file:src/App.ts', | |
| path: 'src/App.ts', | |
| kind: 'file', | |
| title: 'App.ts', | |
| language: 'text', | |
| content: 'const title = "Todo"\nexport default title', | |
| state: 'ok', | |
| size: 42, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-code-selection': 'file:src/App.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-code-selection') | |
| await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 2, 'export default title') | |
| const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) | |
| await clickElement(addButtons[addButtons.length - 1]!) | |
| expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']).toMatchObject([ | |
| { | |
| kind: 'code-selection', | |
| path: 'src/App.ts', | |
| absolutePath: '/repo/src/App.ts', | |
| name: 'App.ts', | |
| lineStart: 1, | |
| lineEnd: 2, | |
| quote: 'const title = "Todo"\nexport default title', | |
| }, | |
| ]) | |
| expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']?.[0]?.note).toBeUndefined() | |
| }) | |
| it('keeps the selected-code action near the preview instead of the file tree', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-selection-position': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-selection-position': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-selection-position': [{ | |
| id: 'file:src/App.ts', | |
| path: 'src/App.ts', | |
| kind: 'file', | |
| title: 'App.ts', | |
| language: 'text', | |
| content: 'const title = "Todo"\nexport default title', | |
| state: 'ok', | |
| size: 42, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-selection-position': 'file:src/App.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-selection-position') | |
| await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"') | |
| const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) | |
| const floatingAddButton = addButtons[addButtons.length - 1]! | |
| expect(floatingAddButton.style.left).toBe('101px') | |
| expect(floatingAddButton.style.top).toBe('46px') | |
| fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), { | |
| key: 'Escape', | |
| }) | |
| await flushReactWork() | |
| expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) | |
| }) | |
| it('dismisses the selected-code action when clicking outside the popover', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-selection-dismiss': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-selection-dismiss': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-selection-dismiss': [{ | |
| id: 'file:src/App.ts', | |
| path: 'src/App.ts', | |
| kind: 'file', | |
| title: 'App.ts', | |
| language: 'text', | |
| content: 'const title = "Todo"\nexport default title', | |
| state: 'ok', | |
| size: 42, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-selection-dismiss': 'file:src/App.ts', | |
| }, | |
| })) | |
| const view = await renderPanel('session-selection-dismiss') | |
| await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"') | |
| expect(view.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(2) | |
| await act(async () => { | |
| fireEvent.pointerDown(document.body) | |
| await Promise.resolve() | |
| }) | |
| await flushReactWork() | |
| expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) | |
| expect(window.getSelection()?.toString()).toBe('') | |
| }) | |
| it('adds selected markdown text from a preview to the chat context', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-markdown-selection': { | |
| isOpen: true, | |
| activeView: 'all', | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-markdown-selection': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| previewTabsBySession: { | |
| ...state.previewTabsBySession, | |
| 'session-markdown-selection': [{ | |
| id: 'file:docs/guide.md', | |
| path: 'docs/guide.md', | |
| kind: 'file', | |
| title: 'guide.md', | |
| language: 'markdown', | |
| content: '# Guide\nAlpha note and Beta note', | |
| state: 'ok', | |
| size: 33, | |
| }], | |
| }, | |
| activePreviewTabIdBySession: { | |
| ...state.activePreviewTabIdBySession, | |
| 'session-markdown-selection': 'file:docs/guide.md', | |
| }, | |
| })) | |
| const view = await renderPanel('session-markdown-selection') | |
| const paragraph = view.getByText('Alpha note and Beta note') | |
| const markdownSurface = paragraph.closest('.min-h-0') ?? paragraph | |
| await selectRenderedText(paragraph, 'Alpha note and Beta note', markdownSurface) | |
| const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) | |
| const floatingAddButton = addButtons[addButtons.length - 1]! | |
| await clickElement(floatingAddButton) | |
| expect(useWorkspaceChatContextStore.getState().referencesBySession['session-markdown-selection']).toMatchObject([ | |
| { | |
| kind: 'code-selection', | |
| path: 'docs/guide.md', | |
| absolutePath: '/repo/docs/guide.md', | |
| name: 'guide.md', | |
| lineStart: 2, | |
| lineEnd: 2, | |
| quote: 'Alpha note and Beta note', | |
| }, | |
| ]) | |
| }) | |
| it('uses the localized view menu label', async () => { | |
| await setSettingsState({ ...settingsInitialState, locale: 'zh' }) | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-zh': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-zh': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-zh') | |
| expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy() | |
| }) | |
| it('keeps the workspace header controls compact', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-compact-header': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-compact-header': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-compact-header') | |
| const viewMenuButton = view.getByRole('button', { name: 'Changed files' }) | |
| const refreshButton = view.getByRole('button', { name: 'Refresh workspace' }) | |
| const closeButton = view.getByRole('button', { name: 'Close workspace panel' }) | |
| expect(viewMenuButton.className).toContain('text-[14px]') | |
| expect(viewMenuButton.className).not.toContain('text-[18px]') | |
| expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]') | |
| expect(refreshButton.className).toContain('h-7 w-7') | |
| expect(closeButton.className).toContain('h-7 w-7') | |
| expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') | |
| expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]') | |
| }) | |
| it('shows explicit empty and error states in the changed view', async () => { | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-empty': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| statusBySession: { | |
| ...state.statusBySession, | |
| 'session-empty': { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| }, | |
| }, | |
| })) | |
| const view = await renderPanel('session-empty') | |
| expect(view.getByText('No changes')).toBeTruthy() | |
| getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => { | |
| if (sessionId === 'session-error') { | |
| throw new Error('status failed') | |
| } | |
| return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? { | |
| state: 'ok', | |
| workDir: '/repo', | |
| repoName: 'repo', | |
| branch: 'main', | |
| isGitRepo: true, | |
| changedFiles: [], | |
| } | |
| }) | |
| await setWorkspaceState((state) => ({ | |
| ...state, | |
| panelBySession: { | |
| ...state.panelBySession, | |
| 'session-error': { | |
| isOpen: true, | |
| activeView: 'changed', | |
| hasUserSelectedView: true, | |
| }, | |
| }, | |
| errors: { | |
| ...state.errors, | |
| statusBySession: { | |
| ...state.errors.statusBySession, | |
| 'session-error': 'status failed', | |
| }, | |
| }, | |
| })) | |
| await act(async () => { | |
| view.rerender(<WorkspacePanel sessionId="session-error" />) | |
| await Promise.resolve() | |
| await Promise.resolve() | |
| }) | |
| await waitFor(() => { | |
| expect(view.getByText('status failed')).toBeTruthy() | |
| }) | |
| }) | |
| }) | |