fix(desktop): open native folder dialog when creating projects

This commit is contained in:
程序员阿江(Relakkes)
2026-09-19 18:36:48 +08:00
parent 30855c77d4
commit 6bfed2b774
2 changed files with 87 additions and 2 deletions
@@ -1,11 +1,30 @@
import '@testing-library/jest-dom'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
const nativeDialog = vi.hoisted(() => ({
isDesktop: false,
open: vi.fn(),
}))
vi.mock('@/lib/desktopHost', () => ({
getDesktopHost: () => ({
isDesktop: nativeDialog.isDesktop,
capabilities: { dialogs: true },
dialogs: { open: nativeDialog.open },
}),
}))
beforeEach(() => {
nativeDialog.isDesktop = false
nativeDialog.open.mockReset()
})
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'dirPicker.chooseProjectFolder': 'Choose project folder',
'common.cancel': 'Cancel',
'common.save': 'Save',
'sidebar.projectEditor.createTitle': 'Create project',
@@ -71,6 +90,40 @@ function CreateHarness({
afterEach(cleanup)
describe('ProjectEditorModal', () => {
it('opens the native folder dialog directly and preserves the selected path', async () => {
nativeDialog.isDesktop = true
nativeDialog.open.mockResolvedValue('/workspace/selected ')
const onSourceFolderChange = vi.fn()
const onSubmit = vi.fn()
render(<CreateHarness onSubmit={onSubmit} onSourceFolderChange={onSourceFolderChange} />)
expect(screen.queryByRole('button', { name: 'Choose source folder' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose project folder' }))
await waitFor(() => expect(onSourceFolderChange).toHaveBeenCalledWith('/workspace/selected '))
expect(nativeDialog.open).toHaveBeenCalledWith({
directory: true,
multiple: false,
title: 'Choose project folder',
})
expect(screen.getByText('/workspace/selected')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('leaves the source unchanged on cancellation and reports dialog failures', async () => {
nativeDialog.isDesktop = true
nativeDialog.open.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('Dialog unavailable'))
const onSourceFolderChange = vi.fn()
render(<CreateHarness onSubmit={vi.fn()} onSourceFolderChange={onSourceFolderChange} />)
const choose = screen.getByRole('button', { name: 'Choose project folder' })
fireEvent.click(choose)
await waitFor(() => expect(choose).not.toBeDisabled())
expect(onSourceFolderChange).not.toHaveBeenCalled()
fireEvent.click(choose)
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Dialog unavailable'))
expect(onSourceFolderChange).not.toHaveBeenCalled()
})
it('validates normalized names and preserves exact source and logical paths', async () => {
const onSubmit = vi.fn()
const onSourceFolderChange = vi.fn()
@@ -1,5 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react'
import { FolderOpen } from 'lucide-react'
import { getDesktopHost } from '@/lib/desktopHost'
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@@ -66,6 +68,8 @@ export function ProjectEditorModal(props: ProjectEditorModalProps) {
onClose,
onSubmit,
} = props
const host = getDesktopHost()
const useNativeFolderDialog = host.isDesktop && host.capabilities.dialogs
const sourceFolder = mode === 'create' ? props.sourceFolder : logicalRoot ?? ''
const suggestedFolderName = suggestedName?.trim() || folderName(logicalRoot || sourceFolder)
const [name, setName] = useState(() => initialName ?? suggestedFolderName)
@@ -155,6 +159,20 @@ export function ProjectEditorModal(props: ProjectEditorModalProps) {
}))
}
const handleChooseSourceFolder = () => {
if (mode !== 'create') return
void runAction(async () => {
const selected = await host.dialogs.open({
directory: true,
multiple: false,
title: t('dirPicker.chooseProjectFolder'),
})
if (typeof selected === 'string' && selected.length > 0) {
props.onSourceFolderChange(selected)
}
})
}
const handleRestoreFolderName = () => {
if (mode !== 'edit' || !props.onRestoreFolderName) return
void runAction(props.onRestoreFolderName, () => {
@@ -220,7 +238,21 @@ export function ProjectEditorModal(props: ProjectEditorModalProps) {
{t('sidebar.projectEditor.sourceFolder')}
<span className="ml-0.5 text-[var(--color-error)]">*</span>
</span>
<DirectoryPicker value={sourceFolder} onChange={props.onSourceFolderChange} variant="workbar" />
{useNativeFolderDialog ? (
<Button
variant="secondary"
onClick={handleChooseSourceFolder}
disabled={busy}
title={sourceFolder || undefined}
aria-label={t('dirPicker.chooseProjectFolder')}
className="min-w-0 self-start max-w-full"
>
<FolderOpen size={16} className="shrink-0" aria-hidden="true" />
<span className="truncate">{sourceFolder || t('dirPicker.chooseProjectFolder')}</span>
</Button>
) : (
<DirectoryPicker value={sourceFolder} onChange={props.onSourceFolderChange} variant="workbar" />
)}
{showValidation && sourceFolderError && (
<p id="project-editor-source-folder-error" role="alert" className="text-xs text-[var(--color-error)]">
{sourceFolderError}