From 6c74f057eb35523fd9d4fd2b4c144b769942421c Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 25 Aug 2026 00:16:02 +0800 Subject: [PATCH] feat: gate remote projects as a technical preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote Host and managed SSH project entry points were always exposed while the signed artifact workflow remains preview-stage. Remote Projects now defaults off behind its own Platform Features tab, preserves saved Hosts, projects, and credentials, and returns active remote work to a local project when disabled. Main IPC enforces the same setting while still allowing in-flight cancellation. Application settings migrate to version 9 with the preview disabled. Release note: 新增“远程项目(技术预览)”独立页签和开关,默认关闭;启用后显示 SSH Host 与远程项目功能,关闭不会删除已有配置。 --- FEATURES.md | 2 +- UI-DESIGN.md | 2 +- .../remote-host-and-goodbuddy-agent-design.md | 6 + ...istant-workbar-and-execution-spaces-prd.md | 8 +- src/main/application-settings-store.test.ts | 94 +++++- src/main/application-settings-store.ts | 39 ++- src/main/ipc.test.ts | 283 +++++++++++++++++- src/main/ipc.ts | 98 ++++-- src/renderer/src/App.test.tsx | 244 ++++++++++++++- src/renderer/src/App.tsx | 72 ++++- src/renderer/src/MagicNotesWorkspace.test.tsx | 5 + .../src/PlatformFeaturesSettingsSection.tsx | 85 +++++- src/renderer/src/ProjectSwitcher.test.tsx | 126 ++++++-- src/renderer/src/ProjectSwitcher.tsx | 125 ++++++-- src/renderer/src/SettingsPanel.test.tsx | 121 +++++++- src/renderer/src/SettingsPanel.tsx | 31 +- .../src/UpdateSettingsSection.test.tsx | 1 + .../i18n/locales/en-US/settingsSections.ts | 8 + .../i18n/locales/zh-CN/settingsSections.ts | 6 + src/renderer/src/settings-categories.test.ts | 16 + src/renderer/src/settings-categories.ts | 8 + src/shared/application-settings-contracts.ts | 1 + src/shared/goodbuddy-config-contracts.test.ts | 1 + 23 files changed, 1267 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/settings-categories.test.ts diff --git a/FEATURES.md b/FEATURES.md index 47e63a03..5e1b5ced 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -23,7 +23,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 -- [x] **托管 SSH OpenCode 闭环(签名工件待发布)**:项目创建/设置可选择已保存 SSH Host、远端工作目录和 Ask/Execute;新建项目可手工输入路径,或通过 Main 管理的只读、有界 SFTP 目录选择器浏览,选择结果仍需通过正常保存验证,普通非 Git 目录也可作为 Workspace,Git 状态/差异能力仅在仓库可安全解析时启用。应用重启后固定进入项目列表中的第一个普通本地项目,不会自动激活上次远程项目;只有用户主动切换到托管 SSH 项目时才开始远端验证,相同项目的重复激活共享同一操作。Host 地址、用户或固定 Host Key 变化时会关闭旧连接并定向退役该 Host 的 Runtime 缓存;所有引用项目保持待重新激活状态,当前项目自动重新激活并刷新验证,其他项目在下次选择时刷新,重新选择当前 SSH 项目也会强制验证。失败或取消后 Composer 继续禁用,用户排队消息保留在 Main 持久队列中,直到成功激活后继续。主动切换期间侧栏显示 Host、Agent、Workspace、Runtime、Saving 五阶段进度和取消入口,并暂时禁用冲突的项目切换、创建与设置操作。Main 验证并安装签名 Agent/OpenCode Runtime,通过按需启动的 detached Agent、私有 Unix socket 和 ACP v3 保持长任务不受 SSH 抖动影响;Main 持久化单调恢复 cursor,重连后恢复同一 binding、重放未确认输出并继续同一 OpenCode Session,只有确认远端 close 后才删除恢复 identity,Agent 终态事务会清空 active journal frame。Agent、固定 Node Runtime 与 OpenCode Runtime 分别维护精确工件;Agent 升级会在签名、registry、owner/mode/size 与 digest 全部一致时复用现有 Node,否则上传候选 Node。桌面更新后用户首次打开对应托管 SSH 项目会自动升级内置 Agent/Runtime,并在完整验证成功后原子刷新项目绑定,失败时保留旧绑定。普通 Portable 携带两个 lock 与 production 公钥 registry;若本地存在 Agent 工件,构建会先严格校验再嵌入,运行时可自动安装或升级,不存在工件时仍保持 metadata-only,只复用完整验证通过的匹配组件。SSH Host 设置以只读卡片显示 Host 已安装版本与当前 GoodBuddy 所需版本,查看设置不会触发安装。Ask 使用 bubblewrap 只读 Workspace;Execute 直接使用所选 SSH 账号的完整权限,不要求额外 consent 或逐工具审批。模型凭据和 Provider URL 留在 Main;模型桥不限制 Prompt 内的模型调用轮数、工具调用次数或 Token,只保留取消、Runtime deadline、有界传输与结果不确定时禁止自动重放。Windows 到 Linux x64 的安装、metadata-only Portable 项目保存、provider-free 模型桥、断线重放、Session 续接和零残留清理已使用 production 签名工件完成真实 Host 验证;production 公钥 registry 已供应,剩余发布门槛是当前 Linux x64/arm64 正式签名工件完整矩阵与经明确授权的有界真实模型调用。 +- [x] **托管 SSH OpenCode 闭环(技术预览,签名工件待发布)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制,默认关闭。关闭时隐藏 SSH Host 管理、托管 SSH 项目创建和已保存远程项目,Main 同时拒绝新的远程操作;已有 Host、项目和凭据原样保留,关闭期间的远程激活会被取消,当前远程项目切回第一个普通本地项目。启用后,项目创建/设置可选择已保存 SSH Host、远端工作目录和 Ask/Execute;新建项目可手工输入路径,或通过 Main 管理的只读、有界 SFTP 目录选择器浏览,选择结果仍需通过正常保存验证,普通非 Git 目录也可作为 Workspace,Git 状态/差异能力仅在仓库可安全解析时启用。应用重启后固定进入项目列表中的第一个普通本地项目,不会自动激活上次远程项目;只有用户主动切换到托管 SSH 项目时才开始远端验证,相同项目的重复激活共享同一操作。Host 地址、用户或固定 Host Key 变化时会关闭旧连接并定向退役该 Host 的 Runtime 缓存;所有引用项目保持待重新激活状态,当前项目自动重新激活并刷新验证,其他项目在下次选择时刷新,重新选择当前 SSH 项目也会强制验证。失败或取消后 Composer 继续禁用,用户排队消息保留在 Main 持久队列中,直到成功激活后继续。主动切换期间侧栏显示 Host、Agent、Workspace、Runtime、Saving 五阶段进度和取消入口,并暂时禁用冲突的项目切换、创建与设置操作。Main 验证并安装签名 Agent/OpenCode Runtime,通过按需启动的 detached Agent、私有 Unix socket 和 ACP v3 保持长任务不受 SSH 抖动影响;Main 持久化单调恢复 cursor,重连后恢复同一 binding、重放未确认输出并继续同一 OpenCode Session,只有确认远端 close 后才删除恢复 identity,Agent 终态事务会清空 active journal frame。Agent、固定 Node Runtime 与 OpenCode Runtime 分别维护精确工件;Agent 升级会在签名、registry、owner/mode/size 与 digest 全部一致时复用现有 Node,否则上传候选 Node。桌面更新后用户首次打开对应托管 SSH 项目会自动升级内置 Agent/Runtime,并在完整验证成功后原子刷新项目绑定,失败时保留旧绑定。普通 Portable 携带两个 lock 与 production 公钥 registry;若本地存在 Agent 工件,构建会先严格校验再嵌入,运行时可自动安装或升级,不存在工件时仍保持 metadata-only,只复用完整验证通过的匹配组件。SSH Host 设置以只读卡片显示 Host 已安装版本与当前 GoodBuddy 所需版本,查看设置不会触发安装。Ask 使用 bubblewrap 只读 Workspace;Execute 直接使用所选 SSH 账号的完整权限,不要求额外 consent 或逐工具审批。模型凭据和 Provider URL 留在 Main;模型桥不限制 Prompt 内的模型调用轮数、工具调用次数或 Token,只保留取消、Runtime deadline、有界传输与结果不确定时禁止自动重放。Windows 到 Linux x64 的安装、metadata-only Portable 项目保存、provider-free 模型桥、断线重放、Session 续接和零残留清理已使用 production 签名工件完成真实 Host 验证;production 公钥 registry 已供应,剩余发布门槛是当前 Linux x64/arm64 正式签名工件完整矩阵与经明确授权的有界真实模型调用。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index e26ff9ea..14806d73 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -370,7 +370,7 @@ - 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。 - 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。 - 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称和项目类型,并按数据可用性补充本地目录、远程来源、平台、连接状态或其他能帮助辨认项目的信息。 -- 项目菜单当前区分“本地项目 / 远程通道”。未来出现真正的远程项目时新增独立分组,不把消息通道命名为远程项目。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。 +- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。 ### 6.12 应用通知与就地反馈 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index a70c7319..de328438 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -6,6 +6,12 @@ ## 产品语义 +远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立 +“远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存 +远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、 +项目或凭据;进行中的激活允许取消,当前远程项目切回第一个普通本地项目。应用启动仍固定 +选择普通本地项目,不会因为保存了远程项目而自动连接 Host。 + 远程项目只有两种工作模式: - **Ask**:Runtime 在操作系统边界以只读方式访问项目 Workspace。 diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 7fdb22ab..9b6ff6c2 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -475,7 +475,13 @@ type WorkbarPanelInstance = { ### 9.1 设置入口 -设置中心增加“主机与远程执行”分类,管理: +设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示 +“主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的 +SSH/远程项目操作;已有 Host、项目和凭据保持不变。若关闭时正在激活远程项目,应用发出 +取消并保持或切回普通本地项目;若当前项目已经是远程项目,则切回项目列表中的第一个普通 +本地项目。重新启用后恢复完整入口和既有数据。 + +启用后,设置中心显示“主机与远程执行”分类,管理: - 主机名称、地址、端口和用户名。 - 认证方式和凭据配置状态。 diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index a5c6329f..dc658975 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', @@ -74,16 +75,18 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 8, + version: 9, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', @@ -95,6 +98,29 @@ describe('ApplicationSettingsStore', () => { ).toEqual([]) }) + it('persists the Remote Projects preference', async () => { + const { filePath, store } = await createStore() + + await expect( + store.update({ remoteProjectsEnabled: true }) + ).resolves.toEqual({ + ...defaultApplicationSettings, + remoteProjectsEnabled: true + }) + await expect( + new ApplicationSettingsStore(filePath).get() + ).resolves.toEqual({ + ...defaultApplicationSettings, + remoteProjectsEnabled: true + }) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + version: 9, + lastSeenReleaseNotesVersion: null, + ...defaultApplicationSettings, + remoteProjectsEnabled: true + }) + }) + it('creates the parent directory and can reload persisted settings', async () => { const { directory } = await createStore() const filePath = join(directory, 'nested', 'application-settings.json') @@ -110,6 +136,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -134,6 +161,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -158,6 +186,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -182,6 +211,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', @@ -209,10 +239,11 @@ describe('ApplicationSettingsStore', () => { new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion() ).resolves.toBe('0.8.18') expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 8, + version: 9, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', @@ -240,6 +271,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', @@ -267,6 +299,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'mirror', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', @@ -279,8 +312,9 @@ describe('ApplicationSettingsStore', () => { await store.update({ modelDownloadSource: 'hugging-face' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ ...versionSix, - version: 8, + version: 9, modelDownloadSource: 'hugging-face', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true }) }) @@ -303,6 +337,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -313,7 +348,53 @@ describe('ApplicationSettingsStore', () => { ) }) + it('lazily migrates version 8 with Remote Projects disabled', async () => { + const { filePath, store } = await createStore() + const versionEight = { + version: 8, + checkUpdatesOnStartup: false, + updateSource: 'mirror', + modelDownloadSource: 'hugging-face', + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: false, + magicNoteCommentMode: 'after-save-manual', + magicNoteCommentFormat: 'narrative', + warnings: [{ code: 'application-settings-recovered' }], + lastSeenReleaseNotesVersion: '0.8.18' + } + await writeFile(filePath, JSON.stringify(versionEight), 'utf8') + + await expect(store.get()).resolves.toEqual({ + checkUpdatesOnStartup: false, + updateSource: 'mirror', + modelDownloadSource: 'hugging-face', + remoteProjectsEnabled: false, + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: false, + magicNoteCommentMode: 'after-save-manual', + magicNoteCommentFormat: 'narrative' + }) + await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe( + '0.8.18' + ) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual( + versionEight + ) + + await store.update({ checkUpdatesOnStartup: true }) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ + ...versionEight, + version: 9, + checkUpdatesOnStartup: true, + remoteProjectsEnabled: false + }) + }) + it('strictly rejects incomplete full settings', () => { + expect( + applicationSettingsSchema.safeParse(defaultApplicationSettings) + .success + ).toBe(true) for (const input of [ {}, { checkUpdatesOnStartup: 'true' }, @@ -337,6 +418,7 @@ describe('ApplicationSettingsStore', () => { {}, { checkUpdatesOnStartup: 'true' }, { modelDownloadSource: 'automatic' }, + { remoteProjectsEnabled: 'false' }, { anotherSetting: true }, null ]) { @@ -358,6 +440,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -473,16 +556,18 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 8, + version: 9, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -509,6 +594,7 @@ describe('ApplicationSettingsStore', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index c124cceb..bac86855 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -20,7 +20,7 @@ export { } from '../shared/application-settings-contracts' export type { ApplicationSettings } from '../shared/application-settings-contracts' -const CURRENT_SETTINGS_VERSION = 8 +const CURRENT_SETTINGS_VERSION = 9 const legacyStoredApplicationSettingsSchema = z .object({ @@ -50,6 +50,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, + remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ @@ -61,6 +62,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, + remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ @@ -72,6 +74,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ modelDownloadSource: true, + remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ @@ -82,13 +85,25 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema - .omit({ magicNotesShowIncompleteTodoCount: true }) + .omit({ + remoteProjectsEnabled: true, + magicNotesShowIncompleteTodoCount: true + }) .extend({ version: z.literal(7), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() +const versionEightStoredApplicationSettingsSchema = + applicationSettingsSchema + .omit({ remoteProjectsEnabled: true }) + .extend({ + version: z.literal(8), + lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() + }) + .strict() + const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ version: z.literal(CURRENT_SETTINGS_VERSION), @@ -104,6 +119,7 @@ export const defaultApplicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -161,12 +177,23 @@ export class ApplicationSettingsStore { ) const result = storedApplicationSettingsSchema.safeParse(parsed) if (!result.success) { + const versionEightResult = + versionEightStoredApplicationSettingsSchema.safeParse(parsed) + if (versionEightResult.success) { + this.settings = { + ...versionEightResult.data, + version: CURRENT_SETTINGS_VERSION, + remoteProjectsEnabled: false + } + return this.settings + } const versionSevenResult = versionSevenStoredApplicationSettingsSchema.safeParse(parsed) if (versionSevenResult.success) { this.settings = { ...versionSevenResult.data, version: CURRENT_SETTINGS_VERSION, + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true } return this.settings @@ -178,6 +205,7 @@ export class ApplicationSettingsStore { ...versionSixResult.data, version: CURRENT_SETTINGS_VERSION, modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true } return this.settings @@ -190,6 +218,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true } return this.settings @@ -202,6 +231,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, lastSeenReleaseNotesVersion: null } @@ -215,6 +245,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null @@ -229,6 +260,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', @@ -245,6 +277,7 @@ export class ApplicationSettingsStore { legacyResult.data.checkUpdatesOnStartup, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -287,6 +320,7 @@ export class ApplicationSettingsStore { checkUpdatesOnStartup: stored.checkUpdatesOnStartup, updateSource: stored.updateSource, modelDownloadSource: stored.modelDownloadSource, + remoteProjectsEnabled: stored.remoteProjectsEnabled, magicNotesEnabled: stored.magicNotesEnabled, magicNotesShowIncompleteTodoCount: stored.magicNotesShowIncompleteTodoCount, @@ -322,6 +356,7 @@ export class ApplicationSettingsStore { checkUpdatesOnStartup: next.checkUpdatesOnStartup, updateSource: next.updateSource, modelDownloadSource: next.modelDownloadSource, + remoteProjectsEnabled: next.remoteProjectsEnabled, magicNotesEnabled: next.magicNotesEnabled, magicNotesShowIncompleteTodoCount: next.magicNotesShowIncompleteTodoCount, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 426c632d..1e65d94a 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1086,7 +1086,9 @@ describe('registerIpcHandlers SSH hosts', () => { undefined, // browserControl undefined, // subagentService undefined, // channelSettingsStore - undefined, // applicationSettingsStore + { + get: vi.fn(async () => ({ remoteProjectsEnabled: true })) + } as never, // applicationSettingsStore undefined, // versionChecker undefined, // speechModelManager undefined, // embeddingIndexCoordinator @@ -1167,11 +1169,11 @@ describe('registerIpcHandlers SSH hosts', () => { name: 'Build host' } }) - expect( + await expect( electronMocks.handlers.get( ipcChannels.sshHostsDiscardCandidate )?.(event, { candidateId }) - ).toBeUndefined() + ).resolves.toBeUndefined() expect(sshHostService.discardCandidate).toHaveBeenCalledWith( candidateId ) @@ -1184,12 +1186,12 @@ describe('registerIpcHandlers SSH hosts', () => { listProjectIdsReferencingSshHost.mockReturnValue([ '00000000-0000-4000-8000-000000000107' ]) - expect(() => + await expect( electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( event, { hostId } ) - ).toThrow('SSH 主机仍被项目引用,无法删除') + ).rejects.toThrow('SSH 主机仍被项目引用,无法删除') expect(sshHostService.remove).toHaveBeenCalledOnce() await expect( @@ -1205,7 +1207,7 @@ describe('registerIpcHandlers SSH hosts', () => { ).rejects.toThrow() expect(inspectRemoteEnvironment).toHaveBeenCalledOnce() - expect(() => + await expect( electronMocks.handlers.get( ipcChannels.sshHostsValidateAndSave )?.(event, { @@ -1213,14 +1215,14 @@ describe('registerIpcHandlers SSH hosts', () => { fingerprintSha256: fingerprint, input: { ...createInput, command: 'whoami' } }) - ).toThrow() + ).rejects.toThrow() expect(sshHostService.validateAndSave).toHaveBeenCalledOnce() - expect(() => + await expect( electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({ sender: {}, senderFrame: webContents.mainFrame }) - ).toThrow('拒绝来自未知窗口的 IPC 请求') + ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get( @@ -1274,11 +1276,11 @@ describe('registerIpcHandlers SSH hosts', () => { await vi.waitFor(() => expect(browseSignal).toBeInstanceOf(AbortSignal) ) - expect( + await expect( electronMocks.handlers.get( ipcChannels.sshHostsCancelDirectoryBrowse )?.(event) - ).toBeUndefined() + ).resolves.toBeUndefined() await expect(activeBrowse).rejects.toMatchObject({ name: 'AbortError' }) @@ -1312,11 +1314,11 @@ describe('registerIpcHandlers SSH hosts', () => { webContents, remoteDraft ) - expect( + await expect( electronMocks.handlers.get( ipcChannels.remoteProjectCancelCurrent )?.(event) - ).toBeUndefined() + ).resolves.toBeUndefined() expect( remoteProjectSaveService.cancelCurrent ).toHaveBeenCalledWith(webContents) @@ -1349,6 +1351,241 @@ describe('registerIpcHandlers SSH hosts', () => { expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce() }) + it('blocks disabled and unavailable preview access before remote services', async () => { + const hostId = '00000000-0000-4000-8000-000000000105' + const candidateId = '00000000-0000-4000-8000-000000000106' + const projectId = '00000000-0000-4000-8000-000000000107' + const fingerprint = `SHA256:${'A'.repeat(43)}` + const remoteProject = { + id: projectId, + name: 'Remote project', + description: '', + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh' as const, + hostId, + remoteRootPath: '/srv/project' + }, + defaultWorkMode: 'ask' as const, + kind: 'user' as const, + status: 'active' as const, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z' + } + const applicationSettingsStore = { + get: vi.fn(async () => ({ remoteProjectsEnabled: false })) + } + const sshHostService = { + getSnapshot: vi.fn(), + remove: vi.fn(), + inspectDraftHostKey: vi.fn(), + discardCandidate: vi.fn(), + validateAndSave: vi.fn() + } + const remoteProjectSaveService = { + activate: vi.fn(), + save: vi.fn(), + cancelCurrent: vi.fn(), + dispose: vi.fn(async () => undefined) + } + const sshHostDirectoryBrowser = { + listDirectories: vi.fn() + } + const sshHostRemoteEnvironmentInspector = { + inspect: vi.fn() + } + const executionSpaceResolver = { + resolveProject: vi.fn() + } + const assistantDatabase = { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + listProjectIdsReferencingSshHost: vi.fn(() => []), + getProject: vi.fn(() => remoteProject), + updateProject: vi.fn(), + setProjectArchived: vi.fn(), + deleteProject: vi.fn() + } + const webContents = { + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + assistantDatabase as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => undefined), + undefined, + undefined, + undefined, + undefined, + applicationSettingsStore as never, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + sshHostService as never, + executionSpaceResolver as never, + remoteProjectSaveService as never, + sshHostDirectoryBrowser as never, + sshHostRemoteEnvironmentInspector as never + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + const createInput = { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + } + const remoteDraft = { + intent: 'create', + draft: { + name: remoteProject.name, + description: '', + runtimeSelection: { provider: 'opencode' }, + hostId, + remoteRootPath: '/srv/project', + defaultWorkMode: 'ask' + } + } + const disabledRequests: Array<[string, unknown?]> = [ + [ipcChannels.sshHostsGet], + [ipcChannels.sshHostsRemove, { hostId }], + [ + ipcChannels.sshHostsInspectDraftKey, + { + hostId, + hostname: createInput.hostname, + port: createInput.port, + username: createInput.username + } + ], + [ipcChannels.sshHostsDiscardCandidate, { candidateId }], + [ + ipcChannels.sshHostsValidateAndSave, + { candidateId, fingerprintSha256: fingerprint, input: createInput } + ], + [ipcChannels.sshHostsRemoteEnvironment, { hostId }], + [ + ipcChannels.sshHostsBrowseDirectories, + { hostId, path: '/srv' } + ], + [ipcChannels.remoteProjectActivate, projectId], + [ipcChannels.remoteProjectSave, remoteDraft] + ] + for (const [channel, input] of disabledRequests) { + await expect( + electronMocks.handlers.get(channel)?.(event, input) + ).rejects.toThrow('远程项目(技术预览)未启用') + } + + await expect( + electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, { + projectId, + input: { + name: remoteProject.name, + description: '', + rootPath: remoteProject.rootPath, + defaultWorkMode: 'ask' + } + }) + ).rejects.toThrow('远程项目(技术预览)未启用') + await expect( + electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.( + event, + { projectId, archived: true } + ) + ).rejects.toThrow('远程项目(技术预览)未启用') + await expect( + electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, { + projectId, + confirmation: remoteProject.name + }) + ).rejects.toThrow('远程项目(技术预览)未启用') + await expect( + electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.( + event, + { projectId, path: 'README.md' } + ) + ).rejects.toThrow('远程项目(技术预览)未启用') + + expect(sshHostService.getSnapshot).not.toHaveBeenCalled() + expect(sshHostService.remove).not.toHaveBeenCalled() + expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled() + expect(sshHostService.discardCandidate).not.toHaveBeenCalled() + expect(sshHostService.validateAndSave).not.toHaveBeenCalled() + expect( + sshHostRemoteEnvironmentInspector.inspect + ).not.toHaveBeenCalled() + expect( + sshHostDirectoryBrowser.listDirectories + ).not.toHaveBeenCalled() + expect(remoteProjectSaveService.activate).not.toHaveBeenCalled() + expect(remoteProjectSaveService.save).not.toHaveBeenCalled() + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsCancelDirectoryBrowse + )?.(event) + ).resolves.toBeUndefined() + await expect( + electronMocks.handlers.get( + ipcChannels.remoteProjectCancelCurrent + )?.(event) + ).resolves.toBeUndefined() + expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith( + webContents + ) + expect( + assistantDatabase.listProjectIdsReferencingSshHost + ).not.toHaveBeenCalled() + expect(assistantDatabase.updateProject).not.toHaveBeenCalled() + expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled() + expect(assistantDatabase.deleteProject).not.toHaveBeenCalled() + expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled() + + applicationSettingsStore.get.mockRejectedValueOnce( + new Error('settings unavailable') + ) + await expect( + electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event) + ).rejects.toThrow('远程项目(技术预览)未启用') + expect(sshHostService.getSnapshot).not.toHaveBeenCalled() + + await dispose() + }) + it('sends save progress only to a live owning WebContents', () => { const owner = { isDestroyed: vi.fn(() => false), @@ -3096,7 +3333,16 @@ describe('registerIpcHandlers workspace files', () => { assistantDatabase as never, { clear: vi.fn() } as never, {} as never, - vi.fn(async () => {}) + vi.fn(async () => {}), + undefined, + undefined, + undefined, + undefined, + { + get: vi.fn(async () => { + throw new Error('settings unavailable') + }) + } as never ) const event = { sender: webContents, @@ -3175,7 +3421,14 @@ describe('registerIpcHandlers workspace files', () => { assistantDatabase as never, { clear: vi.fn() } as never, {} as never, - vi.fn(async () => {}) + vi.fn(async () => {}), + undefined, + undefined, + undefined, + undefined, + { + get: vi.fn(async () => ({ remoteProjectsEnabled: true })) + } as never ) const event = { sender: webContents, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4a62d8e5..b02e4752 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1040,6 +1040,19 @@ export function registerIpcHandlers( const trackExecution = executionTracker.track const spaceResolver: ExecutionSpaceResolver = executionSpaceResolver ?? new ExecutionSpaceResolver() + const requireRemoteProjectsEnabled = async (): Promise => { + try { + if ( + (await applicationSettingsStore?.get()) + ?.remoteProjectsEnabled === true + ) { + return + } + } catch { + // Keep the feature closed when its settings cannot be read. + } + throw new Error('远程项目(技术预览)未启用') + } const registerHandler = ( channel: Parameters[0], listener: Parameters[1], @@ -1063,10 +1076,14 @@ export function registerIpcHandlers( followConfiguredAgentRuntime?: boolean } ): Promise => { - const executionSpace = request.projectId - ? spaceResolver.resolveProject( - assistantDatabase.getProject(request.projectId) - ) + const project = request.projectId + ? assistantDatabase.getProject(request.projectId) + : undefined + if (project?.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } + const executionSpace = project + ? spaceResolver.resolveProject(project) : request.workspaceOverride?.trim() ? spaceResolver.resolveLocal(request.workspaceOverride.trim()) : undefined @@ -3648,10 +3665,14 @@ export function registerIpcHandlers( settings, request.runtimeSelection ) - const executionSpace = request.projectId - ? spaceResolver.resolveProject( - assistantDatabase.getProject(request.projectId) - ) + const project = request.projectId + ? assistantDatabase.getProject(request.projectId) + : undefined + if (project?.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } + const executionSpace = project + ? spaceResolver.resolveProject(project) : spaceResolver.resolveLocal(selected.settings.workspacePath) if (executionSpace.kind === 'ssh') { throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) @@ -3816,10 +3837,14 @@ export function registerIpcHandlers( ? { profileId: request.profileId } : {}) } - const executionSpace = request.projectId - ? spaceResolver.resolveProject( - assistantDatabase.getProject(request.projectId) - ) + const project = request.projectId + ? assistantDatabase.getProject(request.projectId) + : undefined + if (project?.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } + const executionSpace = project + ? spaceResolver.resolveProject(project) : spaceResolver.resolveLocal( (await settingsStore.getResolvedSettings()).workspacePath ) @@ -4047,8 +4072,9 @@ export function registerIpcHandlers( } ) - registerHandler(ipcChannels.sshHostsGet, (event) => { + registerHandler(ipcChannels.sshHostsGet, async (event) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } @@ -4057,8 +4083,9 @@ export function registerIpcHandlers( registerHandler( ipcChannels.sshHostsRemove, - (event, input: unknown) => { + async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } @@ -4074,8 +4101,9 @@ export function registerIpcHandlers( registerHandler( ipcChannels.sshHostsInspectDraftKey, - (event, input: unknown) => { + async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } @@ -4087,8 +4115,9 @@ export function registerIpcHandlers( registerHandler( ipcChannels.sshHostsDiscardCandidate, - (event, input: unknown) => { + async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } @@ -4100,8 +4129,9 @@ export function registerIpcHandlers( registerHandler( ipcChannels.sshHostsValidateAndSave, - (event, input: unknown) => { + async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } @@ -4115,6 +4145,7 @@ export function registerIpcHandlers( ipcChannels.sshHostsRemoteEnvironment, async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostRemoteEnvironmentInspector) { throw new Error('SSH 远端运行环境服务不可用') } @@ -4129,6 +4160,7 @@ export function registerIpcHandlers( ipcChannels.sshHostsBrowseDirectories, async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!sshHostDirectoryBrowser) { throw new Error('SSH 远端目录浏览服务不可用') } @@ -4159,7 +4191,7 @@ export function registerIpcHandlers( registerHandler( ipcChannels.sshHostsCancelDirectoryBrowse, - (event) => { + async (event) => { assertTrustedSender(event, window) activeSshDirectoryBrowse?.abort( new DOMException( @@ -4972,6 +5004,7 @@ export function registerIpcHandlers( ipcChannels.remoteProjectActivate, async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!remoteProjectSaveService) { throw new Error('远程项目验证服务不可用') } @@ -4987,6 +5020,7 @@ export function registerIpcHandlers( ipcChannels.remoteProjectSave, async (event, input: unknown) => { assertTrustedSender(event, window) + await requireRemoteProjectsEnabled() if (!remoteProjectSaveService) { throw new Error('远程项目验证服务不可用') } @@ -5000,7 +5034,7 @@ export function registerIpcHandlers( registerHandler( ipcChannels.remoteProjectCancelCurrent, - (event) => { + async (event) => { assertTrustedSender(event, window) if (!remoteProjectSaveService) { throw new Error('远程项目验证服务不可用') @@ -5014,6 +5048,10 @@ export function registerIpcHandlers( async (event, input: unknown) => { assertTrustedSender(event, window) const value = projectUpdateRequestSchema.parse(input) + const current = assistantDatabase.getProject(value.projectId) + if (current.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } const project = assistantDatabase.updateProject( value.projectId, value.input @@ -5025,9 +5063,13 @@ export function registerIpcHandlers( registerHandler( ipcChannels.projectsSetArchived, - (event, input: unknown) => { + async (event, input: unknown) => { assertTrustedSender(event, window) const value = projectArchiveRequestSchema.parse(input) + const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } assistantDatabase.setProjectArchived( value.projectId, value.archived @@ -5039,6 +5081,10 @@ export function registerIpcHandlers( async (event, input: unknown) => { assertTrustedSender(event, window) const value = projectDeleteRequestSchema.parse(input) + const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } assistantDatabase.deleteProject( value.projectId, value.confirmation @@ -5281,6 +5327,9 @@ export function registerIpcHandlers( const project = assistantDatabase.getProject( assistantIdSchema.parse(input) ) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } const executionSpace = spaceResolver.resolveProject(project) try { return await getWorkspaceChanges( @@ -5297,6 +5346,9 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceDirectoryRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } const executionSpace = spaceResolver.resolveProject(project) try { return await listWorkspaceDirectory( @@ -5314,6 +5366,9 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceFileRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } const executionSpace = spaceResolver.resolveProject(project) try { return await readWorkspaceFile( @@ -5331,6 +5386,9 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceOpenPathRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } const executionSpace = spaceResolver.resolveProject(project) spaceResolver.assertLocal(executionSpace) let targetPath: string diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e7749964..1ed7093d 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -899,6 +899,35 @@ function deferred(): { return { promise, resolve, reject } } +function installRemoteProjectsSetting(enabled: boolean): { + updateSettings: NonNullable['updateSettings'] +} { + let settings: ApplicationSettings = { + checkUpdatesOnStartup: false, + updateSource: 'github', + modelDownloadSource: 'modelscope', + remoteProjectsEnabled: enabled, + magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, + magicNoteCommentMode: 'immediate', + magicNoteCommentFormat: 'combined' + } + const updateSettings = vi.fn< + NonNullable['updateSettings'] + >(async (input) => { + settings = { ...settings, ...input } + return { ...settings } + }) + api.updates = { + getSettings: vi.fn(async () => ({ ...settings })), + updateSettings, + check: vi.fn(), + openReleasePage: vi.fn(), + onResult: vi.fn(() => () => {}) + } + return { updateSettings } +} + describe('App', () => { beforeEach(() => { localStorage.clear() @@ -1008,6 +1037,7 @@ describe('App', () => { vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]) vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]) api.channels = undefined + api.updates = undefined newConversationListener = undefined beforeQuitListener = undefined browserListener = undefined @@ -1930,6 +1960,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -2024,6 +2055,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -2033,6 +2065,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -2128,6 +2161,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -2137,6 +2171,7 @@ describe('App', () => { checkUpdatesOnStartup: true, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -3973,6 +4008,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -5592,8 +5628,19 @@ describe('App', () => { await waitFor(() => expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce() ) + const projectSaveIndex = vi + .mocked(api.conversations.saveLocal) + .mock.calls.findIndex(([batch]) => + batch.some( + (conversation) => + conversation.header.projectId === secondProject.id + ) + ) + expect(projectSaveIndex).toBeGreaterThanOrEqual(0) expect( - vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1) + vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder[ + projectSaveIndex + ] ).toBeLessThan( vi.mocked( api.conversationQueue.enqueueUser @@ -5637,7 +5684,169 @@ describe('App', () => { ) }) + it('keeps saved remote projects hidden while the preview is disabled', async () => { + installRemoteProjectsSetting(false) + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000116', + name: '已保存远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000216', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + + render() + + const trigger = await screen.findByRole('button', { + name: '当前项目' + }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu', { name: '当前项目' }) + expect( + within(menu).queryByText(remoteProject.name) + ).not.toBeInTheDocument() + expect(api.projects.remote.activate).not.toHaveBeenCalled() + }) + + it('falls back to a local project when the remote preview is disabled', async () => { + const { updateSettings } = installRemoteProjectsSetting(true) + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000117', + name: '预览远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000217', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockResolvedValueOnce( + remoteProject + ) + + render() + await screen.findByRole('button', { name: '当前项目' }) + selectProjectOption(remoteProject.name) + await waitFor(() => + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(remoteProject.name) + ) + + fireEvent.click(screen.getByText('本地工作区')) + fireEvent.click( + await screen.findByRole('tab', { name: '平台功能' }) + ) + fireEvent.click( + await screen.findByRole('tab', { + name: '远程项目(技术预览)' + }) + ) + const remoteProjectsSwitch = await screen.findByRole('switch', { + name: '远程项目(技术预览)' + }) + expect(remoteProjectsSwitch).toBeChecked() + fireEvent.click(remoteProjectsSwitch) + + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ + remoteProjectsEnabled: false + }) + ) + await waitFor(() => + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + ) + fireEvent.click( + screen.getByRole('button', { name: '当前项目' }) + ) + expect( + screen.queryByText(remoteProject.name) + ).not.toBeInTheDocument() + expect(api.projects.setArchived).not.toHaveBeenCalled() + expect(api.projects.delete).not.toHaveBeenCalled() + }) + + it('cancels pending remote activation when the preview is disabled', async () => { + const { updateSettings } = installRemoteProjectsSetting(true) + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000118', + name: '连接中的远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000218', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + const activation = deferred() + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockReturnValueOnce( + activation.promise + ) + + render() + await screen.findByRole('button', { name: '当前项目' }) + selectProjectOption(remoteProject.name) + await screen.findByRole('progressbar', { + name: '远程项目连接进度' + }) + + fireEvent.click(screen.getByText('本地工作区')) + fireEvent.click( + await screen.findByRole('tab', { name: '平台功能' }) + ) + fireEvent.click( + await screen.findByRole('tab', { + name: '远程项目(技术预览)' + }) + ) + fireEvent.click( + await screen.findByRole('switch', { + name: '远程项目(技术预览)' + }) + ) + + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ + remoteProjectsEnabled: false + }) + ) + expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce() + await act(async () => { + activation.resolve(remoteProject) + await activation.promise + }) + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + }) + it('keeps the current project active when remote activation fails', async () => { + installRemoteProjectsSetting(true) const remoteProject = { ...project, id: '00000000-0000-4000-8000-000000000104', @@ -5659,9 +5868,11 @@ describe('App', () => { new Error('Agent upload failed') ) render() - expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) + await waitFor(() => + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + ) selectProjectOption(remoteProject.name) @@ -5676,6 +5887,7 @@ describe('App', () => { }) it('revalidates the current SSH project when it is selected again', async () => { + installRemoteProjectsSetting(true) const remoteProject = { ...project, id: '00000000-0000-4000-8000-000000000114', @@ -5718,6 +5930,7 @@ describe('App', () => { }) it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => { + installRemoteProjectsSetting(true) const hostId = '00000000-0000-4000-8000-000000000215' const candidateId = '00000000-0000-4000-8000-000000000315' @@ -5967,6 +6180,7 @@ describe('App', () => { }) it('shows every direct remote activation phase and blocks conflicting project actions', async () => { + installRemoteProjectsSetting(true) const remoteProject = { ...project, id: '00000000-0000-4000-8000-000000000105', @@ -6041,6 +6255,7 @@ describe('App', () => { }) it('cancels direct remote activation without switching projects', async () => { + installRemoteProjectsSetting(true) const remoteProject = { ...project, id: '00000000-0000-4000-8000-000000000106', @@ -8111,6 +8326,19 @@ describe('App', () => { }) it('edits and safely deletes the current project from project settings', async () => { + const hiddenRemoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000119', + name: '隐藏远程项目', + rootPath: '/srv/hidden', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000219', + remoteRootPath: '/srv/hidden' + }, + builtInDefault: false + } const secondProject = { ...project, id: '00000000-0000-4000-8000-000000000102', @@ -8122,6 +8350,7 @@ describe('App', () => { } } vi.mocked(api.projects.list).mockResolvedValueOnce([ + hiddenRemoteProject, project, secondProject ]) @@ -9309,6 +9538,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -9318,6 +9548,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -9363,6 +9594,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -9415,6 +9647,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate' as const, @@ -9446,6 +9679,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -9455,6 +9689,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, @@ -9489,6 +9724,7 @@ describe('App', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 79ab9112..d1c567ee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1421,6 +1421,15 @@ function getProjectDefaultRuntimeSelection( : selection } +function isOrdinaryLocalProject( + project: AssistantProject +): boolean { + return ( + project.kind === 'user' && + project.executionSpace.kind === 'local' + ) +} + function resolveContextMetricsRuntimeSelection( selection: AgentRuntimeSelection, settings: RuntimeSettings @@ -2322,6 +2331,8 @@ function App(): React.JSX.Element { useState() const [settingsInitialChannel, setSettingsInitialChannel] = useState() + const [remoteProjectsEnabled, setRemoteProjectsEnabled] = + useState(false) const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) const [ magicNotesShowIncompleteTodoCount, @@ -2728,6 +2739,7 @@ function App(): React.JSX.Element { void updates .getSettings() .then(async (settings) => { + setRemoteProjectsEnabled(settings.remoteProjectsEnabled) setMagicNotesEnabled(settings.magicNotesEnabled) setMagicNotesShowIncompleteTodoCount( settings.magicNotesShowIncompleteTodoCount @@ -4843,6 +4855,12 @@ function App(): React.JSX.Element { project: AssistantProject current: boolean }> => { + if ( + !remoteProjectsEnabled || + project.executionSpace.kind !== 'ssh' + ) { + return { project, current: false } + } const requestId = remoteProjectActivationRequestRef.current + 1 remoteProjectActivationRequestRef.current = requestId const control = { @@ -4938,7 +4956,7 @@ function App(): React.JSX.Element { } } }, - [notify] + [notify, remoteProjectsEnabled] ) const cancelRemoteProjectActivation = useCallback((): void => { @@ -5044,6 +5062,9 @@ function App(): React.JSX.Element { const handleSshHostUpdated = useCallback( (hostId: string): void => { + if (!remoteProjectsEnabled) { + return + } const affectedProjects = projects.filter( (candidate) => candidate.executionSpace.kind === 'ssh' && @@ -5085,6 +5106,7 @@ function App(): React.JSX.Element { markRemoteProjectsRequiringActivation, notify, projects, + remoteProjectsEnabled, resumeProjectConversationQueues ] ) @@ -5100,11 +5122,7 @@ function App(): React.JSX.Element { return } setProjects(value) - const project = value.find( - (candidate) => - candidate.kind === 'user' && - candidate.executionSpace.kind === 'local' - ) + const project = value.find(isOrdinaryLocalProject) if (!project) { throw new Error('没有可用的本地项目') } @@ -5806,6 +5824,9 @@ function App(): React.JSX.Element { commitProjectSelection(project) return } + if (!remoteProjectsEnabled) { + return + } void activateRemoteProject(project).then( (activated) => { if (activated.current) { @@ -5871,6 +5892,12 @@ function App(): React.JSX.Element { const loadCommittedRemoteProject = async ( project: AssistantProject ): Promise => { + if ( + !remoteProjectsEnabled || + project.executionSpace.kind !== 'ssh' + ) { + return + } setProjects((current) => current.some((candidate) => candidate.id === project.id) ? current.map((candidate) => @@ -5884,12 +5911,34 @@ function App(): React.JSX.Element { resumeProjectConversationQueues(project.id) } + const handleRemoteProjectsEnabledChange = ( + enabled: boolean + ): void => { + setRemoteProjectsEnabled(enabled) + if (enabled) { + return + } + remoteProjectActivationRequestRef.current += 1 + if (remoteProjectActivationControlRef.current) { + cancelRemoteProjectActivation() + } + if (activeProject?.executionSpace.kind !== 'ssh') { + return + } + const localProject = projects.find(isOrdinaryLocalProject) + if (localProject) { + commitProjectSelection(localProject) + } + } + const archiveProject = async (projectId: string): Promise => { await window.goodbuddy.projects.setArchived(projectId, true) clearRemoteProjectActivationRequirement(projectId) const remaining = projects.filter((project) => project.id !== projectId) setProjects(remaining) - const next = remaining[0] + const next = remoteProjectsEnabled + ? remaining[0] + : remaining.find(isOrdinaryLocalProject) if (next) { selectProject(next.id) } @@ -5940,7 +5989,9 @@ function App(): React.JSX.Element { : [] }) ) - const next = remainingProjects[0] + const next = remoteProjectsEnabled + ? remainingProjects[0] + : remainingProjects.find(isOrdinaryLocalProject) if (next) { setActiveProjectId(next.id) setWorkMode( @@ -7839,6 +7890,7 @@ function App(): React.JSX.Element { onUpdate={updateProject} projects={projects} remoteActivation={remoteProjectActivation} + remoteProjectsEnabled={remoteProjectsEnabled} /> {activeProject?.kind !== 'channel' && ( @@ -10013,6 +10065,7 @@ function App(): React.JSX.Element { initialCategory={settingsInitialCategory} initialChannel={settingsInitialChannel} magicNotesEnabled={magicNotesEnabled} + remoteProjectsEnabled={remoteProjectsEnabled} onAppearanceThemeChange={setAppearanceTheme} onClearLocalData={clearLocalData} onClose={() => { @@ -10047,6 +10100,9 @@ function App(): React.JSX.Element { } } } + onRemoteProjectsEnabledChange={ + handleRemoteProjectsEnabledChange + } onNotify={notify} onLeaveRequestReady={registerSettingsLeaveRequester} onSshHostUpdated={handleSshHostUpdated} diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index b2d1bcbf..44f932a6 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -189,6 +189,7 @@ const getApplicationSettings = vi.fn<() => Promise>(async ( checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -202,6 +203,7 @@ beforeEach(() => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -1070,6 +1072,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', @@ -1103,6 +1106,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', @@ -1219,6 +1223,7 @@ describe('MagicNotesWorkspace', () => { checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index d0e879a9..e328c17e 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -28,6 +28,7 @@ type PlatformFeaturesSettingsSectionProps = { onMagicNotesShowIncompleteTodoCountChange: ( enabled: boolean ) => void + onRemoteProjectsEnabledChange: (enabled: boolean) => void onNotify?: (notification: AppNotificationInput) => void onDirtyChange?: (dirty: boolean) => void onShortcutSettingsChanged?: ( @@ -35,7 +36,10 @@ type PlatformFeaturesSettingsSectionProps = { ) => void } -type PlatformFeaturesTab = 'general' | 'magic-notes' +type PlatformFeaturesTab = + | 'general' + | 'remote-projects' + | 'magic-notes' const shortcutErrorTranslationKeys: Record< GlobalShortcutUpdateErrorCode, @@ -52,6 +56,7 @@ const shortcutErrorTranslationKeys: Record< export function PlatformFeaturesSettingsSection({ onMagicNotesEnabledChange, onMagicNotesShowIncompleteTodoCountChange, + onRemoteProjectsEnabledChange, onNotify, onDirtyChange, onShortcutSettingsChanged @@ -296,6 +301,32 @@ export function PlatformFeaturesSettingsSection({ } } + const changeRemoteProjects = async ( + enabled: boolean + ): Promise => { + const updates = window.goodbuddy.updates + if (!updates || !settings) { + return + } + setSaving(true) + setError(undefined) + try { + const nextSettings = await updates.updateSettings({ + remoteProjectsEnabled: enabled + }) + setSettings(nextSettings) + onRemoteProjectsEnabledChange( + nextSettings.remoteProjectsEnabled + ) + } catch { + setError( + t('platformFeatures.errors.saveRemoteProjectsFailed') + ) + } finally { + setSaving(false) + } + } + const changeCommentMode = async ( magicNoteCommentMode: MagicNoteCommentMode ): Promise => { @@ -380,6 +411,10 @@ export function PlatformFeaturesSettingsSection({ id: 'general', label: t('platformFeatures.tabs.general') }, + { + id: 'remote-projects', + label: t('platformFeatures.remoteProjects.title') + }, { id: 'magic-notes', label: t('platformFeatures.tabs.magicNotes') @@ -568,6 +603,54 @@ export function PlatformFeaturesSettingsSection({ )} + +
{ }) function renderSwitcher( - currentProject: AssistantProject = project + currentProject: AssistantProject = project, + { + projects = [currentProject], + remoteProjectsEnabled = true + }: { + projects?: AssistantProject[] + remoteProjectsEnabled?: boolean + } = {} ): { onCreate: ReturnType onRemoteCommitted: ReturnType @@ -96,7 +103,8 @@ function renderSwitcher( onSelect={vi.fn()} onSelectRoot={onSelectRoot} onUpdate={onUpdate} - projects={[currentProject]} + projects={projects} + remoteProjectsEnabled={remoteProjectsEnabled} runtimeSettings={runtimeSettings} /> ) @@ -154,39 +162,43 @@ function installRemoteApi() { ]) ) const cancelDirectoryBrowse = vi.fn(async () => undefined) + const getSnapshot = vi.fn(async () => ({ + hosts: [ + { + id: hostId, + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent' as const, + credentialConfigured: true, + credentialSource: 'system-agent' as const, + hostKey: { state: 'verified' as const, generation: 1 }, + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z' + } + ], + secureStorageAvailable: true + })) + const onSaveProgress = vi.fn( + (listener: (value: { phase: 'host' | 'agent' }) => void) => { + progress = listener + return vi.fn() + } + ) Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { sshHosts: { browseDirectories, cancelDirectoryBrowse, - getSnapshot: vi.fn(async () => ({ - hosts: [ - { - id: hostId, - name: 'Build host', - hostname: 'build.example.com', - port: 22, - username: 'builder', - authentication: 'system-agent', - credentialConfigured: true, - credentialSource: 'system-agent', - hostKey: { state: 'verified', generation: 1 }, - createdAt: '2026-08-04T00:00:00.000Z', - updatedAt: '2026-08-04T00:00:00.000Z' - } - ], - secureStorageAvailable: true - })) + getSnapshot }, projects: { remote: { save, cancelCurrent, - onSaveProgress: vi.fn((listener) => { - progress = listener - return vi.fn() - }) + onSaveProgress } } } @@ -196,6 +208,8 @@ function installRemoteApi() { cancelCurrent, browseDirectories, cancelDirectoryBrowse, + getSnapshot, + onSaveProgress, emit: (phase: 'host' | 'agent') => progress?.({ phase }) } } @@ -308,6 +322,70 @@ describe('ProjectSwitcher runtime fields', () => { }) describe('ProjectSwitcher managed SSH projects', () => { + it('hides remote projects and APIs when the feature is disabled', async () => { + await i18n.changeLanguage('en-US') + const api = installRemoteApi() + const remoteProject: AssistantProject = { + ...project, + id: remoteProjectId, + name: 'Remote project', + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: '/srv/project' + }, + runtimeSelection: { provider: 'opencode' } + } + renderSwitcher(project, { + projects: [project, remoteProject], + remoteProjectsEnabled: false + }) + + fireEvent.click(screen.getByLabelText('Current project')) + const menu = screen.getByRole('menu', { name: 'Current project' }) + expect( + within(menu).queryByRole('group', { name: 'Remote projects' }) + ).not.toBeInTheDocument() + expect(within(menu).queryByText('Remote project')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('New project')) + const dialog = screen.getByRole('dialog', { name: 'New project' }) + expect( + within(dialog).queryByRole('button', { name: 'Managed SSH' }) + ).not.toBeInTheDocument() + expect(api.getSnapshot).not.toHaveBeenCalled() + expect(api.onSaveProgress).not.toHaveBeenCalled() + expect(api.save).not.toHaveBeenCalled() + }) + + it('refuses settings for a saved SSH project while disabled', () => { + const api = installRemoteApi() + const remoteProject: AssistantProject = { + ...project, + id: remoteProjectId, + name: 'Remote project', + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: '/srv/project' + }, + runtimeSelection: { provider: 'opencode' } + } + renderSwitcher(remoteProject, { remoteProjectsEnabled: false }) + + const settings = screen.getByLabelText('项目设置') + expect(settings).toBeDisabled() + fireEvent.click(settings) + expect( + screen.queryByRole('dialog', { name: '项目设置' }) + ).not.toBeInTheDocument() + expect(api.getSnapshot).not.toHaveBeenCalled() + expect(api.onSaveProgress).not.toHaveBeenCalled() + expect(api.save).not.toHaveBeenCalled() + }) + it('uses remote work directory copy and opens from a valid typed path', async () => { await i18n.changeLanguage('en-US') const api = installRemoteApi() diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index 416d3db9..92d0d8c8 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -49,6 +49,7 @@ import { sshHostErrorMessage } from './ssh-host-ui' type ProjectSwitcherProps = { projects: AssistantProject[] activeProjectId: string + remoteProjectsEnabled?: boolean remoteActivation?: { projectId: string phase: RemoteProjectSavePhase @@ -173,6 +174,7 @@ function isRemoteAbsolutePath(value: string): boolean { export function ProjectSwitcher({ projects, activeProjectId, + remoteProjectsEnabled = false, remoteActivation, runtimeSettings, onArchive, @@ -249,13 +251,16 @@ export function ProjectSwitcher({ const channelProjects = projects.filter( (project) => project.kind === 'channel' ) - const userProjects = [...localProjects, ...remoteProjects] + const userProjects = [ + ...localProjects, + ...(remoteProjectsEnabled ? remoteProjects : []) + ] const busy = saving || remoteSaving || archiving || deleting || - remoteActivation !== undefined + (remoteProjectsEnabled && remoteActivation !== undefined) const remoteFieldsDisabled = busy const remoteDirectoryBrowseDisabled = remoteFieldsDisabled || @@ -278,7 +283,11 @@ export function ProjectSwitcher({ : t('projectSwitcher.selector.empty') useEffect(() => { - if (!dialogMode || executionSpaceKind !== 'ssh') { + if ( + !remoteProjectsEnabled || + !dialogMode || + executionSpaceKind !== 'ssh' + ) { return } let active = true @@ -318,10 +327,14 @@ export function ProjectSwitcher({ return () => { active = false } - }, [dialogMode, executionSpaceKind, t]) + }, [dialogMode, executionSpaceKind, remoteProjectsEnabled, t]) useEffect(() => { - if (!dialogMode || executionSpaceKind !== 'ssh') { + if ( + !remoteProjectsEnabled || + !dialogMode || + executionSpaceKind !== 'ssh' + ) { return } const remoteApi = window.goodbuddy.projects.remote @@ -331,7 +344,28 @@ export function ProjectSwitcher({ return remoteApi.onSaveProgress((progress) => setRemoteSavePhase(progress.phase) ) - }, [dialogMode, executionSpaceKind]) + }, [dialogMode, executionSpaceKind, remoteProjectsEnabled]) + + useEffect(() => { + if ( + remoteProjectsEnabled || + dialogMode !== 'settings' || + activeProject?.executionSpace.kind !== 'ssh' + ) { + return + } + let active = true + queueMicrotask(() => { + if (active) { + setConfirmingDelete(false) + setDeleteConfirmation('') + setDialogMode(undefined) + } + }) + return () => { + active = false + } + }, [activeProject, dialogMode, remoteProjectsEnabled]) const resetRemoteDraft = (): void => { directoryBrowseRequestRef.current += 1 @@ -349,7 +383,9 @@ export function ProjectSwitcher({ const cancelRemoteDirectoryBrowse = useCallback((): void => { directoryBrowseRequestRef.current += 1 - const api = window.goodbuddy.sshHosts + const api = remoteProjectsEnabled + ? window.goodbuddy.sshHosts + : undefined if (api) { void api.cancelDirectoryBrowse().catch(() => undefined) } @@ -360,11 +396,14 @@ export function ProjectSwitcher({ requestAnimationFrame(() => directoryPickerTriggerRef.current?.focus() ) - }, []) + }, [remoteProjectsEnabled]) const browseRemoteDirectories = async ( path?: string ): Promise => { + if (!remoteProjectsEnabled) { + return + } const api = window.goodbuddy.sshHosts if (!api || !remoteHostId) { return @@ -465,7 +504,7 @@ export function ProjectSwitcher({ return } if (event.key === 'Escape' && !archiving && !deleting) { - if (remoteSaving) { + if (remoteProjectsEnabled && remoteSaving) { void window.goodbuddy.projects.remote .cancelCurrent() .catch(() => undefined) @@ -485,18 +524,21 @@ export function ProjectSwitcher({ deleting, dialogMode, directoryPickerOpen, + remoteProjectsEnabled, remoteSaving ]) const closeDialog = (): void => { directoryBrowseRequestRef.current += 1 if (directoryPickerOpen) { - const api = window.goodbuddy.sshHosts + const api = remoteProjectsEnabled + ? window.goodbuddy.sshHosts + : undefined if (api) { void api.cancelDirectoryBrowse().catch(() => undefined) } } - if (remoteSaving) { + if (remoteProjectsEnabled && remoteSaving) { void window.goodbuddy.projects.remote .cancelCurrent() .catch(() => undefined) @@ -520,7 +562,11 @@ export function ProjectSwitcher({ const saveRemoteProject = async (): Promise => { setError(undefined) - if (!draft.name.trim() || !validateRemoteDraft()) { + if ( + !remoteProjectsEnabled || + !draft.name.trim() || + !validateRemoteDraft() + ) { return } setRemoteSaving(true) @@ -668,7 +714,9 @@ export function ProjectSwitcher({ aria-haspopup="menu" aria-label={t('projectSwitcher.selector.ariaLabel')} className="project-switcher__trigger" - disabled={remoteActivation !== undefined} + disabled={ + remoteProjectsEnabled && remoteActivation !== undefined + } onClick={() => setProjectMenuOpen((open) => !open)} onKeyDown={(event) => { if ( @@ -738,11 +786,17 @@ export function ProjectSwitcher({ label: t('projectSwitcher.selector.userProjects'), icon: Folder }, - { - projects: remoteProjects, - label: t('projectSwitcher.selector.remoteProjects'), - icon: Server - }, + ...(remoteProjectsEnabled + ? [ + { + projects: remoteProjects, + label: t( + 'projectSwitcher.selector.remoteProjects' + ), + icon: Server + } + ] + : []), { projects: channelProjects, label: t('projectSwitcher.selector.channelProjects'), @@ -823,7 +877,9 @@ export function ProjectSwitcher({ - {remoteActivation && ( + {remoteProjectsEnabled && remoteActivation && ( diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index f2e90339..17e22ec2 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -364,7 +364,8 @@ const heartbeatSettingsProps = { onUpdateProject: vi.fn(async () => { throw new Error('Project update is not used in this test') }), - projects: [] + projects: [], + remoteProjectsEnabled: true } const assistantExpert: AssistantExpert = { id: '00000000-0000-4000-8000-000000000101', @@ -461,6 +462,7 @@ let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -683,6 +685,7 @@ describe('SettingsPanel runtime files', () => { checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', + remoteProjectsEnabled: false, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', @@ -1734,6 +1737,122 @@ describe('SettingsPanel runtime files', () => { ).toBeInTheDocument() }) + it('persists Remote Projects from its own tab and notifies its parent', async () => { + const onRemoteProjectsEnabledChange = vi.fn() + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) + const remoteProjectsTab = await screen.findByRole('tab', { + name: '远程项目(技术预览)' + }) + expect(remoteProjectsTab).toHaveAttribute( + 'aria-selected', + 'false' + ) + expect( + screen.queryByRole('switch', { + name: '远程项目(技术预览)' + }) + ).not.toBeInTheDocument() + fireEvent.click(remoteProjectsTab) + expect(remoteProjectsTab).toHaveAttribute('aria-selected', 'true') + const remoteProjectsSwitch = await screen.findByRole('switch', { + name: '远程项目(技术预览)' + }) + expect(remoteProjectsSwitch).not.toBeChecked() + + fireEvent.click(remoteProjectsSwitch) + + await waitFor(() => + expect(updateApplicationSettings).toHaveBeenCalledWith({ + remoteProjectsEnabled: true + }) + ) + expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true) + expect(remoteProjectsSwitch).toBeChecked() + }) + + it('shows the SSH Hosts category only while Remote Projects is enabled', () => { + const commonProps = { + ...heartbeatSettingsProps, + open: true, + onClearLocalData: vi.fn(async () => {}), + onClose: vi.fn(), + onSaved: vi.fn() + } + const { rerender } = render( + + ) + + expect( + screen.queryByRole('tab', { name: '主机与远程执行' }) + ).not.toBeInTheDocument() + + rerender( + + ) + + expect( + screen.getByRole('tab', { name: '主机与远程执行' }) + ).toBeInTheDocument() + }) + + it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => { + const commonProps = { + ...heartbeatSettingsProps, + initialCategory: 'ssh-hosts' as const, + open: true, + onClearLocalData: vi.fn(async () => {}), + onClose: vi.fn(), + onSaved: vi.fn() + } + const { rerender } = render( + + ) + await screen.findByText('尚未配置 SSH 主机') + + rerender( + + ) + + await waitFor(() => + expect( + screen.getByRole('tab', { name: '平台功能' }) + ).toHaveAttribute('aria-selected', 'true') + ) + expect( + screen.queryByRole('tab', { name: '主机与远程执行' }) + ).not.toBeInTheDocument() + expect( + await screen.findByRole('tab', { + name: '远程项目(技术预览)' + }) + ).toBeInTheDocument() + }) + it('records physical Control separately from Command on macOS', async () => { shortcutSettingsSnapshot = { ...shortcutSettingsSnapshot, diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index f09f83fa..7d4c3beb 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -60,7 +60,7 @@ import { SettingsWarningList } from './SettingsPrimitives' import { - settingsCategoryList, + getSettingsCategoryList, type SettingsCategoryId } from './settings-categories' import type { AppearanceTheme } from './theme' @@ -115,7 +115,6 @@ function toEmbeddingConnectionDrafts( })) } -const settingsTabs = settingsCategoryList.map(({ id }) => id) const contextCompressionTokenScale = 1_000 const minimumContextCompressionTriggerThousands = 8 const maximumContextCompressionTriggerThousands = 1_000 @@ -218,7 +217,9 @@ type SettingsPanelProps = { appearanceTheme?: AppearanceTheme onAppearanceThemeChange?: (theme: AppearanceTheme) => void magicNotesEnabled?: boolean + remoteProjectsEnabled?: boolean onMagicNotesEnabledChange?: (enabled: boolean) => void + onRemoteProjectsEnabledChange?: (enabled: boolean) => void onMagicNotesShowIncompleteTodoCountChange?: ( enabled: boolean ) => void @@ -585,7 +586,9 @@ export function SettingsPanel({ appearanceTheme = 'system', onAppearanceThemeChange = () => {}, magicNotesEnabled = false, + remoteProjectsEnabled = false, onMagicNotesEnabledChange = () => {}, + onRemoteProjectsEnabledChange = () => {}, onMagicNotesShowIncompleteTodoCountChange = () => {}, onShortcutSettingsChanged = () => {}, onLeaveRequestReady = () => {} @@ -707,8 +710,16 @@ export function SettingsPanel({ const [clearingLocalData, setClearingLocalData] = useState(false) const [detection, setDetection] = useState() const [detecting, setDetecting] = useState(false) - const [activeTab, setActiveTab] = - useState(initialCategory ?? 'runtime') + const [selectedTab, setActiveTab] = + useState( + initialCategory === 'ssh-hosts' && !remoteProjectsEnabled + ? 'platform-features' + : initialCategory ?? 'runtime' + ) + const activeTab = + selectedTab === 'ssh-hosts' && !remoteProjectsEnabled + ? 'platform-features' + : selectedTab const [modelType, setModelType] = useState('llm') const [speechModelDraftId, setSpeechModelDraftId] = useState< string | null | undefined @@ -959,6 +970,11 @@ export function SettingsPanel({ return true } + const visibleSettingsCategories = getSettingsCategoryList( + remoteProjectsEnabled + ) + const settingsTabs = visibleSettingsCategories.map(({ id }) => id) + const handleTabKeyDown = ( event: React.KeyboardEvent, tab: SettingsCategoryId @@ -1820,7 +1836,7 @@ export function SettingsPanel({ className="settings-tabs" role="tablist" > - {settingsCategoryList.map((category) => ( + {visibleSettingsCategories.map((category) => (