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
+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',