feat: gate remote projects as a technical preview

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 与远程项目功能,关闭不会删除已有配置。
This commit is contained in:
mesalogo
2026-08-25 00:16:02 +08:00
parent 607bb33249
commit 6c74f057eb
23 changed files with 1267 additions and 115 deletions
+1 -1
View File
@@ -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 账号可用工具、进程、网络和可写路径的完整授权。
+1 -1
View File
@@ -370,7 +370,7 @@
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称和项目类型,并按数据可用性补充本地目录、远程来源、平台、连接状态或其他能帮助辨认项目的信息。
- 项目菜单当前区分“本地项目 / 远程通道”。未来出现真正的远程项目时新增独立分组,不把消息通道命名为远程项目。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
### 6.12 应用通知与就地反馈
@@ -6,6 +6,12 @@
## 产品语义
远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立
“远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
项目或凭据;进行中的激活允许取消,当前远程项目切回第一个普通本地项目。应用启动仍固定
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
远程项目只有两种工作模式:
- **Ask**:Runtime 在操作系统边界以只读方式访问项目 Workspace。
@@ -475,7 +475,13 @@ type WorkbarPanelInstance = {
### 9.1 设置入口
设置中心增加“主机与远程执行”分类,管理:
设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示
“主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的
SSH/远程项目操作;已有 Host、项目和凭据保持不变。若关闭时正在激活远程项目,应用发出
取消并保持或切回普通本地项目;若当前项目已经是远程项目,则切回项目列表中的第一个普通
本地项目。重新启用后恢复完整入口和既有数据。
启用后,设置中心显示“主机与远程执行”分类,管理:
- 主机名称、地址、端口和用户名。
- 认证方式和凭据配置状态。
+90 -4
View File
@@ -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',
+37 -2
View File
@@ -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,
+268 -15
View File
@@ -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,
+78 -20
View File
@@ -1040,6 +1040,19 @@ export function registerIpcHandlers(
const trackExecution = executionTracker.track
const spaceResolver: ExecutionSpaceResolver =
executionSpaceResolver ?? new ExecutionSpaceResolver()
const requireRemoteProjectsEnabled = async (): Promise<void> => {
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<typeof ipcMain.handle>[0],
listener: Parameters<typeof ipcMain.handle>[1],
@@ -1063,10 +1076,14 @@ export function registerIpcHandlers(
followConfiguredAgentRuntime?: boolean
}
): Promise<AgentRuntime> => {
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
+240 -4
View File
@@ -899,6 +899,35 @@ function deferred<T>(): {
return { promise, resolve, reject }
}
function installRemoteProjectsSetting(enabled: boolean): {
updateSettings: NonNullable<DesktopApi['updates']>['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<DesktopApi['updates']>['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(<App />)
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(<App />)
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<typeof remoteProject>()
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockReturnValueOnce(
activation.promise
)
render(<App />)
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(<App />)
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',
+64 -8
View File
@@ -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<SettingsCategoryId>()
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>()
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<void> => {
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<void> => {
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}
@@ -189,6 +189,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(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',
@@ -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<void> => {
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<void> => {
@@ -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({
)}
</section>
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
hidden={activeSection !== 'remote-projects'}
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.remoteProjects.title')}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.description'
)}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t(
'platformFeatures.remoteProjects.title'
)}
checked={settings.remoteProjectsEnabled}
disabled={saving}
onChange={(event) =>
void changeRemoteProjects(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.remoteProjects.enabled')}
</span>
</label>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</section>
<section
aria-labelledby="platform-features-tab-magic-notes"
className="settings-section"
+102 -24
View File
@@ -69,7 +69,14 @@ afterEach(async () => {
})
function renderSwitcher(
currentProject: AssistantProject = project
currentProject: AssistantProject = project,
{
projects = [currentProject],
remoteProjectsEnabled = true
}: {
projects?: AssistantProject[]
remoteProjectsEnabled?: boolean
} = {}
): {
onCreate: ReturnType<typeof vi.fn>
onRemoteCommitted: ReturnType<typeof vi.fn>
@@ -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()
+98 -27
View File
@@ -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<void> => {
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<void> => {
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({
<button
aria-label={t('projectSwitcher.selector.create')}
className="icon-button"
disabled={remoteActivation !== undefined}
disabled={
remoteProjectsEnabled && remoteActivation !== undefined
}
onClick={() => {
setProjectMenuOpen(false)
setError(undefined)
@@ -851,11 +907,22 @@ export function ProjectSwitcher({
<button
aria-label={t('projectSwitcher.selector.settings')}
className="icon-button"
disabled={!activeProject || remoteActivation !== undefined}
disabled={
!activeProject ||
(remoteProjectsEnabled && remoteActivation !== undefined) ||
(!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh')
}
onClick={() => {
if (!activeProject) {
return
}
if (
!remoteProjectsEnabled &&
activeProject.executionSpace.kind === 'ssh'
) {
return
}
setProjectMenuOpen(false)
setError(undefined)
setConfirmingDelete(false)
@@ -900,7 +967,7 @@ export function ProjectSwitcher({
<Settings size={15} />
</button>
</div>
{remoteActivation && (
{remoteProjectsEnabled && remoteActivation && (
<RemoteProjectProgress
cancelling={remoteActivation.cancelling}
onCancel={onCancelRemoteActivation}
@@ -1035,12 +1102,16 @@ export function ProjectSwitcher({
'projectSwitcher.remote.executionSpaces.local'
)
},
{
value: 'ssh',
label: t(
'projectSwitcher.remote.executionSpaces.ssh'
)
}
...(remoteProjectsEnabled
? [
{
value: 'ssh' as const,
label: t(
'projectSwitcher.remote.executionSpaces.ssh'
)
}
]
: [])
]}
value={executionSpaceKind}
/>
+120 -1
View File
@@ -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(
<SettingsPanel
{...heartbeatSettingsProps}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
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(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
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(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
await screen.findByText('尚未配置 SSH 主机')
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
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,
+25 -6
View File
@@ -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<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] =
useState<SettingsCategoryId>(initialCategory ?? 'runtime')
const [selectedTab, setActiveTab] =
useState<SettingsCategoryId>(
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: initialCategory ?? 'runtime'
)
const activeTab =
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('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<HTMLButtonElement>,
tab: SettingsCategoryId
@@ -1820,7 +1836,7 @@ export function SettingsPanel({
className="settings-tabs"
role="tablist"
>
{settingsCategoryList.map((category) => (
{visibleSettingsCategories.map((category) => (
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
@@ -2036,6 +2052,9 @@ export function SettingsPanel({
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
@@ -3584,7 +3603,7 @@ export function SettingsPanel({
/>
)}
{activeTab === 'ssh-hosts' && (
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onHostUpdated={onSshHostUpdated}
@@ -21,6 +21,7 @@ describe('UpdateSettingsSection', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
@@ -444,6 +444,8 @@ export const settingsSections = {
serviceUnavailable:
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveRemoteProjectsFailed:
'Could not save the Remote Projects setting. Try again.',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
saveIncompleteTodoCountFailed:
'Could not save the incomplete to-do count setting. Try again.',
@@ -460,6 +462,12 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
remoteProjects: {
title: 'Remote Projects (Technical Preview)',
description:
'Enables SSH Host management and GoodBuddy-managed remote projects.',
enabled: 'Enable Remote Projects'
},
shortcut: {
title: 'Global quick access',
description:
@@ -421,6 +421,7 @@ export const settingsSections = {
errors: {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveRemoteProjectsFailed: '保存远程项目设置失败,请重试',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
'保存未完成待办数量设置失败,请重试',
@@ -434,6 +435,11 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
enabled: '启用远程项目'
},
shortcut: {
title: '全局快捷唤起',
description: '在其他应用前台时,也可以显示或隐藏 GoodBuddy。',
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getSettingsCategoryList } from './settings-categories'
describe('getSettingsCategoryList', () => {
it('hides SSH hosts while Remote Projects is disabled', () => {
expect(
getSettingsCategoryList(false).map(({ id }) => id)
).not.toContain('ssh-hosts')
})
it('includes SSH hosts while Remote Projects is enabled', () => {
expect(
getSettingsCategoryList(true).map(({ id }) => id)
).toContain('ssh-hosts')
})
})
+8
View File
@@ -59,6 +59,14 @@ export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id']
export function getSettingsCategoryList(
remoteProjectsEnabled: boolean
): readonly SettingsCategoryDefinition[] {
return remoteProjectsEnabled
? settingsCategoryList
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
}
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & {
readonly label: string
readonly navigationDescription: string
@@ -29,6 +29,7 @@ const applicationPreferencesSchema = z
checkUpdatesOnStartup: z.boolean(),
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
remoteProjectsEnabled: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNotesShowIncompleteTodoCount: z.boolean(),
magicNoteCommentMode: magicNoteCommentModeSchema,
@@ -135,6 +135,7 @@ describe('GoodBuddy configuration contracts', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',