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
+27
View File
@@ -6464,6 +6464,33 @@ describe('registerIpcHandlers agent terminal state', () => {
}
})
it('requires a directory through project creation IPC while preserving blank-path updates', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const event = trustedEvent(harness.webContents)
const create = electronMocks.handlers.get(ipcChannels.projectsCreate)!
const input = { name: 'Directory project', description: '', rootPath: '', defaultWorkMode: 'ask' as const }
const initialCount = database.listProjects().length
for (const rootPath of ['', ' ']) {
expect(() => create(event, { ...input, rootPath })).toThrow()
}
expect(database.listProjects()).toHaveLength(initialCount)
const created = create(event, { ...input, rootPath: ` ${process.cwd()} ` }) as AssistantProject
expect(database.getProject(created.id).rootPath).toBe(process.cwd())
database.updateProject(created.id, input)
await electronMocks.handlers.get(ipcChannels.projectsUpdate)!(event, {
projectId: created.id, input: { ...input, name: 'Renamed historical project' }
})
expect(database.getProject(created.id)).toMatchObject({ name: 'Renamed historical project', rootPath: '' })
} finally {
await harness.dispose()
database.close()
}
})
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
+2 -1
View File
@@ -147,6 +147,7 @@ import {
normalizeInteractiveWorkMode,
projectChannelLabels,
projectCreateSchema,
projectUpdateSchema,
scheduleCreateSchema,
expertCreateSchema,
type AssistantSchedule,
@@ -729,7 +730,7 @@ const approvalResponseSchema = z
const projectUpdateRequestSchema = z
.object({
projectId: assistantIdSchema,
input: projectCreateSchema
input: projectUpdateSchema
})
.strict()
const projectArchiveRequestSchema = z
+111
View File
@@ -76,6 +76,9 @@ function createSftp(options: {
) => void
) => callback(undefined, batches.shift() ?? [])
),
stat: vi.fn((_path, callback) => {
callback(new Error('Target unavailable'))
}),
close: vi.fn(
(
_handle: Buffer,
@@ -171,6 +174,114 @@ describe('bounded directory SFTP', () => {
)
})
it('includes only directory link targets and preserves their entry paths', async () => {
const sftp = createSftp({
canonicalPath: '/',
batches: [[
entry('relative', 'symbolic-link'),
entry('absolute', 'symbolic-link'),
entry('file-link', 'symbolic-link'),
entry('broken', 'symbolic-link'),
entry('denied', 'symbolic-link'),
entry('loop', 'symbolic-link'),
entry('../invalid', 'symbolic-link'),
entry('regular')
]]
})
sftp.stat = vi.fn((path, callback) => {
if (path === '/relative' || path === '/absolute') {
callback(undefined, entry('target').attrs)
} else if (path === '/file-link') {
callback(undefined, entry('target', 'file').attrs)
} else {
callback(Object.assign(new Error('Cannot stat target'), {
code: path === '/denied' ? 3 : 2
}))
}
})
const result = await listBoundedSftpDirectories(opener(sftp), '/')
expect(result.entries).toEqual([
{ name: 'absolute', path: '/absolute' },
{ name: 'regular', path: '/regular' },
{ name: 'relative', path: '/relative' }
])
expect(sftp.stat).toHaveBeenCalledTimes(6)
expect(sftp.realpath).toHaveBeenCalledTimes(2)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('bounds link lookups by the scanned-entry limit and keeps the return limit', async () => {
const sftp = createSftp({ batches: [
Array.from({ length: 2_100 }, (_, index) =>
entry(`link-${index.toString().padStart(4, '0')}`, 'symbolic-link'))
] })
sftp.stat = vi.fn((_path, callback) => {
callback(undefined, entry('target').attrs)
})
const result = await listBoundedSftpDirectories(opener(sftp))
expect(sftp.stat).toHaveBeenCalledTimes(2_000)
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(result.entries).toHaveLength(500)
expect(result.entries[499]?.name).toBe('link-0499')
expect(result.truncated).toBe(true)
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it.each(['abort', 'timeout', 'channel-error'] as const)(
'cleans up a pending STAT on %s and ignores late results', async (reason) => {
vi.useFakeTimers()
try {
const sftp = createSftp({ batches: [[
entry('first', 'symbolic-link'),
entry('second', 'symbolic-link')
]] })
let callback: Parameters<DirectorySftp['stat']>[1] | undefined
sftp.stat = vi.fn((_path, next) => { callback = next })
const controller = new AbortController()
const pending = listBoundedSftpDirectories(opener(sftp), undefined, controller.signal)
const expected = expect(pending).rejects.toThrow(
reason === 'timeout' ? '目录浏览超时' : reason
)
expect(sftp.stat).toHaveBeenCalledOnce()
expect(sftp.readdir).toHaveBeenCalledOnce()
if (reason === 'timeout') {
await vi.advanceTimersByTimeAsync(30_000)
} else if (reason === 'abort') {
controller.abort(new Error(reason))
} else {
(sftp as unknown as EventEmitter).emit('error', new Error(reason))
}
await expected
callback?.(undefined, entry('target').attrs)
await Promise.resolve()
await Promise.resolve()
expect(sftp.stat).toHaveBeenCalledOnce()
expect(sftp.readdir).toHaveBeenCalledOnce()
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
expect((sftp as unknown as EventEmitter).listenerCount('error')).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
}
)
it('closes resources if issuing STAT throws', async () => {
const sftp = createSftp({ batches: [[entry('link', 'symbolic-link')]] })
sftp.stat = vi.fn(() => { throw new Error('STAT failed') })
await expect(listBoundedSftpDirectories(opener(sftp))).rejects.toThrow('STAT failed')
expect(sftp.close).toHaveBeenCalledOnce()
expect(sftp.end).toHaveBeenCalledOnce()
})
it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => {
const sftp = createSftp({
batches: [[entry('\u{10000}'), entry('\ue000')], []]
+50 -22
View File
@@ -18,6 +18,7 @@ export type DirectorySftp = Pick<
| 'realpath'
| 'opendir'
| 'readdir'
| 'stat'
| 'close'
| 'end'
| 'on'
@@ -67,7 +68,7 @@ function parentPath(path: string): string | null {
return separator === 0 ? '/' : path.slice(0, separator)
}
function validDirectoryName(entry: FileEntryWithStats): boolean {
function validDirectoryEntry(entry: FileEntryWithStats): boolean {
const name = entry?.filename
if (
typeof name !== 'string' ||
@@ -84,9 +85,9 @@ function validDirectoryName(entry: FileEntryWithStats): boolean {
try {
return (
typeof entry.attrs?.isDirectory === 'function' &&
entry.attrs.isDirectory() === true &&
typeof entry.attrs.isSymbolicLink === 'function' &&
entry.attrs.isSymbolicLink() === false
(entry.attrs.isSymbolicLink() === true ||
entry.attrs.isDirectory() === true)
)
} catch {
return false
@@ -221,6 +222,38 @@ export function listBoundedSftpDirectories(
finishError(new Error('SSH 目录浏览超时'))
}, OPERATION_TIMEOUT_MS)
const inspectEntries = async (
entries: FileEntryWithStats[]
): Promise<void> => {
for (const entry of entries) {
if (settled) {
return
}
if (!validDirectoryEntry(entry)) {
continue
}
const path = joinPath(listingPath!, entry.filename)
if (!isCanonicalAbsolutePosixPath(path)) {
continue
}
if (entry.attrs.isSymbolicLink()) {
// STAT follows links; keep the entry path for later realpath navigation.
const isDirectory = await new Promise<boolean>((resolve) => {
sftp!.stat(path, (error, attrs) => {
resolve(!settled && !error && attrs?.isDirectory() === true)
})
})
if (settled) {
return
}
if (!isDirectory) {
continue
}
}
directories.add(entry.filename)
}
}
const readNextBatch = (): void => {
if (settled || !sftp || !handle) {
return
@@ -267,26 +300,21 @@ export function listBoundedSftpDirectories(
MAXIMUM_SCANNED_ENTRIES - scannedEntries
const inspected = list.slice(0, remaining)
scannedEntries += inspected.length
for (const entry of inspected) {
if (
validDirectoryName(entry) &&
isCanonicalAbsolutePosixPath(
joinPath(listingPath!, entry.filename)
)
) {
directories.add(entry.filename)
void inspectEntries(inspected).then(() => {
if (settled) {
return
}
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
if (
list.length > inspected.length ||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
) {
truncated = true
finishSuccess()
} else {
readNextBatch()
}
}).catch(finishError)
})
} catch (error) {
finishError(error)
+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:
+14
View File
@@ -159,6 +159,20 @@ describe('isUntouchedBuiltInDefaultProject', () => {
})
describe('project execution space contracts', () => {
it.each(['', ' ', '\t\n'])('rejects blank creation paths but preserves updates: %j', (rootPath) => {
const input = { name: 'Project', description: '', rootPath, defaultWorkMode: 'ask' }
const result = projectCreateSchema.safeParse(input)
expect(result.success).toBe(false)
if (!result.success) expect(result.error.issues[0]?.path).toEqual(['rootPath'])
expect(projectUpdateSchema.parse(input).rootPath).toBe('')
})
it.each(['C:\\Workspace', '/home/user/project'])('accepts and trims a creation directory: %s', (rootPath) => {
expect(projectCreateSchema.parse({
name: 'Project', description: '', rootPath: ` ${rootPath} `, defaultWorkMode: 'ask'
}).rootPath).toBe(rootPath)
})
it('parses strict local and SSH output without normalizing paths', () => {
expect(
projectExecutionSpaceSchema.parse({
+4 -2
View File
@@ -277,7 +277,7 @@ export function normalizeInteractiveWorkMode(
return workMode === 'execute' ? 'execute' : 'ask'
}
export const projectCreateSchema = z
export const projectUpdateSchema = z
.object({
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(2_000),
@@ -288,7 +288,9 @@ export const projectCreateSchema = z
})
.strict()
export const projectUpdateSchema = projectCreateSchema
export const projectCreateSchema = projectUpdateSchema.extend({
rootPath: z.string().trim().min(1).max(4_096)
})
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>