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:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user