mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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.
173 lines
5.4 KiB
TypeScript
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()
|
|
})
|
|
})
|