mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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.
This commit is contained in:
parent
bda7d86a05
commit
21954e5f5c
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
@ -19,6 +19,17 @@ 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
|
||||
@ -109,6 +120,36 @@ describe('DirectoryPicker', () => {
|
||||
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({
|
||||
|
||||
@ -20,6 +20,9 @@ let cachedProjects: RecentProject[] | null = null
|
||||
let cacheTimestamp = 0
|
||||
const CACHE_TTL = 30_000 // 30s
|
||||
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
||||
const DROPDOWN_WIDTH = 400
|
||||
const DROPDOWN_VIEWPORT_MARGIN = 12
|
||||
const DROPDOWN_HEIGHT = 380 // approximate max height
|
||||
|
||||
function isTauriRuntime() {
|
||||
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
@ -41,7 +44,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
const [browsePath, setBrowsePath] = useState('')
|
||||
const [browseParent, setBrowseParent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number; direction: 'up' | 'down' } | null>(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
||||
@ -51,13 +54,16 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
const updateDropdownPos = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const DROPDOWN_HEIGHT = 380 // approximate max height
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const direction = spaceBelow >= DROPDOWN_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
|
||||
const width = Math.min(DROPDOWN_WIDTH, Math.max(0, window.innerWidth - DROPDOWN_VIEWPORT_MARGIN * 2))
|
||||
const maxLeft = Math.max(DROPDOWN_VIEWPORT_MARGIN, window.innerWidth - width - DROPDOWN_VIEWPORT_MARGIN)
|
||||
const left = Math.min(Math.max(rect.left, DROPDOWN_VIEWPORT_MARGIN), maxLeft)
|
||||
setDropdownPos({
|
||||
top: direction === 'down' ? rect.bottom + 4 : rect.top - 4,
|
||||
left: rect.left,
|
||||
left,
|
||||
width,
|
||||
direction,
|
||||
})
|
||||
}, [])
|
||||
@ -159,10 +165,11 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
|
||||
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
|
||||
|
||||
const dropdownClassName = 'w-[400px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
|
||||
const dropdownClassName = 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
|
||||
const dropdownStyle = {
|
||||
position: 'fixed' as const,
|
||||
left: dropdownPos?.left,
|
||||
width: dropdownPos?.width,
|
||||
...(dropdownPos?.direction === 'down'
|
||||
? { top: dropdownPos.top }
|
||||
: { bottom: window.innerHeight - (dropdownPos?.top ?? 0) }),
|
||||
@ -340,6 +347,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
||||
) : createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
data-testid="directory-picker-menu"
|
||||
className={dropdownClassName}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user