fix(projects): require creation directories and show remote directory links

Require a root folder in the local project creation dialog and IPC while preserving updates to historical projects with blank paths. Show remote symlinks whose targets are directories and resolve their canonical paths when browsing.

Validation: focused UI, contract, IPC and SSH tests passed; typecheck and lint passed. Verified directory links on the shared Linux Host. Full test run timed out after a Magic Notes storage timing failure.

Release note: 新建本地项目必须选择目录;远程项目选择目录时可查看并进入指向目录的符号链接。
This commit is contained in:
mesalogo
2026-10-04 15:00:13 +08:00
parent 629e82545b
commit ee186f9f70
15 changed files with 309 additions and 29 deletions
+41 -2
View File
@@ -444,7 +444,7 @@ describe('ProjectSwitcher runtime fields', () => {
})
it('creates an ordinary project with DeepSeek Harness', async () => {
const { onCreate } = renderSwitcher()
const { onCreate, onSelectRoot } = renderSwitcher()
openCreate('新建项目')
const dialog = screen.getByRole('dialog', { name: '新建项目' })
@@ -455,6 +455,9 @@ describe('ProjectSwitcher runtime fields', () => {
// New projects start from a concrete execution mode.
expect(runtime).not.toHaveValue('')
fireEvent.change(runtime, { target: { value: 'deepseek-harness' } })
onSelectRoot.mockResolvedValue('C:\\Workspace')
fireEvent.click(within(dialog).getByRole('button', { name: '选择项目根目录' }))
await waitFor(() => expect(within(dialog).getByRole('button', { name: '创建' })).toBeEnabled())
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
@@ -469,8 +472,41 @@ describe('ProjectSwitcher runtime fields', () => {
)
})
it('requires a local directory and preserves the selection when the picker is cancelled', async () => {
const { onCreate, onSelectRoot } = renderSwitcher()
openCreate('新建项目')
const dialog = screen.getByRole('dialog', { name: '新建项目' })
const create = within(dialog).getByRole('button', { name: '创建' })
const select = within(dialog).getByRole('button', { name: '选择项目根目录' })
const root = within(dialog).getByLabelText('根目录')
fireEvent.change(within(dialog).getByLabelText('名称'), { target: { value: 'Directory project' } })
expect(root).toHaveAttribute('aria-required', 'true')
expect(root).toHaveAccessibleDescription('根目录为必填项,请选择目录后创建项目。')
expect(create).toBeDisabled()
fireEvent.click(create)
expect(onCreate).not.toHaveBeenCalled()
for (const selection of [undefined, ' ', 'C:\\Selected', undefined]) {
onSelectRoot.mockResolvedValueOnce(selection)
await act(async () => { fireEvent.click(select) })
if (selection === 'C:\\Selected' || root.getAttribute('value') === 'C:\\Selected') {
expect(root).toHaveValue('C:\\Selected')
expect(create).toBeEnabled()
} else {
expect(create).toBeDisabled()
}
}
fireEvent.click(create)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ rootPath: 'C:\\Selected' }))
expect(screen.queryByRole('dialog', { name: '新建项目' })).not.toBeInTheDocument()
})
})
it('edits an ordinary project to use DeepSeek Harness and switches work mode by keyboard', async () => {
const { onUpdate } = renderSwitcher()
const { onUpdate } = renderSwitcher({
...project, rootPath: '', executionSpace: { kind: 'local', rootPath: '' }
})
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
fireEvent.click(screen.getByRole('menuitem', {
@@ -501,6 +537,7 @@ describe('ProjectSwitcher runtime fields', () => {
expect(onUpdate).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
rootPath: '',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'deepseek-harness' }
})
@@ -516,6 +553,8 @@ describe('ProjectSwitcher runtime fields', () => {
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
expect(within(dialog).getByLabelText('Root folder')).toHaveAttribute('aria-required', 'true')
expect(within(dialog).getByLabelText('Root folder')).toHaveAccessibleDescription('Required: select a root folder to create the project.')
expect(
within(dialog)
.getAllByRole('group', { name: 'Default mode' })
+14 -2
View File
@@ -934,6 +934,7 @@ function ProjectSwitcherView({
}
const save = async (): Promise<void> => {
if (dialogMode === 'create' && !draft.rootPath.trim()) return
setSaving(true)
setError(undefined)
try {
@@ -1346,7 +1347,12 @@ function ProjectSwitcherView({
{t('projectSwitcher.dialog.fields.rootPath')}
</span>
<div className="project-create-card__path">
<input readOnly value={draft.rootPath} />
<input
aria-required={dialogMode === 'create' || undefined}
aria-describedby={dialogMode === 'create' ? 'project-root-required' : undefined}
readOnly
value={draft.rootPath}
/>
<button
aria-label={t(
'projectSwitcher.dialog.selectRoot'
@@ -1360,6 +1366,11 @@ function ProjectSwitcherView({
</button>
</div>
</label>
{dialogMode === 'create' && (
<small id="project-root-required">
{t('projectSwitcher.dialog.rootRequired')}
</small>
)}
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
@@ -1904,7 +1915,8 @@ function ProjectSwitcherView({
<button
className="primary-button"
disabled={
busy || !draft.name.trim() || confirmingDelete
busy || !draft.name.trim() || confirmingDelete ||
(dialogMode === 'create' && !draft.rootPath.trim())
}
onClick={() => void save()}
type="button"
@@ -105,6 +105,7 @@ export const workspace = {
'Applies only to new conversations in this project. Existing conversations are unchanged.',
channelManaged: 'GoodBuddy manages channel project names.',
selectRoot: 'Select project root folder',
rootRequired: 'Required: select a root folder to create the project.',
danger: {
title: 'Danger zone',
description:
@@ -102,6 +102,7 @@ export const workspace = {
'仅应用于此项目中新建的对话,不会更改已有对话。',
channelManaged: '通道项目名称由 GoodBuddy 管理。',
selectRoot: '选择项目根目录',
rootRequired: '根目录为必填项,请选择目录后创建项目。',
danger: {
title: '危险操作',
description: