cc-haha/desktop/src/components/shared/DirectoryPicker.test.tsx
程序员阿江(Relakkes) 21954e5f5c fix: keep directory picker menu within viewport
The shared directory picker is used from right-aligned controls such as the MCP target project selector. Its portal menu previously used the trigger's raw left coordinate, so a trigger near the window edge could render the 400px menu partly off-screen.

Clamp the fixed-position menu against the viewport while preserving the existing width where space allows, and cover the right-edge case with a focused component test.

Constraint: DirectoryPicker is shared by MCP settings, task prompts, adapter settings, and launch controls.
Rejected: Special-case the MCP form layout | the overflow comes from the shared portal positioning and would remain in other right-aligned uses.
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bun run test -- src/components/shared/DirectoryPicker.test.tsx
Tested: bun run check:desktop
Not-tested: Manual browser smoke; local agent-browser automation hung during startup/command evaluation.
2026-05-25 20:10:24 +08:00

173 lines
5.4 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getRecentProjects: vi.fn(),
},
}))
vi.mock('../../api/filesystem', () => ({
filesystemApi: {
browse: vi.fn(),
},
}))
import { DirectoryPicker } from './DirectoryPicker'
import { sessionsApi } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
describe('DirectoryPicker', () => {
let originalInnerWidth: number
beforeEach(() => {
originalInnerWidth = window.innerWidth
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
vi.restoreAllMocks()
})
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
render(
<DirectoryPicker
value="/workspace/checkout/.claude/worktrees/desktop-feature-rail-12345678"
onChange={vi.fn()}
/>,
)
expect(screen.getByRole('button')).toHaveTextContent('checkout')
expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678')
})
it('does not duplicate the branch in the selected project chip', async () => {
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
projects: [{
projectPath: '/workspace/project',
realPath: '/workspace/project',
projectName: 'project',
repoName: 'NanmiCoder/OpenCutSkill',
branch: 'main',
isGit: true,
modifiedAt: '2026-05-07T00:00:00.000Z',
sessionCount: 1,
}],
})
render(
<DirectoryPicker
value="/workspace/project"
onChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button'))
const trigger = await waitFor(() => screen.getAllByRole('button', { name: /NanmiCoder\/OpenCutSkill/ })[0])
expect(trigger).toHaveTextContent('NanmiCoder/OpenCutSkill')
expect(trigger).not.toHaveTextContent('main')
})
it('supports the flat workbar trigger variant without changing the selected label', () => {
render(
<DirectoryPicker
value="/workspace/project"
onChange={vi.fn()}
variant="workbar"
/>,
)
const trigger = screen.getByRole('button')
expect(trigger).toHaveTextContent('project')
expect(trigger.className).toContain('rounded-[7px]')
expect(trigger.className).not.toContain('rounded-full')
})
it('constrains long workbar project names without hiding the full path from hover users', () => {
const longProjectName = 'project-with-a-very-long-directory-name-that-should-not-stretch-the-launch-bar'
const longPath = `/workspace/${longProjectName}`
render(
<DirectoryPicker
value={longPath}
onChange={vi.fn()}
variant="workbar"
/>,
)
const trigger = screen.getByRole('button')
const label = screen.getByText(longProjectName)
const triggerClasses = trigger.className.split(/\s+/)
expect(trigger).toHaveAttribute('title', longPath)
expect(triggerClasses).toContain('max-w-full')
expect(triggerClasses).not.toContain('w-full')
expect(trigger.parentElement?.className).toContain('max-w-[320px]')
expect(label.className).toContain('truncate')
})
it('can show a Git icon for workbar projects before the recent-project cache is loaded', () => {
render(
<DirectoryPicker
value="/workspace/project"
onChange={vi.fn()}
variant="workbar"
isGitProject
/>,
)
expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument()
})
it('keeps the recent-project menu inside the viewport when the trigger is near the right edge', async () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
render(
<DirectoryPicker
value="/workspace/project"
onChange={vi.fn()}
/>,
)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
x: 920,
y: 24,
top: 24,
left: 920,
right: 1010,
bottom: 60,
width: 90,
height: 36,
toJSON: () => ({}),
} as DOMRect)
fireEvent.click(trigger)
const menu = await screen.findByTestId('directory-picker-menu')
expect(menu).toHaveStyle({ left: '612px', width: '400px' })
})
it('renders browse entries without nesting interactive buttons', async () => {
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
vi.mocked(filesystemApi.browse).mockResolvedValue({
currentPath: '/workspace',
parentPath: '/Users/nanmi',
entries: [{ name: 'project', path: '/workspace/project', isDirectory: true }],
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<DirectoryPicker value="" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /选择项目|Select a project/ }))
fireEvent.click(await screen.findByText(/选择其他文件夹|Choose a different folder/))
expect(await screen.findByRole('button', { name: /project/ })).toBeInTheDocument()
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('validateDOMNesting'))
errorSpy.mockRestore()
})
})