mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Several desktop copy entry points still called navigator.clipboard directly, which can fail or report success inconsistently in Tauri and browser test contexts. Route those copy actions through the existing textarea execCommand fallback helper and cover the fallback paths for startup diagnostics, markdown copy controls, and workspace file paths. Constraint: Clipboard API is not reliable in every desktop/browser context. Rejected: Leave direct navigator.clipboard calls in isolated components | repeats the diagnostics copy failure mode and creates inconsistent copy behavior. Confidence: high Scope-risk: narrow Directive: New desktop copy controls should use copyTextToClipboard or CopyButton instead of calling navigator.clipboard directly. Tested: cd desktop && bun run lint Tested: cd desktop && bun run test -- --run src/components/markdown/MarkdownRenderer.test.tsx src/components/layout/StartupErrorView.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/__tests__/diagnosticsSettings.test.tsx src/components/chat/MessageList.test.tsx Tested: bun run check:desktop Tested: bun run check:native Tested: bun run check:coverage Tested: bun run verify (passed=7 failed=0 skipped=3) Not-tested: Live provider baseline; not required for this desktop-only clipboard fix.
1654 lines
48 KiB
TypeScript
1654 lines
48 KiB
TypeScript
// @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: 40,
|
|
right: 240,
|
|
bottom: 58,
|
|
width: 120,
|
|
height: 18,
|
|
x: 120,
|
|
y: 40,
|
|
toJSON: () => ({}),
|
|
}),
|
|
})
|
|
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
selection?.addRange(range)
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
|
|
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 { 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()
|
|
})
|
|
|
|
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')
|
|
} 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('180px')
|
|
expect(floatingAddButton.style.top).toBe('80px')
|
|
|
|
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('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()
|
|
})
|
|
})
|
|
})
|