diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md index 1c8e2693..6db47ee7 100644 --- a/docs/features/application-tool-navigation/prd.md +++ b/docs/features/application-tool-navigation/prd.md @@ -150,6 +150,8 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。 悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。 右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。 + +新建本地项目必须填写名称并选择根目录,空目录或纯空白目录不能创建。此要求仅适用于创建;历史空目录项目仍可编辑和保存,内置默认项目与消息通道项目的初始化规则保持不变。 活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见 [活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。 diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index 56966d16..8ff2c202 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,15 @@ # 应用导航实施进度 +## 2026-10-04:新建本地项目必选目录 + +对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。 + +- `npx vitest run --project unit src/renderer/src/ProjectSwitcher.test.tsx src/shared/assistant-contracts.test.ts`:57 项通过,覆盖空白目录、目录选择与取消、正常创建、中英文提示、历史空目录设置保存及既有 SSH 界面回归。 +- `npx vitest run src/main/ipc.test.ts -t "requires a directory through project creation IPC"`:1 项通过,172 项未选中。调用生产 IPC handler 和真实内存 SQLite,验证空目录创建不写库、有效目录创建落库,以及历史空目录更新成功。 +- `npm run typecheck` 和本次修改的 8 个源码/测试文件的定向 ESLint 检查通过。 + +本次未运行完整测试集或原生 Electron 目录选择器验收,未修改远程主机文档、Agent 或远程连接流程;模型调用 0 次。 + ## 2026-10-04:双行入口合并为单个按钮 对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index d33cb860..d015e3ab 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -479,6 +479,7 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re - 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`, 同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。 - `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。 +- 本地创建使用共享 `projectCreateSchema`,`rootPath` 经 trim 后要求 1 至 4096 个字符,`projectsCreate` IPC 在写库前校验。`projectUpdateSchema` 保留原有空目录兼容性,`projectsUpdate` 请求显式使用该 schema;持久化执行空间、默认项目和通道初始化不增加目录限制。`ProjectSwitcher` 在创建按钮和保存入口检查空目录,设置保存沿用原规则。 - `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。 - Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。 - 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index d96306ca..7d322790 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -311,6 +311,8 @@ OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次 左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。 +新建本地项目的根目录字段常显“根目录为必填项,请选择目录后创建项目。”,英文为“Required: select a root folder to create the project.”。只读路径框声明 `aria-required`,通过 `aria-describedby` 关联提示;沿用目录选择按钮。名称或目录去除首尾空白后为空时禁用“创建”。取消目录选择保留原选择和其他草稿;项目设置不显示创建专用提示,也不要求历史空目录项目补选目录。 + 左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。 项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 93de348c..776f0f86 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -279,7 +279,9 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、 - 创建项目时,用户可以直接输入远端工作目录,或通过输入框右侧的文件夹按钮浏览并选择目录。 - 项目保存前的目录浏览使用 Main 管理的只读、有界 SFTP,只返回目录;每次请求重新解析并校验当前 Host revision 与 Host Key generation,不安装 Agent、不暴露任意 shell/SFTP 接口。 +- 普通目录直接列出;符号链接通过 `stat` 检查目标,仅列出目录目标。文件链接、断链、循环链接和目标不可读的链接不列出。返回项保留当前目录下的链接名称和路径,进入该项时沿用 `realpath` 解析目标路径。 - 浏览从 SSH 账号 Home 或当前有效绝对路径开始,限制扫描次数、返回条目和总时限;取消、Host 变化、超时或连接失败时关闭 SFTP 并保留用户原先输入。 +- 每次浏览最多扫描 2,000 项、读取 64 批、返回 500 个目录,总时限 30 秒。链接检查逐项执行,计入同一次浏览的扫描及时间限制;检查期间取消、超时或通道报错仍关闭目录句柄和 SFTP 通道,忽略迟到的结果。 - 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。 - Ask Workspace handle 不暴露写入方法。 - Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。 @@ -1093,3 +1095,30 @@ node "C:\Users\jiang\AppData\Local\Temp\opencode\lifecycle-host-run.cjs" driver 延迟或清理失败,也没有覆盖未知终态后的新 binding 场景、Continue、Execute、断线恢复、 完整 UI 操作或签名包安装。故障时序与未知终态的下一次发送以本机定向回归为证据,不能用 这两轮成功请求替代。 + +## 目录符号链接验证(2026-10-04) + +`bounded-directory-sftp.ts` 已支持目录链接。该路径由 Desktop Main 的 SSH connection +lease 调用,不经过 Agent daemon 或 Runtime;本次直接构建并运行修改后的 Main SFTP +源码,无需更改 Host 上的 Agent 安装。 + +聚焦命令 `npm test -- src/main/ssh/bounded-directory-sftp.test.ts src/main/ssh/ssh-host-directory-browser.test.ts src/main/ssh/ssh-connection-pool.test.ts` +通过 3 个文件、40 项测试,覆盖目录目标筛选、条目路径、扫描及返回上限,以及等待 +`stat` 时取消、超时、通道错误、迟到回调和清理。`npm run lint` 通过。 +最终 `npm run typecheck` 通过;IPC 聚焦测试 +`npx vitest run src/main/ipc.test.ts -t "requires a directory through project creation IPC"` +通过 1 项,172 项未选中。 +全量 `npm test` 在 120 秒后由执行器终止,结束前报告 Magic Notes storage 启动读取 +耗时测试失败;本轮没有全量通过结论。 + +真实共享 Linux Host 验证复用 GoodBuddy safeStorage 凭据和固定 Host Key。LAN +`192.168.0.23` 未建立连接,VPN `10.7.0.23` 成功。测试在独立 +`/root/tmp/gb-directory-links-*` 目录创建普通目录、相对及绝对目录链接、链式链接、 +文件链接、断链和循环链接,通过生产 `lease.listDirectories` 验证可见项与原始条目 +路径,再进入三类目录链接确认返回目标的规范路径及子目录。取消一次真实浏览后, +同一 lease 再次浏览成功。测试目录已删除,模型调用 0 次。 + +临时探测脚本为 `C:\Users\jiang\AppData\Local\Temp\opencode\directory-symlink-host.ts`, +运行命令为 `node "C:\Users\jiang\AppData\Local\Temp\opencode\directory-symlink-host-run.cjs"`, +退出码 0。Host 场景未注入目标权限错误或 `stat` 超时,这些失败分支由聚焦回归覆盖; +本次验证范围不含 Renderer 操作和项目创建验证。 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 7a7ec948..56628ec1 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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')) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 09773725..27b1cedb 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 diff --git a/src/main/ssh/bounded-directory-sftp.test.ts b/src/main/ssh/bounded-directory-sftp.test.ts index c34348fc..94f7d9cd 100644 --- a/src/main/ssh/bounded-directory-sftp.test.ts +++ b/src/main/ssh/bounded-directory-sftp.test.ts @@ -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[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')], []] diff --git a/src/main/ssh/bounded-directory-sftp.ts b/src/main/ssh/bounded-directory-sftp.ts index 16f08f25..a37557ff 100644 --- a/src/main/ssh/bounded-directory-sftp.ts +++ b/src/main/ssh/bounded-directory-sftp.ts @@ -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 => { + 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((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) diff --git a/src/renderer/src/ProjectSwitcher.test.tsx b/src/renderer/src/ProjectSwitcher.test.tsx index a910cbc6..6d96a813 100644 --- a/src/renderer/src/ProjectSwitcher.test.tsx +++ b/src/renderer/src/ProjectSwitcher.test.tsx @@ -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' }) diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index 422a027e..e88175b3 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -934,6 +934,7 @@ function ProjectSwitcherView({ } const save = async (): Promise => { + if (dialogMode === 'create' && !draft.rootPath.trim()) return setSaving(true) setError(undefined) try { @@ -1346,7 +1347,12 @@ function ProjectSwitcherView({ {t('projectSwitcher.dialog.fields.rootPath')}
- +
+ {dialogMode === 'create' && ( + + {t('projectSwitcher.dialog.rootRequired')} + + )} void save()} type="button" diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index b45cfa36..58f4bc13 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -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: diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index dd6cd063..062e8731 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -102,6 +102,7 @@ export const workspace = { '仅应用于此项目中新建的对话,不会更改已有对话。', channelManaged: '通道项目名称由 GoodBuddy 管理。', selectRoot: '选择项目根目录', + rootRequired: '根目录为必填项,请选择目录后创建项目。', danger: { title: '危险操作', description: diff --git a/src/shared/assistant-contracts.test.ts b/src/shared/assistant-contracts.test.ts index 35490eeb..a35a2fa3 100644 --- a/src/shared/assistant-contracts.test.ts +++ b/src/shared/assistant-contracts.test.ts @@ -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({ diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 07de93a9..94bdd8ea 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -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