fix: make managed SSH sessions fast and resilient
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Managed SSH project selection repeatedly activated the remote Host and could leave idle ACP bindings rejected as stale-controller after a controller generation change. Host environment updates also coupled project state to component installation evidence and presented a crowded control surface. Project selection is now local-only, one Host connection serves compatible project and conversation callers, idle bindings are safely replaced only after proven controller takeover, and active prompts keep exact resume/replay. Remote environment setup now uses explicit signed compound package preparation with bounded transfer, rollback, recovery, and clearer settings. Managed Hosts must be updated to separately distributed Agent 0.11.6 before the daemon-side controller recovery fix takes effect. Desktop packages do not embed the Agent or remote Runtime, and existing project data is preserved. Release note: 托管 SSH 项目切换不再重复连接,同一 Host 可顺畅承载多个项目和对话;同时修复重连后的 stale-controller,并优化远程环境安装、更新与状态布局。
This commit is contained in:
+83
-549
@@ -395,8 +395,6 @@ const api: DesktopApi = {
|
||||
setArchived: vi.fn(async () => {}),
|
||||
delete: vi.fn(async () => {}),
|
||||
remote: {
|
||||
activate:
|
||||
vi.fn<DesktopApi['projects']['remote']['activate']>(),
|
||||
save: vi.fn<DesktopApi['projects']['remote']['save']>(),
|
||||
cancelCurrent:
|
||||
vi.fn<DesktopApi['projects']['remote']['cancelCurrent']>(),
|
||||
@@ -983,7 +981,6 @@ describe('App', () => {
|
||||
vi.mocked(api.conversations.onChanged)
|
||||
.mockReset()
|
||||
.mockReturnValue(() => undefined)
|
||||
vi.mocked(api.projects.remote.activate).mockReset()
|
||||
vi.mocked(api.projects.remote.cancelCurrent)
|
||||
.mockReset()
|
||||
.mockResolvedValue()
|
||||
@@ -5809,7 +5806,6 @@ describe('App', () => {
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
expect(api.projects.remote.activate).not.toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
localStorage.getItem('goodbuddy.active-project.v1')
|
||||
@@ -5817,7 +5813,7 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only supported OpenCode choices in a managed SSH conversation', async () => {
|
||||
it('switches immediately to a managed SSH project and shows only supported OpenCode choices', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const remoteProject = {
|
||||
...project,
|
||||
@@ -5836,9 +5832,6 @@ describe('App', () => {
|
||||
project,
|
||||
remoteProject
|
||||
])
|
||||
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
|
||||
remoteProject
|
||||
)
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000319',
|
||||
@@ -5872,6 +5865,9 @@ describe('App', () => {
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(remoteProject.name)
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('progressbar')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const runtimeButton = await screen.findByRole('button', {
|
||||
name: /OpenCode · 默认模型/u
|
||||
@@ -5914,6 +5910,57 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches between projects on one Host without a connection phase', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const first = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000126',
|
||||
name: 'Host 项目一',
|
||||
rootPath: '/srv/one',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId: '00000000-0000-4000-8000-000000000226',
|
||||
remoteRootPath: '/srv/one'
|
||||
},
|
||||
builtInDefault: false
|
||||
}
|
||||
const second = {
|
||||
...first,
|
||||
id: '00000000-0000-4000-8000-000000000127',
|
||||
name: 'Host 项目二',
|
||||
rootPath: '/srv/two',
|
||||
executionSpace: {
|
||||
...first.executionSpace,
|
||||
remoteRootPath: '/srv/two'
|
||||
}
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
first,
|
||||
second
|
||||
])
|
||||
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
vi.mocked(
|
||||
api.runtimeCustomization.getNativeSnapshot
|
||||
).mockClear()
|
||||
|
||||
for (const remoteProject of [first, second, first]) {
|
||||
selectProjectOption(remoteProject.name)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(remoteProject.name)
|
||||
expect(
|
||||
screen.queryByRole('progressbar')
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
expect(
|
||||
api.runtimeCustomization.getNativeSnapshot
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps saved remote projects hidden while the preview is disabled', async () => {
|
||||
installRemoteProjectsSetting(false)
|
||||
const remoteProject = {
|
||||
@@ -5944,7 +5991,6 @@ describe('App', () => {
|
||||
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 () => {
|
||||
@@ -5966,9 +6012,6 @@ describe('App', () => {
|
||||
project,
|
||||
remoteProject
|
||||
])
|
||||
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
|
||||
remoteProject
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
@@ -6014,79 +6057,18 @@ describe('App', () => {
|
||||
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 () => {
|
||||
it('deletes an unreachable non-active remote project without connecting', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const remoteProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000104',
|
||||
name: '失效远程项目',
|
||||
rootPath: '/srv/project',
|
||||
id: '00000000-0000-4000-8000-000000000125',
|
||||
name: '目录已丢失的远程项目',
|
||||
rootPath: '/srv/missing',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId: '00000000-0000-4000-8000-000000000204',
|
||||
remoteRootPath: '/srv/project'
|
||||
hostId: '00000000-0000-4000-8000-000000000225',
|
||||
remoteRootPath: '/srv/missing'
|
||||
},
|
||||
builtInDefault: false
|
||||
}
|
||||
@@ -6094,489 +6076,40 @@ describe('App', () => {
|
||||
project,
|
||||
remoteProject
|
||||
])
|
||||
vi.mocked(api.projects.remote.activate).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Error invoking remote method 'projects:remote:activate': AgentInstallationError: Agent upload failed"
|
||||
)
|
||||
)
|
||||
render(<App />)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
)
|
||||
|
||||
selectProjectOption(remoteProject.name)
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'远程项目更新失败:Agent upload failed。请检查 Host 后重试'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
})
|
||||
|
||||
it('revalidates the current SSH project when it is selected again', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const remoteProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000114',
|
||||
name: '可重新连接的远程项目',
|
||||
rootPath: '/srv/project',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId: '00000000-0000-4000-8000-000000000214',
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
builtInDefault: false
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
remoteProject
|
||||
])
|
||||
vi.mocked(api.projects.remote.activate).mockResolvedValue(
|
||||
remoteProject
|
||||
)
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
|
||||
selectProjectOption(remoteProject.name)
|
||||
await waitFor(() =>
|
||||
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(remoteProject.name)
|
||||
|
||||
selectProjectOption(remoteProject.name)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
|
||||
remoteProject.id
|
||||
)
|
||||
})
|
||||
|
||||
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'
|
||||
const remoteConversationId =
|
||||
'00000000-0000-4000-8000-000000000415'
|
||||
const fingerprint = `SHA256:${'A'.repeat(43)}`
|
||||
const host = {
|
||||
id: hostId,
|
||||
name: 'Build host',
|
||||
hostname: '192.168.0.23',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authentication: 'system-agent' as const,
|
||||
credentialConfigured: true,
|
||||
credentialSource: 'system-agent' as const,
|
||||
hostKey: {
|
||||
state: 'verified' as const,
|
||||
algorithm: 'ecdsa-sha2-nistp256',
|
||||
fingerprintSha256: fingerprint,
|
||||
generation: 1
|
||||
},
|
||||
lastValidatedAt: '2026-08-24T00:00:00.000Z',
|
||||
createdAt: '2026-08-24T00:00:00.000Z',
|
||||
updatedAt: '2026-08-24T00:00:00.000Z'
|
||||
}
|
||||
const updatedHost = {
|
||||
...host,
|
||||
hostname: '10.7.0.23',
|
||||
hostKey: { ...host.hostKey, generation: 2 },
|
||||
updatedAt: '2026-08-24T00:01:00.000Z'
|
||||
}
|
||||
const remoteProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000115',
|
||||
name: 'Host 一致性项目',
|
||||
rootPath: '/root',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId,
|
||||
remoteRootPath: '/root'
|
||||
},
|
||||
builtInDefault: false
|
||||
}
|
||||
const sshHosts: NonNullable<DesktopApi['sshHosts']> = {
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
hosts: [host],
|
||||
secureStorageAvailable: true
|
||||
})),
|
||||
getAgentPackageInventory: vi.fn(async () => ({
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
catalog: {
|
||||
state: 'available' as const,
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
error: null
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
state: 'verified' as const,
|
||||
version: '0.11.2',
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: true,
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntimeVersion: '1.18.9'
|
||||
},
|
||||
{
|
||||
platform: 'linux' as const,
|
||||
architecture: 'arm64' as const,
|
||||
state: 'not-downloaded' as const,
|
||||
version: null,
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: false,
|
||||
agentProtocol: null,
|
||||
remoteRuntimeVersion: null
|
||||
}
|
||||
]
|
||||
})),
|
||||
downloadAgentPackage: vi.fn(),
|
||||
importAgentPackage: vi.fn(),
|
||||
exportAgentPackage: vi.fn(async () => {}),
|
||||
onAgentPackageProgress: vi.fn(() => () => {}),
|
||||
remove: vi.fn(async () => undefined),
|
||||
inspectDraftHostKey: vi.fn(async () => ({
|
||||
candidateId,
|
||||
hostId,
|
||||
state: 'verified' as const,
|
||||
algorithm: 'ecdsa-sha2-nistp256',
|
||||
fingerprintSha256: fingerprint
|
||||
})),
|
||||
discardCandidate: vi.fn(async () => undefined),
|
||||
validateAndSave: vi.fn(async () => ({
|
||||
host: updatedHost,
|
||||
connection: {
|
||||
hostId,
|
||||
connected: true as const,
|
||||
latencyMs: 12,
|
||||
platform: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
shell: '/bin/bash',
|
||||
homeDirectory: '/root',
|
||||
detail: 'SSH 已连接,远端系统为 linux/x64'
|
||||
}
|
||||
})),
|
||||
getRemoteEnvironment: vi.fn(async () => ({
|
||||
hostId,
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
architecture: 'x64' as const,
|
||||
agent: {
|
||||
state: 'current' as const,
|
||||
expected: {
|
||||
version: '0.11.2-e2e.8',
|
||||
architecture: 'x64' as const
|
||||
},
|
||||
installed: {
|
||||
version: '0.11.2-e2e.8',
|
||||
architecture: 'x64' as const
|
||||
}
|
||||
},
|
||||
runtimes: [{
|
||||
runtimeId: 'opencode' as const,
|
||||
provider: 'opencode' as const,
|
||||
state: 'current' as const,
|
||||
expected: {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64' as const
|
||||
},
|
||||
installed: {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64' as const
|
||||
}
|
||||
}]
|
||||
})),
|
||||
updateRemoteEnvironment: vi.fn(async () => undefined),
|
||||
cancelRemoteEnvironmentUpdate: vi.fn(async () => undefined),
|
||||
onRemoteEnvironmentUpdateProgress: vi.fn(() => () => undefined),
|
||||
browseDirectories: vi.fn(async () => ({
|
||||
path: '/root',
|
||||
homeDirectory: '/root',
|
||||
parentPath: '/',
|
||||
entries: [],
|
||||
truncated: false
|
||||
})),
|
||||
cancelDirectoryBrowse: vi.fn(async () => undefined)
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
remoteProject
|
||||
])
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: remoteConversationId,
|
||||
projectId: remoteProject.id,
|
||||
title: 'Host 一致性对话',
|
||||
updatedAt: Date.now(),
|
||||
messages: []
|
||||
}
|
||||
])
|
||||
const hostReactivation = deferred<typeof remoteProject>()
|
||||
vi.mocked(api.projects.remote.activate)
|
||||
.mockResolvedValueOnce(remoteProject)
|
||||
.mockReturnValueOnce(hostReactivation.promise)
|
||||
.mockResolvedValueOnce(remoteProject)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: { ...api, sshHosts }
|
||||
})
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
selectProjectOption(remoteProject.name)
|
||||
await waitFor(() =>
|
||||
expect(api.projects.remote.activate).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByText('本地工作区'))
|
||||
await screen.findByRole('heading', { name: '设置中心' })
|
||||
fireEvent.click(screen.getByLabelText('当前项目'))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '主机与远程执行' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '编辑 Build host'
|
||||
screen.getByRole('menuitem', {
|
||||
name: `管理项目 ${remoteProject.name}`
|
||||
})
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('主机地址'), {
|
||||
target: { value: '10.7.0.23' }
|
||||
})
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '检查 Host Key' })
|
||||
within(dialog).getByRole('button', { name: '删除项目' })
|
||||
)
|
||||
fireEvent.change(
|
||||
within(dialog).getByLabelText(
|
||||
`输入“${remoteProject.name}”确认删除`
|
||||
),
|
||||
{ target: { value: remoteProject.name } }
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '确认身份并继续'
|
||||
within(dialog).getByRole('button', {
|
||||
name: '永久删除项目'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '验证并保存' })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sshHosts.validateAndSave).toHaveBeenCalledOnce()
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.projects.remote.activate).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(api.projects.remote.activate).toHaveBeenLastCalledWith(
|
||||
remoteProject.id
|
||||
)
|
||||
|
||||
const queuedDispatch: ConversationQueueDispatch = {
|
||||
item: {
|
||||
id: '00000000-0000-4000-8000-000000000515',
|
||||
conversationId: remoteConversationId,
|
||||
source: 'user',
|
||||
label: '等待 Host 恢复的消息',
|
||||
createdAt: '2026-08-24T00:02:00.000Z'
|
||||
},
|
||||
input: {
|
||||
conversationId: remoteConversationId,
|
||||
projectId: remoteProject.id,
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
workMode: 'ask',
|
||||
includeMemoryContext: true,
|
||||
prompt: '等待 Host 恢复的消息',
|
||||
attachments: [],
|
||||
knowledgeLibraryIds: [],
|
||||
knowledgeRetrievalMode: 'auto'
|
||||
}
|
||||
}
|
||||
act(() => {
|
||||
conversationQueueDispatchListener?.(queuedDispatch)
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(api.conversationQueue.releaseUser).toHaveBeenCalledWith(
|
||||
queuedDispatch.item.id
|
||||
expect(api.projects.delete).toHaveBeenCalledWith(
|
||||
remoteProject.id,
|
||||
remoteProject.name
|
||||
)
|
||||
)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
hostReactivation.reject(new Error('Host reconnect failed'))
|
||||
await hostReactivation.promise.catch(() => undefined)
|
||||
})
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'远程项目更新失败:Host reconnect failed。请检查 Host 后重试'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话' }))
|
||||
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '前台消息也必须等待重新连接' }
|
||||
})
|
||||
const send = screen.getByLabelText('发送')
|
||||
expect(send).toBeDisabled()
|
||||
expect(send).toHaveAttribute(
|
||||
'title',
|
||||
'远程项目需要重新连接,请重新选择当前项目后再发送'
|
||||
)
|
||||
|
||||
selectProjectOption(remoteProject.name)
|
||||
await waitFor(() =>
|
||||
expect(api.projects.remote.activate).toHaveBeenCalledTimes(3)
|
||||
)
|
||||
await waitFor(() => expect(send).toBeEnabled())
|
||||
await waitFor(() =>
|
||||
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
|
||||
remoteConversationId
|
||||
)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
conversationQueueDispatchListener?.(queuedDispatch)
|
||||
})
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(run.mock.calls[0]![0]).toMatchObject({
|
||||
queueItemId: queuedDispatch.item.id,
|
||||
projectId: remoteProject.id,
|
||||
prompt: queuedDispatch.input.prompt
|
||||
})
|
||||
expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows every direct remote activation phase and blocks conflicting project actions', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const remoteProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000105',
|
||||
name: '阶段远程项目',
|
||||
rootPath: '/srv/project',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId: '00000000-0000-4000-8000-000000000205',
|
||||
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 />)
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
|
||||
selectProjectOption(remoteProject.name)
|
||||
|
||||
const progress = await screen.findByRole('progressbar', {
|
||||
name: '远程项目连接进度'
|
||||
})
|
||||
expect(progress).toHaveAttribute('max', '5')
|
||||
expect(progress).toHaveAttribute('value', '1')
|
||||
expect(
|
||||
screen.getByText('正在连接远程项目“阶段远程项目”')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toBeDisabled()
|
||||
expect(screen.getByLabelText('新建项目')).toBeDisabled()
|
||||
expect(screen.getByLabelText('项目设置')).toBeDisabled()
|
||||
|
||||
act(() => {
|
||||
remoteProjectSaveProgressListener?.({ phase: 'workspace' })
|
||||
})
|
||||
expect(progress).toHaveAttribute('value', '3')
|
||||
expect(
|
||||
screen.getByText('当前阶段:远端工作区')
|
||||
).toBeInTheDocument()
|
||||
const stages = screen.getByRole('list', {
|
||||
name: '远程项目连接阶段'
|
||||
})
|
||||
expect(within(stages).getAllByRole('listitem')).toHaveLength(5)
|
||||
expect(
|
||||
within(stages).getByText('远端工作区').closest('li')
|
||||
).toHaveAttribute('aria-current', 'step')
|
||||
|
||||
await act(async () => {
|
||||
activation.resolve(remoteProject)
|
||||
await activation.promise
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '远程项目连接进度'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(remoteProject.name)
|
||||
})
|
||||
|
||||
it('cancels direct remote activation without switching projects', async () => {
|
||||
installRemoteProjectsSetting(true)
|
||||
const remoteProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000106',
|
||||
name: '可取消远程项目',
|
||||
rootPath: '/srv/project',
|
||||
runtimeSelection: { provider: 'opencode' as const },
|
||||
executionSpace: {
|
||||
kind: 'ssh' as const,
|
||||
hostId: '00000000-0000-4000-8000-000000000206',
|
||||
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 />)
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
selectProjectOption(remoteProject.name)
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '取消连接' })
|
||||
)
|
||||
expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '取消中…' })
|
||||
).toBeDisabled()
|
||||
await act(async () => {
|
||||
activation.reject(
|
||||
new DOMException('Remote project save cancelled', 'AbortError')
|
||||
)
|
||||
await activation.promise.catch(() => undefined)
|
||||
})
|
||||
|
||||
expect(
|
||||
await screen.findByText('已取消远程项目连接')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前项目' })
|
||||
).toHaveTextContent(project.name)
|
||||
expect(
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '远程项目连接进度'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows grouped project details and keeps the rich menu keyboard accessible', async () => {
|
||||
@@ -6610,15 +6143,16 @@ describe('App', () => {
|
||||
const selectedProject = within(menu)
|
||||
.getByText(project.name, { selector: 'b' })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
const remoteProject = within(menu)
|
||||
.getByText(channelProject.name, { selector: 'b' })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
const remoteProjectSettings = within(menu).getByRole(
|
||||
'menuitem',
|
||||
{ name: `管理项目 ${channelProject.name}` }
|
||||
)
|
||||
expect(selectedProject).toHaveAttribute('aria-checked', 'true')
|
||||
await waitFor(() => expect(selectedProject).toHaveFocus())
|
||||
|
||||
fireEvent.keyDown(selectedProject!, { key: 'End' })
|
||||
expect(remoteProject).toHaveFocus()
|
||||
fireEvent.keyDown(remoteProject!, { key: 'Escape' })
|
||||
expect(remoteProjectSettings).toHaveFocus()
|
||||
fireEvent.keyDown(remoteProjectSettings, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: '当前项目' })
|
||||
|
||||
+94
-390
@@ -89,9 +89,6 @@ import {
|
||||
agentRuntimeSelectionSchema,
|
||||
type AgentRuntimeSelection
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import type {
|
||||
RemoteProjectSavePhase
|
||||
} from '../../shared/remote-project-candidate-contracts'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider
|
||||
@@ -1994,25 +1991,6 @@ function App(): React.JSX.Element {
|
||||
const [selectedExpertId, setSelectedExpertId] = useState('')
|
||||
const [activeProjectId, setActiveProjectId] = useState('')
|
||||
const activeProjectIdRef = useRef(activeProjectId)
|
||||
const remoteProjectActivationRequestRef = useRef(0)
|
||||
const remoteProjectActivationControlRef = useRef<{
|
||||
requestId: number
|
||||
projectId: string
|
||||
cancelRequested: boolean
|
||||
} | undefined>(undefined)
|
||||
const [remoteProjectActivation, setRemoteProjectActivation] =
|
||||
useState<{
|
||||
projectId: string
|
||||
phase: RemoteProjectSavePhase
|
||||
cancelling: boolean
|
||||
}>()
|
||||
const remoteProjectsRequiringActivationRef = useRef(
|
||||
new Set<string>()
|
||||
)
|
||||
const [
|
||||
remoteProjectsRequiringActivation,
|
||||
setRemoteProjectsRequiringActivation
|
||||
] = useState<Set<string>>(() => new Set())
|
||||
const workspaceChangesRequestRef = useRef(0)
|
||||
const runtimeStatusRequestRef = useRef(0)
|
||||
const runtimeSetupPromptedRef = useRef(false)
|
||||
@@ -3020,6 +2998,7 @@ function App(): React.JSX.Element {
|
||||
if (
|
||||
!activeRuntimeSelection ||
|
||||
activeConversation?.remote ||
|
||||
activeProjectUsesManagedSsh ||
|
||||
(activeRuntimeSelection.provider !== 'opencode' &&
|
||||
activeRuntimeSelection.provider !== 'continue')
|
||||
) {
|
||||
@@ -3053,6 +3032,7 @@ function App(): React.JSX.Element {
|
||||
})
|
||||
}, [
|
||||
activeConversation?.remote,
|
||||
activeProjectUsesManagedSsh,
|
||||
activeProjectId,
|
||||
activeRuntimeSelection,
|
||||
activeRuntimeSelectionKey
|
||||
@@ -3396,8 +3376,6 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[notify, projects, runtimeSettings, setActiveId, setView]
|
||||
)
|
||||
const activeRemoteProjectRequiresActivation =
|
||||
remoteProjectsRequiringActivation.has(activeProjectId)
|
||||
const activeProjectDisplayName = activeProject
|
||||
? getProjectDisplayText(activeProject, tWorkspace).name
|
||||
: undefined
|
||||
@@ -4880,191 +4858,6 @@ function App(): React.JSX.Element {
|
||||
[]
|
||||
)
|
||||
|
||||
const activateRemoteProject = useCallback(
|
||||
async (
|
||||
project: AssistantProject
|
||||
): Promise<{
|
||||
project: AssistantProject
|
||||
current: boolean
|
||||
}> => {
|
||||
if (
|
||||
!remoteProjectsEnabled ||
|
||||
project.executionSpace.kind !== 'ssh'
|
||||
) {
|
||||
return { project, current: false }
|
||||
}
|
||||
const requestId = remoteProjectActivationRequestRef.current + 1
|
||||
remoteProjectActivationRequestRef.current = requestId
|
||||
const control = {
|
||||
requestId,
|
||||
projectId: project.id,
|
||||
cancelRequested: false
|
||||
}
|
||||
remoteProjectActivationControlRef.current = control
|
||||
setRemoteProjectActivation({
|
||||
projectId: project.id,
|
||||
phase: 'host',
|
||||
cancelling: false
|
||||
})
|
||||
const removeProgress =
|
||||
window.goodbuddy.projects.remote.onSaveProgress(
|
||||
(progress) => {
|
||||
if (
|
||||
remoteProjectActivationControlRef.current !==
|
||||
control
|
||||
) {
|
||||
return
|
||||
}
|
||||
setRemoteProjectActivation({
|
||||
projectId: project.id,
|
||||
phase: progress.phase,
|
||||
cancelling: control.cancelRequested
|
||||
})
|
||||
}
|
||||
)
|
||||
try {
|
||||
const activated =
|
||||
await window.goodbuddy.projects.remote.activate(project.id)
|
||||
setProjects((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === activated.id ? activated : candidate
|
||||
)
|
||||
)
|
||||
const current =
|
||||
remoteProjectActivationRequestRef.current === requestId &&
|
||||
!control.cancelRequested
|
||||
if (current) {
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: tRef.current('notices.remoteProjectActivated'),
|
||||
dedupeKey: `remote-project-activated:${project.id}`
|
||||
})
|
||||
} else if (
|
||||
remoteProjectActivationRequestRef.current === requestId &&
|
||||
control.cancelRequested
|
||||
) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: tRef.current(
|
||||
'notices.remoteProjectActivationCancelled'
|
||||
),
|
||||
dedupeKey: `remote-project-cancelled:${project.id}`
|
||||
})
|
||||
}
|
||||
return { project: activated, current }
|
||||
} catch (reason) {
|
||||
if (
|
||||
remoteProjectActivationRequestRef.current !== requestId
|
||||
) {
|
||||
return { project, current: false }
|
||||
}
|
||||
if (control.cancelRequested) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: tRef.current(
|
||||
'notices.remoteProjectActivationCancelled'
|
||||
),
|
||||
dedupeKey: `remote-project-cancelled:${project.id}`
|
||||
})
|
||||
return { project, current: false }
|
||||
}
|
||||
const message = displayErrorMessage(
|
||||
reason,
|
||||
tRef.current('notices.remoteProjectActivationUnknownError')
|
||||
)
|
||||
throw new Error(
|
||||
tRef.current('notices.remoteProjectActivationFailed', {
|
||||
message
|
||||
}),
|
||||
{ cause: reason }
|
||||
)
|
||||
} finally {
|
||||
removeProgress()
|
||||
if (
|
||||
remoteProjectActivationControlRef.current === control
|
||||
) {
|
||||
remoteProjectActivationControlRef.current = undefined
|
||||
setRemoteProjectActivation(undefined)
|
||||
}
|
||||
}
|
||||
},
|
||||
[notify, remoteProjectsEnabled]
|
||||
)
|
||||
|
||||
const cancelRemoteProjectActivation = useCallback((): void => {
|
||||
const control = remoteProjectActivationControlRef.current
|
||||
if (!control || control.cancelRequested) {
|
||||
return
|
||||
}
|
||||
control.cancelRequested = true
|
||||
setRemoteProjectActivation((current) =>
|
||||
current && current.projectId === control.projectId
|
||||
? { ...current, cancelling: true }
|
||||
: current
|
||||
)
|
||||
void window.goodbuddy.projects.remote.cancelCurrent().catch(
|
||||
(reason: unknown) => {
|
||||
if (
|
||||
remoteProjectActivationControlRef.current !== control
|
||||
) {
|
||||
return
|
||||
}
|
||||
control.cancelRequested = false
|
||||
setRemoteProjectActivation((current) =>
|
||||
current && current.projectId === control.projectId
|
||||
? { ...current, cancelling: false }
|
||||
: current
|
||||
)
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current(
|
||||
'notices.remoteProjectActivationCancelFailed',
|
||||
{
|
||||
message: displayErrorMessage(
|
||||
reason,
|
||||
tRef.current(
|
||||
'notices.remoteProjectActivationCancelUnknownError'
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}, [notify])
|
||||
|
||||
const markRemoteProjectsRequiringActivation = useCallback(
|
||||
(projectIds: string[]): void => {
|
||||
const next = new Set(remoteProjectsRequiringActivationRef.current)
|
||||
let changed = false
|
||||
for (const projectId of projectIds) {
|
||||
if (!next.has(projectId)) {
|
||||
next.add(projectId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
remoteProjectsRequiringActivationRef.current = next
|
||||
setRemoteProjectsRequiringActivation(next)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const clearRemoteProjectActivationRequirement = useCallback(
|
||||
(projectId: string): void => {
|
||||
if (!remoteProjectsRequiringActivationRef.current.has(projectId)) {
|
||||
return
|
||||
}
|
||||
const next = new Set(remoteProjectsRequiringActivationRef.current)
|
||||
next.delete(projectId)
|
||||
remoteProjectsRequiringActivationRef.current = next
|
||||
setRemoteProjectsRequiringActivation(next)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resumeProjectConversationQueues = useCallback(
|
||||
(projectId: string): void => {
|
||||
const conversationIds = conversationsRef.current
|
||||
@@ -5094,57 +4887,6 @@ function App(): React.JSX.Element {
|
||||
[notify]
|
||||
)
|
||||
|
||||
const handleSshHostUpdated = useCallback(
|
||||
(hostId: string): void => {
|
||||
if (!remoteProjectsEnabled) {
|
||||
return
|
||||
}
|
||||
const affectedProjects = projects.filter(
|
||||
(candidate) =>
|
||||
candidate.executionSpace.kind === 'ssh' &&
|
||||
candidate.executionSpace.hostId === hostId
|
||||
)
|
||||
markRemoteProjectsRequiringActivation(
|
||||
affectedProjects.map((project) => project.id)
|
||||
)
|
||||
const project = affectedProjects.find(
|
||||
(candidate) => candidate.id === activeProjectIdRef.current
|
||||
)
|
||||
if (!project) {
|
||||
return
|
||||
}
|
||||
void activateRemoteProject(project).then(
|
||||
(activated) => {
|
||||
if (!activated.current) {
|
||||
return
|
||||
}
|
||||
clearRemoteProjectActivationRequirement(activated.project.id)
|
||||
resumeProjectConversationQueues(activated.project.id)
|
||||
},
|
||||
(reason: unknown) => {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: tRef.current(
|
||||
'notices.remoteProjectActivationUnknownError'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
[
|
||||
activateRemoteProject,
|
||||
clearRemoteProjectActivationRequirement,
|
||||
markRemoteProjectsRequiringActivation,
|
||||
notify,
|
||||
projects,
|
||||
remoteProjectsEnabled,
|
||||
resumeProjectConversationQueues
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const initialization = Promise.all([
|
||||
@@ -5160,7 +4902,6 @@ function App(): React.JSX.Element {
|
||||
if (!project) {
|
||||
throw new Error('没有可用的本地项目')
|
||||
}
|
||||
remoteProjectActivationRequestRef.current += 1
|
||||
setActiveProjectId(project.id)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(project.defaultWorkMode)
|
||||
@@ -5814,12 +5555,15 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
}, [startNewConversation])
|
||||
|
||||
const commitProjectSelection = (selected: AssistantProject): void => {
|
||||
const commitProjectSelection = (
|
||||
selected: AssistantProject,
|
||||
candidateConversations = conversationsRef.current
|
||||
): void => {
|
||||
setActiveProjectId(selected.id)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(selected.defaultWorkMode)
|
||||
)
|
||||
const conversation = conversationsRef.current.find(
|
||||
const conversation = candidateConversations.find(
|
||||
(candidate) =>
|
||||
candidate.projectId === selected.id &&
|
||||
(
|
||||
@@ -5873,39 +5617,20 @@ function App(): React.JSX.Element {
|
||||
if (!project) {
|
||||
return
|
||||
}
|
||||
if (project.executionSpace.kind !== 'ssh') {
|
||||
remoteProjectActivationRequestRef.current += 1
|
||||
commitProjectSelection(project)
|
||||
if (
|
||||
project.executionSpace.kind === 'ssh' &&
|
||||
!remoteProjectsEnabled
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!remoteProjectsEnabled) {
|
||||
return
|
||||
}
|
||||
void activateRemoteProject(project).then(
|
||||
(activated) => {
|
||||
if (activated.current) {
|
||||
clearRemoteProjectActivationRequirement(activated.project.id)
|
||||
commitProjectSelection(activated.project)
|
||||
resumeProjectConversationQueues(activated.project.id)
|
||||
}
|
||||
},
|
||||
(reason: unknown) => {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('notices.remoteProjectActivationUnknownError')
|
||||
})
|
||||
}
|
||||
)
|
||||
commitProjectSelection(project)
|
||||
resumeProjectConversationQueues(project.id)
|
||||
}
|
||||
|
||||
const createProject = async (
|
||||
input: ProjectCreateInput
|
||||
): Promise<AssistantProject> => {
|
||||
const project = await window.goodbuddy.projects.create(input)
|
||||
remoteProjectActivationRequestRef.current += 1
|
||||
setProjects((current) => [project, ...current])
|
||||
setActiveProjectId(project.id)
|
||||
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode))
|
||||
@@ -5959,9 +5684,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: [project, ...current]
|
||||
)
|
||||
remoteProjectActivationRequestRef.current += 1
|
||||
commitProjectSelection(project)
|
||||
clearRemoteProjectActivationRequirement(project.id)
|
||||
resumeProjectConversationQueues(project.id)
|
||||
}
|
||||
|
||||
@@ -5972,10 +5695,6 @@ function App(): React.JSX.Element {
|
||||
if (enabled) {
|
||||
return
|
||||
}
|
||||
remoteProjectActivationRequestRef.current += 1
|
||||
if (remoteProjectActivationControlRef.current) {
|
||||
cancelRemoteProjectActivation()
|
||||
}
|
||||
if (activeProject?.executionSpace.kind !== 'ssh') {
|
||||
return
|
||||
}
|
||||
@@ -5987,48 +5706,85 @@ function App(): React.JSX.Element {
|
||||
|
||||
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 = remoteProjectsEnabled
|
||||
? remaining[0]
|
||||
: remaining.find(isOrdinaryLocalProject)
|
||||
if (next) {
|
||||
selectProject(next.id)
|
||||
if (projectId === activeProjectIdRef.current) {
|
||||
const next =
|
||||
remaining.find(isOrdinaryLocalProject) ??
|
||||
(remoteProjectsEnabled ? remaining[0] : undefined)
|
||||
if (next) {
|
||||
selectProject(next.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async (
|
||||
projectId: string,
|
||||
confirmation: string
|
||||
): Promise<void> => {
|
||||
await window.goodbuddy.projects.delete(projectId, confirmation)
|
||||
clearRemoteProjectActivationRequirement(projectId)
|
||||
const remainingProjects = projects.filter(
|
||||
(project) => project.id !== projectId
|
||||
const removeProjectsFromUi = (
|
||||
projectIds: readonly string[]
|
||||
): void => {
|
||||
if (projectIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const deletedProjectIds = new Set(projectIds)
|
||||
const referencesDeletedProject = (
|
||||
projectId: string | undefined
|
||||
): boolean =>
|
||||
projectId !== undefined && deletedProjectIds.has(projectId)
|
||||
const remainingProjects = projectsRef.current.filter(
|
||||
(project) => !deletedProjectIds.has(project.id)
|
||||
)
|
||||
const remainingConversations = conversations.filter(
|
||||
(conversation) => conversation.projectId !== projectId
|
||||
const currentConversations = conversationsRef.current
|
||||
const deletedConversationIds = new Set(
|
||||
currentConversations
|
||||
.filter((conversation) =>
|
||||
referencesDeletedProject(conversation.projectId)
|
||||
)
|
||||
.map((conversation) => conversation.id)
|
||||
)
|
||||
const remainingConversations = currentConversations.filter(
|
||||
(conversation) =>
|
||||
!referencesDeletedProject(conversation.projectId)
|
||||
)
|
||||
setProjects((current) =>
|
||||
current.filter(
|
||||
(project) => !deletedProjectIds.has(project.id)
|
||||
)
|
||||
)
|
||||
setConversations((current) =>
|
||||
current.filter(
|
||||
(conversation) =>
|
||||
!referencesDeletedProject(conversation.projectId)
|
||||
)
|
||||
)
|
||||
setProjects(remainingProjects)
|
||||
setConversations(remainingConversations)
|
||||
setAssistantTasks((current) =>
|
||||
current.filter((task) => task.projectId !== projectId)
|
||||
current.filter(
|
||||
(task) => !referencesDeletedProject(task.projectId)
|
||||
)
|
||||
)
|
||||
setAssistantArtifacts((current) =>
|
||||
current.filter((artifact) => artifact.projectId !== projectId)
|
||||
current.filter(
|
||||
(artifact) =>
|
||||
!referencesDeletedProject(artifact.projectId)
|
||||
)
|
||||
)
|
||||
setAssistantMemories((current) =>
|
||||
current.filter(
|
||||
(memory) =>
|
||||
!(
|
||||
memory.scope === 'project' &&
|
||||
memory.scopeId === projectId
|
||||
referencesDeletedProject(memory.scopeId)
|
||||
) &&
|
||||
!(
|
||||
memory.scope === 'conversation' &&
|
||||
memory.scopeId !== undefined &&
|
||||
deletedConversationIds.has(memory.scopeId)
|
||||
)
|
||||
)
|
||||
)
|
||||
setAssistantSchedules((current) =>
|
||||
current.filter((schedule) => schedule.projectId !== projectId)
|
||||
current.filter(
|
||||
(schedule) =>
|
||||
!referencesDeletedProject(schedule.projectId)
|
||||
)
|
||||
)
|
||||
setAssistantHeartbeats((current) =>
|
||||
current.flatMap((heartbeat) => {
|
||||
@@ -6036,46 +5792,34 @@ function App(): React.JSX.Element {
|
||||
return heartbeat
|
||||
}
|
||||
const projectIds = heartbeat.scope.projectIds.filter(
|
||||
(id) => id !== projectId
|
||||
(id) => !deletedProjectIds.has(id)
|
||||
)
|
||||
return projectIds.length > 0
|
||||
? [{ ...heartbeat, scope: { kind: 'projects', projectIds } }]
|
||||
: []
|
||||
})
|
||||
)
|
||||
const next = remoteProjectsEnabled
|
||||
? remainingProjects[0]
|
||||
: remainingProjects.find(isOrdinaryLocalProject)
|
||||
if (next) {
|
||||
setActiveProjectId(next.id)
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(next.defaultWorkMode)
|
||||
)
|
||||
const nextConversation = remainingConversations.find(
|
||||
(conversation) =>
|
||||
conversation.projectId === next.id &&
|
||||
(next.kind !== 'channel' ||
|
||||
conversation.remote !== undefined)
|
||||
)
|
||||
if (nextConversation) {
|
||||
setActiveId(nextConversation.id)
|
||||
} else if (next.kind === 'channel') {
|
||||
setActiveId('')
|
||||
} else {
|
||||
const created = createConversation(
|
||||
next.id,
|
||||
runtimeSettings
|
||||
? getProjectDefaultRuntimeSelection(next, runtimeSettings)
|
||||
: undefined,
|
||||
t('conversation.greeting')
|
||||
)
|
||||
setConversations((current) => [created, ...current])
|
||||
setActiveId(created.id)
|
||||
}
|
||||
if (!deletedProjectIds.has(activeProjectIdRef.current)) {
|
||||
return
|
||||
}
|
||||
const next = remainingProjects.find(isOrdinaryLocalProject)
|
||||
if (next) {
|
||||
commitProjectSelection(next, remainingConversations)
|
||||
return
|
||||
}
|
||||
setActiveProjectId('')
|
||||
setActiveId('')
|
||||
setView('chat')
|
||||
}
|
||||
|
||||
const deleteProject = async (
|
||||
projectId: string,
|
||||
confirmation: string
|
||||
): Promise<void> => {
|
||||
await window.goodbuddy.projects.delete(projectId, confirmation)
|
||||
removeProjectsFromUi([projectId])
|
||||
}
|
||||
|
||||
const newConversation = (): boolean => {
|
||||
return startNewConversation(activeProjectId || undefined)
|
||||
}
|
||||
@@ -6590,39 +6334,6 @@ function App(): React.JSX.Element {
|
||||
await releaseQueuedItem()
|
||||
return
|
||||
}
|
||||
const dispatchProjectId =
|
||||
queuedInput?.projectId ?? conversationSnapshot.projectId
|
||||
const dispatchProjectReactivating =
|
||||
Boolean(
|
||||
dispatchProjectId &&
|
||||
remoteProjectActivationControlRef.current?.projectId ===
|
||||
dispatchProjectId
|
||||
)
|
||||
const dispatchProjectRequiresActivation =
|
||||
Boolean(
|
||||
dispatchProjectId &&
|
||||
remoteProjectsRequiringActivationRef.current.has(
|
||||
dispatchProjectId
|
||||
)
|
||||
)
|
||||
if (
|
||||
dispatchProjectReactivating ||
|
||||
dispatchProjectRequiresActivation
|
||||
) {
|
||||
if (queuedInput) {
|
||||
await releaseQueuedItem()
|
||||
} else {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: t(
|
||||
dispatchProjectRequiresActivation
|
||||
? 'notices.remoteProjectReconnectRequired'
|
||||
: 'notices.remoteProjectReactivating'
|
||||
)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!queuedInput && selectingContextFilesRef.current) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
@@ -7930,7 +7641,6 @@ function App(): React.JSX.Element {
|
||||
|
||||
<ProjectSwitcher
|
||||
activeProjectId={activeProjectId}
|
||||
onCancelRemoteActivation={cancelRemoteProjectActivation}
|
||||
runtimeSettings={runtimeSettings}
|
||||
onArchive={archiveProject}
|
||||
onCreate={createProject}
|
||||
@@ -7942,7 +7652,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
onUpdate={updateProject}
|
||||
projects={projects}
|
||||
remoteActivation={remoteProjectActivation}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
/>
|
||||
|
||||
@@ -9482,20 +9191,15 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
)) ||
|
||||
selectingContextFiles ||
|
||||
remoteProjectActivation?.projectId ===
|
||||
activeProjectId ||
|
||||
activeRemoteProjectRequiresActivation ||
|
||||
!runtime?.available ||
|
||||
runtimeSwitching ||
|
||||
runtimeStatusKey !== activeRuntimeSelectionKey
|
||||
}
|
||||
onClick={() => void submit()}
|
||||
title={
|
||||
activeRemoteProjectRequiresActivation
|
||||
? t('notices.remoteProjectReconnectRequired')
|
||||
: conversationExecutionRunning
|
||||
? t('composer.queueMessageTitle')
|
||||
: t('composer.sendTitle')
|
||||
conversationExecutionRunning
|
||||
? t('composer.queueMessageTitle')
|
||||
: t('composer.sendTitle')
|
||||
}
|
||||
>
|
||||
<Send aria-hidden="true" size={17} />
|
||||
@@ -10195,7 +9899,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
onNotify={notify}
|
||||
onLeaveRequestReady={registerSettingsLeaveRequester}
|
||||
onSshHostUpdated={handleSshHostUpdated}
|
||||
onProjectsDeleted={removeProjectsFromUi}
|
||||
onSaved={(settings) => {
|
||||
setRuntimeSettings(settings)
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
type RuntimeSettings
|
||||
} from '../../shared/contracts'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
import type { SshDirectoryBrowseResult } from '../../shared/ssh-host-contracts'
|
||||
import type {
|
||||
SshDirectoryBrowseResult,
|
||||
SshHost,
|
||||
SshHostRemoteEnvironment
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
import i18n from './i18n'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
|
||||
@@ -79,7 +83,9 @@ function renderSwitcher(
|
||||
} = {}
|
||||
): {
|
||||
onCreate: ReturnType<typeof vi.fn>
|
||||
onDelete: ReturnType<typeof vi.fn>
|
||||
onRemoteCommitted: ReturnType<typeof vi.fn>
|
||||
onSelect: ReturnType<typeof vi.fn>
|
||||
onSelectRoot: ReturnType<typeof vi.fn>
|
||||
onUpdate: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -93,14 +99,16 @@ function renderSwitcher(
|
||||
}))
|
||||
const onRemoteCommitted = vi.fn(async () => undefined)
|
||||
const onSelectRoot = vi.fn(async () => undefined)
|
||||
const onDelete = vi.fn(async () => undefined)
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
activeProjectId={currentProject.id}
|
||||
onArchive={vi.fn(async () => undefined)}
|
||||
onCreate={onCreate}
|
||||
onDelete={vi.fn(async () => undefined)}
|
||||
onDelete={onDelete}
|
||||
onRemoteCommitted={onRemoteCommitted}
|
||||
onSelect={vi.fn()}
|
||||
onSelect={onSelect}
|
||||
onSelectRoot={onSelectRoot}
|
||||
onUpdate={onUpdate}
|
||||
projects={projects}
|
||||
@@ -110,7 +118,9 @@ function renderSwitcher(
|
||||
)
|
||||
return {
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRemoteCommitted,
|
||||
onSelect,
|
||||
onSelectRoot,
|
||||
onUpdate
|
||||
}
|
||||
@@ -134,7 +144,9 @@ function directoryResult(
|
||||
}
|
||||
}
|
||||
|
||||
function installRemoteApi() {
|
||||
function installRemoteApi(
|
||||
options: { hostValidated?: boolean } = {}
|
||||
) {
|
||||
let progress: ((value: { phase: 'host' | 'agent' }) => void) | undefined
|
||||
const savedProject: AssistantProject = {
|
||||
...project,
|
||||
@@ -162,24 +174,61 @@ function installRemoteApi() {
|
||||
])
|
||||
)
|
||||
const cancelDirectoryBrowse = vi.fn(async () => undefined)
|
||||
const hostValidated = options.hostValidated !== false
|
||||
const host: SshHost = {
|
||||
id: hostId,
|
||||
name: 'Build host',
|
||||
hostname: 'build.example.com',
|
||||
port: 22,
|
||||
username: 'builder',
|
||||
authentication: 'system-agent',
|
||||
credentialConfigured: true,
|
||||
credentialSource: 'system-agent',
|
||||
hostKey: {
|
||||
state: hostValidated ? 'verified' : 'unverified',
|
||||
generation: 1
|
||||
},
|
||||
...(hostValidated
|
||||
? { lastValidatedAt: '2026-08-04T00:00:00.000Z' }
|
||||
: {}),
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
}
|
||||
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'
|
||||
}
|
||||
],
|
||||
hosts: [host],
|
||||
secureStorageAvailable: true
|
||||
}))
|
||||
const currentVersion = {
|
||||
version: '1.0.0',
|
||||
architecture: 'x64' as const
|
||||
}
|
||||
const getRemoteEnvironment = vi.fn(
|
||||
async (requestedHostId: string): Promise<SshHostRemoteEnvironment> => ({
|
||||
hostId: requestedHostId,
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
architecture: 'x64',
|
||||
agent: {
|
||||
state: 'current',
|
||||
expected: currentVersion,
|
||||
installed: currentVersion
|
||||
},
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
state: 'current',
|
||||
expected: currentVersion,
|
||||
installed: currentVersion
|
||||
}
|
||||
],
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'github',
|
||||
packageSize: 1024
|
||||
}
|
||||
})
|
||||
)
|
||||
const updateRemoteEnvironment = vi.fn(async () => undefined)
|
||||
const onSaveProgress = vi.fn(
|
||||
(listener: (value: { phase: 'host' | 'agent' }) => void) => {
|
||||
progress = listener
|
||||
@@ -192,7 +241,9 @@ function installRemoteApi() {
|
||||
sshHosts: {
|
||||
browseDirectories,
|
||||
cancelDirectoryBrowse,
|
||||
getSnapshot
|
||||
getSnapshot,
|
||||
getRemoteEnvironment,
|
||||
updateRemoteEnvironment
|
||||
},
|
||||
projects: {
|
||||
remote: {
|
||||
@@ -209,6 +260,8 @@ function installRemoteApi() {
|
||||
browseDirectories,
|
||||
cancelDirectoryBrowse,
|
||||
getSnapshot,
|
||||
getRemoteEnvironment,
|
||||
updateRemoteEnvironment,
|
||||
onSaveProgress,
|
||||
emit: (phase: 'host' | 'agent') => progress?.({ phase })
|
||||
}
|
||||
@@ -386,6 +439,60 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
expect(api.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes an unreachable remote project from the list without activating it', async () => {
|
||||
const api = installRemoteApi()
|
||||
const remoteProject: AssistantProject = {
|
||||
...project,
|
||||
id: remoteProjectId,
|
||||
name: 'Remote project',
|
||||
rootPath: '/srv/missing',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/missing'
|
||||
},
|
||||
runtimeSelection: { provider: 'opencode' }
|
||||
}
|
||||
const { onDelete, onSelect } = renderSwitcher(project, {
|
||||
projects: [project, remoteProject]
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByLabelText('当前项目'))
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitem', {
|
||||
name: '管理项目 Remote project'
|
||||
})
|
||||
)
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(api.browseDirectories).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '删除项目' })
|
||||
)
|
||||
fireEvent.change(
|
||||
within(dialog).getByLabelText(
|
||||
'输入“Remote project”确认删除'
|
||||
),
|
||||
{ target: { value: 'Remote project' } }
|
||||
)
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '永久删除项目'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onDelete).toHaveBeenCalledWith(
|
||||
remoteProjectId,
|
||||
'Remote project'
|
||||
)
|
||||
)
|
||||
expect(onSelect).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()
|
||||
@@ -855,4 +962,111 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
)
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the local validated Host record and defers remote checks to the requested action', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
)
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), {
|
||||
target: { value: 'Ready project' }
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
|
||||
target: { value: '/srv/project' }
|
||||
})
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '浏览远端工作目录'
|
||||
})
|
||||
).toBeEnabled()
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '保存远程项目'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(api.save).toHaveBeenCalledOnce())
|
||||
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables a Host that has not completed local validation', async () => {
|
||||
const api = installRemoteApi({ hostValidated: false })
|
||||
const remoteProject: AssistantProject = {
|
||||
...project,
|
||||
id: remoteProjectId,
|
||||
name: 'Remote project',
|
||||
rootPath: '/srv/project',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project'
|
||||
},
|
||||
runtimeSelection: { provider: 'opencode' }
|
||||
}
|
||||
renderSwitcher(remoteProject)
|
||||
fireEvent.click(screen.getByLabelText('项目设置'))
|
||||
const dialog = screen.getByRole('dialog', { name: '项目设置' })
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
/此主机尚未完成 Host Key 和连接验证/u
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('SSH 主机')).toHaveValue(hostId)
|
||||
expect(
|
||||
within(dialog).getByRole('option', {
|
||||
name: /Build host.*需要验证/u
|
||||
})
|
||||
).toBeDisabled()
|
||||
const save = within(dialog).getByRole('button', {
|
||||
name: '保存远程项目'
|
||||
})
|
||||
expect(save).toBeDisabled()
|
||||
fireEvent.click(save)
|
||||
expect(api.save).not.toHaveBeenCalled()
|
||||
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never probes remote environments when the project form opens or reopens', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
let dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
)
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '取消' })
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
)
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,11 +50,6 @@ type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
remoteProjectsEnabled?: boolean
|
||||
remoteActivation?: {
|
||||
projectId: string
|
||||
phase: RemoteProjectSavePhase
|
||||
cancelling: boolean
|
||||
}
|
||||
runtimeSettings?: RuntimeSettings
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
|
||||
@@ -62,7 +57,6 @@ type ProjectSwitcherProps = {
|
||||
onSelect: (projectId: string) => void
|
||||
onSelectRoot: () => Promise<string | undefined>
|
||||
onRemoteCommitted: (project: AssistantProject) => Promise<void>
|
||||
onCancelRemoteActivation?: () => void
|
||||
onUpdate: (
|
||||
projectId: string,
|
||||
input: ProjectCreateInput
|
||||
@@ -76,20 +70,21 @@ const remoteProjectPhases: readonly RemoteProjectSavePhase[] = [
|
||||
'runtime',
|
||||
'saving'
|
||||
]
|
||||
type RemoteHostReadiness =
|
||||
| { status: 'ready' }
|
||||
| { status: 'unready' }
|
||||
|
||||
function RemoteProjectProgress({
|
||||
cancelling = false,
|
||||
onCancel,
|
||||
phase,
|
||||
phases = remoteProjectPhases,
|
||||
title
|
||||
}: {
|
||||
cancelling?: boolean
|
||||
onCancel?: () => void
|
||||
phase: RemoteProjectSavePhase
|
||||
phases?: readonly RemoteProjectSavePhase[]
|
||||
title: string
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const phaseIndex = remoteProjectPhases.indexOf(phase)
|
||||
const phaseIndex = phases.indexOf(phase)
|
||||
return (
|
||||
<section
|
||||
aria-live="polite"
|
||||
@@ -99,28 +94,26 @@ function RemoteProjectProgress({
|
||||
<div className="remote-project-progress__header">
|
||||
<strong>{title}</strong>
|
||||
<span>
|
||||
{cancelling
|
||||
? t('projectSwitcher.remote.activation.cancelling')
|
||||
: t('projectSwitcher.remote.phaseStatus', {
|
||||
phase: t(
|
||||
`projectSwitcher.remote.phases.${phase}`
|
||||
)
|
||||
})}
|
||||
{t('projectSwitcher.remote.phaseStatus', {
|
||||
phase: t(
|
||||
`projectSwitcher.remote.phases.${phase}`
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={t(
|
||||
'projectSwitcher.remote.activation.progressLabel'
|
||||
'projectSwitcher.remote.progress.progressLabel'
|
||||
)}
|
||||
max={remoteProjectPhases.length}
|
||||
max={phases.length}
|
||||
value={phaseIndex + 1}
|
||||
/>
|
||||
<ol
|
||||
aria-label={t(
|
||||
'projectSwitcher.remote.activation.stepsLabel'
|
||||
'projectSwitcher.remote.progress.stepsLabel'
|
||||
)}
|
||||
>
|
||||
{remoteProjectPhases.map((candidate, index) => {
|
||||
{phases.map((candidate, index) => {
|
||||
const state =
|
||||
index < phaseIndex
|
||||
? 'complete'
|
||||
@@ -151,18 +144,6 @@ function RemoteProjectProgress({
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{onCancel && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={cancelling}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelling
|
||||
? t('projectSwitcher.remote.activation.cancellingAction')
|
||||
: t('projectSwitcher.remote.activation.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -175,12 +156,10 @@ export function ProjectSwitcher({
|
||||
projects,
|
||||
activeProjectId,
|
||||
remoteProjectsEnabled = false,
|
||||
remoteActivation,
|
||||
runtimeSettings,
|
||||
onArchive,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onCancelRemoteActivation,
|
||||
onRemoteCommitted,
|
||||
onSelect,
|
||||
onSelectRoot,
|
||||
@@ -190,6 +169,8 @@ export function ProjectSwitcher({
|
||||
const [dialogMode, setDialogMode] = useState<
|
||||
'create' | 'settings'
|
||||
>()
|
||||
const [settingsProjectId, setSettingsProjectId] =
|
||||
useState<string>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -201,6 +182,9 @@ export function ProjectSwitcher({
|
||||
>('local')
|
||||
const [sshHosts, setSshHosts] = useState<SshHostsSnapshot>()
|
||||
const [loadingSshHosts, setLoadingSshHosts] = useState(false)
|
||||
const [remoteHostReadiness, setRemoteHostReadiness] = useState<
|
||||
Record<string, RemoteHostReadiness>
|
||||
>({})
|
||||
const [remoteHostId, setRemoteHostId] = useState('')
|
||||
const [remoteRootPath, setRemoteRootPath] = useState('')
|
||||
const [remoteSaving, setRemoteSaving] = useState(false)
|
||||
@@ -223,7 +207,7 @@ export function ProjectSwitcher({
|
||||
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'settings' | undefined
|
||||
'create' | 'settings' | 'picker' | undefined
|
||||
>(undefined)
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<ProjectCreateInput>({
|
||||
@@ -238,6 +222,9 @@ export function ProjectSwitcher({
|
||||
const activeProjectDisplay = activeProject
|
||||
? getProjectDisplayText(activeProject, t)
|
||||
: undefined
|
||||
const settingsProject = projects.find(
|
||||
(project) => project.id === settingsProjectId
|
||||
)
|
||||
const localProjects = projects.filter(
|
||||
(project) =>
|
||||
project.kind === 'user' &&
|
||||
@@ -259,28 +246,23 @@ export function ProjectSwitcher({
|
||||
saving ||
|
||||
remoteSaving ||
|
||||
archiving ||
|
||||
deleting ||
|
||||
(remoteProjectsEnabled && remoteActivation !== undefined)
|
||||
deleting
|
||||
const remoteFieldsDisabled = busy
|
||||
const remoteDirectoryBrowseDisabled =
|
||||
remoteFieldsDisabled ||
|
||||
loadingSshHosts ||
|
||||
!remoteHostId ||
|
||||
remoteHostReadiness[remoteHostId]?.status !== 'ready' ||
|
||||
dialogMode === 'settings'
|
||||
const selectedRemoteHostReadiness =
|
||||
remoteHostReadiness[remoteHostId]
|
||||
const remoteDraftReady =
|
||||
Boolean(
|
||||
draft.name.trim() &&
|
||||
remoteHostId &&
|
||||
selectedRemoteHostReadiness?.status === 'ready' &&
|
||||
isRemoteAbsolutePath(remoteRootPath)
|
||||
)
|
||||
const activatingProject = remoteActivation
|
||||
? projects.find(
|
||||
(project) => project.id === remoteActivation.projectId
|
||||
)
|
||||
: undefined
|
||||
const activatingProjectName = activatingProject
|
||||
? getProjectDisplayText(activatingProject, t).name
|
||||
: t('projectSwitcher.selector.empty')
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -309,11 +291,27 @@ export function ProjectSwitcher({
|
||||
return
|
||||
}
|
||||
setSshHosts(snapshot)
|
||||
const readiness: Record<string, RemoteHostReadiness> =
|
||||
Object.fromEntries(
|
||||
snapshot.hosts.map((host) => [
|
||||
host.id,
|
||||
host.hostKey.state === 'verified' &&
|
||||
host.lastValidatedAt !== undefined
|
||||
? { status: 'ready' as const }
|
||||
: { status: 'unready' as const }
|
||||
])
|
||||
)
|
||||
setRemoteHostReadiness(readiness)
|
||||
setRemoteHostId((current) =>
|
||||
current &&
|
||||
snapshot.hosts.some((host) => host.id === current)
|
||||
? current
|
||||
: snapshot.hosts[0]?.id ?? ''
|
||||
: snapshot.hosts.find(
|
||||
(host) =>
|
||||
readiness[host.id]?.status === 'ready'
|
||||
)?.id ??
|
||||
snapshot.hosts[0]?.id ??
|
||||
''
|
||||
)
|
||||
setLoadingSshHosts(false)
|
||||
},
|
||||
@@ -350,7 +348,7 @@ export function ProjectSwitcher({
|
||||
if (
|
||||
remoteProjectsEnabled ||
|
||||
dialogMode !== 'settings' ||
|
||||
activeProject?.executionSpace.kind !== 'ssh'
|
||||
settingsProject?.executionSpace.kind !== 'ssh'
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -365,12 +363,13 @@ export function ProjectSwitcher({
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [activeProject, dialogMode, remoteProjectsEnabled])
|
||||
}, [dialogMode, remoteProjectsEnabled, settingsProject])
|
||||
|
||||
const resetRemoteDraft = (): void => {
|
||||
directoryBrowseRequestRef.current += 1
|
||||
setSshHosts(undefined)
|
||||
setLoadingSshHosts(false)
|
||||
setRemoteHostReadiness({})
|
||||
setRemoteHostId('')
|
||||
setRemoteRootPath('')
|
||||
setRemoteSaving(false)
|
||||
@@ -444,7 +443,7 @@ export function ProjectSwitcher({
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const items = Array.from(
|
||||
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitemradio"]'
|
||||
'[role^="menuitem"]'
|
||||
) ?? []
|
||||
)
|
||||
const initialItem =
|
||||
@@ -489,6 +488,8 @@ export function ProjectSwitcher({
|
||||
createButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'settings') {
|
||||
settingsButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'picker') {
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
restoreFocusTarget.current = undefined
|
||||
return
|
||||
@@ -546,6 +547,53 @@ export function ProjectSwitcher({
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setDialogMode(undefined)
|
||||
setSettingsProjectId(undefined)
|
||||
}
|
||||
|
||||
const openProjectSettings = (
|
||||
project: AssistantProject,
|
||||
restoreTarget: 'settings' | 'picker'
|
||||
): void => {
|
||||
if (
|
||||
!remoteProjectsEnabled &&
|
||||
project.executionSpace.kind === 'ssh'
|
||||
) {
|
||||
return
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
resetRemoteDraft()
|
||||
setSettingsProjectId(project.id)
|
||||
const nextExecutionSpaceKind =
|
||||
project.executionSpace.kind === 'ssh' ? 'ssh' : 'local'
|
||||
setExecutionSpaceKind(nextExecutionSpaceKind)
|
||||
if (project.executionSpace.kind === 'ssh') {
|
||||
setLoadingSshHosts(true)
|
||||
setRemoteHostId(project.executionSpace.hostId)
|
||||
setRemoteRootPath(project.executionSpace.remoteRootPath)
|
||||
}
|
||||
const nextDraft: ProjectCreateInput = {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
rootPath: project.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
project.defaultWorkMode
|
||||
),
|
||||
runtimeSelection:
|
||||
project.runtimeSelection ??
|
||||
(runtimeSettings
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: undefined)
|
||||
}
|
||||
setDraft(
|
||||
project.kind === 'channel' && runtimeSettings
|
||||
? channelProjectDraft(nextDraft, runtimeSettings)
|
||||
: nextDraft
|
||||
)
|
||||
restoreFocusTarget.current = restoreTarget
|
||||
setDialogMode('settings')
|
||||
}
|
||||
|
||||
const validateRemoteDraft = (): boolean => {
|
||||
@@ -553,6 +601,12 @@ export function ProjectSwitcher({
|
||||
setError(t('projectSwitcher.remote.validation.host'))
|
||||
return false
|
||||
}
|
||||
if (remoteHostReadiness[remoteHostId]?.status !== 'ready') {
|
||||
setError(
|
||||
t('projectSwitcher.remote.readiness.saveBlocked')
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (!isRemoteAbsolutePath(remoteRootPath)) {
|
||||
setError(t('projectSwitcher.remote.validation.root'))
|
||||
return false
|
||||
@@ -588,12 +642,12 @@ export function ProjectSwitcher({
|
||||
}
|
||||
const result =
|
||||
await window.goodbuddy.projects.remote.save(
|
||||
dialogMode === 'settings' && activeProject
|
||||
dialogMode === 'settings' && settingsProject
|
||||
? {
|
||||
intent: 'update',
|
||||
draft: {
|
||||
...modeDraft,
|
||||
projectId: activeProject.id
|
||||
projectId: settingsProject.id
|
||||
}
|
||||
}
|
||||
: {
|
||||
@@ -627,8 +681,8 @@ export function ProjectSwitcher({
|
||||
...draft,
|
||||
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
|
||||
}
|
||||
if (dialogMode === 'settings' && activeProject) {
|
||||
await onUpdate(activeProject.id, input)
|
||||
if (dialogMode === 'settings' && settingsProject) {
|
||||
await onUpdate(settingsProject.id, input)
|
||||
} else {
|
||||
await onCreate(input)
|
||||
}
|
||||
@@ -668,10 +722,13 @@ export function ProjectSwitcher({
|
||||
}
|
||||
|
||||
const archive = async (): Promise<void> => {
|
||||
if (!settingsProject) {
|
||||
return
|
||||
}
|
||||
setArchiving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onArchive(activeProjectId)
|
||||
await onArchive(settingsProject.id)
|
||||
closeDialog()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
@@ -686,13 +743,13 @@ export function ProjectSwitcher({
|
||||
}
|
||||
|
||||
const deleteProject = async (): Promise<void> => {
|
||||
if (!activeProject) {
|
||||
if (!settingsProject) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onDelete(activeProject.id, deleteConfirmation)
|
||||
await onDelete(settingsProject.id, deleteConfirmation)
|
||||
closeDialog()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
@@ -718,9 +775,6 @@ export function ProjectSwitcher({
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__trigger"
|
||||
disabled={
|
||||
remoteProjectsEnabled && remoteActivation !== undefined
|
||||
}
|
||||
onClick={() => setProjectMenuOpen((open) => !open)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -750,7 +804,7 @@ export function ProjectSwitcher({
|
||||
onKeyDown={(event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitemradio"]'
|
||||
'[role^="menuitem"]'
|
||||
)
|
||||
)
|
||||
const currentIndex = items.indexOf(
|
||||
@@ -775,9 +829,11 @@ export function ProjectSwitcher({
|
||||
nextIndex === undefined ? undefined : items.at(nextIndex)
|
||||
if (nextItem) {
|
||||
event.preventDefault()
|
||||
items.forEach((item) => {
|
||||
item.tabIndex = item === nextItem ? 0 : -1
|
||||
})
|
||||
const currentItem = items.at(currentIndex)
|
||||
if (currentItem) {
|
||||
currentItem.tabIndex = -1
|
||||
}
|
||||
nextItem.tabIndex = 0
|
||||
nextItem.focus()
|
||||
}
|
||||
}}
|
||||
@@ -842,34 +898,53 @@ export function ProjectSwitcher({
|
||||
{ path: project.rootPath }
|
||||
)
|
||||
return (
|
||||
<button
|
||||
aria-checked={selected}
|
||||
<div
|
||||
className="project-switcher__menu-item"
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
if (
|
||||
!selected ||
|
||||
project.executionSpace.kind === 'ssh'
|
||||
) {
|
||||
onSelect(project.id)
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
requestAnimationFrame(() =>
|
||||
projectPickerButtonRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<ProjectIcon aria-hidden="true" size={16} />
|
||||
<span>
|
||||
<b>{projectDisplay.name}</b>
|
||||
<small>{detail}</small>
|
||||
</span>
|
||||
{selected && (
|
||||
<Check aria-hidden="true" size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-checked={selected}
|
||||
onClick={() => {
|
||||
if (
|
||||
!selected ||
|
||||
project.executionSpace.kind === 'ssh'
|
||||
) {
|
||||
onSelect(project.id)
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
requestAnimationFrame(() =>
|
||||
projectPickerButtonRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<ProjectIcon aria-hidden="true" size={16} />
|
||||
<span>
|
||||
<b>{projectDisplay.name}</b>
|
||||
<small>{detail}</small>
|
||||
</span>
|
||||
{selected && (
|
||||
<Check aria-hidden="true" size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.settingsNamed',
|
||||
{ name: projectDisplay.name }
|
||||
)}
|
||||
className="project-switcher__menu-settings"
|
||||
onClick={() =>
|
||||
openProjectSettings(project, 'picker')
|
||||
}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<Settings aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -881,14 +956,12 @@ export function ProjectSwitcher({
|
||||
<button
|
||||
aria-label={t('projectSwitcher.selector.create')}
|
||||
className="icon-button"
|
||||
disabled={
|
||||
remoteProjectsEnabled && remoteActivation !== undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
setSettingsProjectId(undefined)
|
||||
resetRemoteDraft()
|
||||
setExecutionSpaceKind('local')
|
||||
setDraft({
|
||||
@@ -913,7 +986,6 @@ export function ProjectSwitcher({
|
||||
className="icon-button"
|
||||
disabled={
|
||||
!activeProject ||
|
||||
(remoteProjectsEnabled && remoteActivation !== undefined) ||
|
||||
(!remoteProjectsEnabled &&
|
||||
activeProject.executionSpace.kind === 'ssh')
|
||||
}
|
||||
@@ -921,49 +993,7 @@ export function ProjectSwitcher({
|
||||
if (!activeProject) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!remoteProjectsEnabled &&
|
||||
activeProject.executionSpace.kind === 'ssh'
|
||||
) {
|
||||
return
|
||||
}
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
setDeleteConfirmation('')
|
||||
resetRemoteDraft()
|
||||
const nextExecutionSpaceKind =
|
||||
activeProject.executionSpace.kind === 'ssh'
|
||||
? 'ssh'
|
||||
: 'local'
|
||||
setExecutionSpaceKind(nextExecutionSpaceKind)
|
||||
if (activeProject.executionSpace.kind === 'ssh') {
|
||||
setLoadingSshHosts(true)
|
||||
setRemoteHostId(activeProject.executionSpace.hostId)
|
||||
setRemoteRootPath(
|
||||
activeProject.executionSpace.remoteRootPath
|
||||
)
|
||||
}
|
||||
const nextDraft: ProjectCreateInput = {
|
||||
name: activeProject.name,
|
||||
description: activeProject.description,
|
||||
rootPath: activeProject.rootPath,
|
||||
defaultWorkMode: normalizeInteractiveWorkMode(
|
||||
activeProject.defaultWorkMode
|
||||
),
|
||||
runtimeSelection:
|
||||
activeProject.runtimeSelection ??
|
||||
(runtimeSettings
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: undefined)
|
||||
}
|
||||
setDraft(
|
||||
activeProject.kind === 'channel' && runtimeSettings
|
||||
? channelProjectDraft(nextDraft, runtimeSettings)
|
||||
: nextDraft
|
||||
)
|
||||
restoreFocusTarget.current = 'settings'
|
||||
setDialogMode('settings')
|
||||
openProjectSettings(activeProject, 'settings')
|
||||
}}
|
||||
ref={settingsButtonRef}
|
||||
type="button"
|
||||
@@ -971,17 +1001,6 @@ export function ProjectSwitcher({
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{remoteProjectsEnabled && remoteActivation && (
|
||||
<RemoteProjectProgress
|
||||
cancelling={remoteActivation.cancelling}
|
||||
onCancel={onCancelRemoteActivation}
|
||||
phase={remoteActivation.phase}
|
||||
title={t(
|
||||
'projectSwitcher.remote.activation.title',
|
||||
{ project: activatingProjectName }
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{dialogMode && (
|
||||
<div
|
||||
className="project-create-backdrop"
|
||||
@@ -1019,7 +1038,7 @@ export function ProjectSwitcher({
|
||||
</button>
|
||||
</header>
|
||||
{dialogMode === 'settings' &&
|
||||
activeProject?.kind === 'channel' &&
|
||||
settingsProject?.kind === 'channel' &&
|
||||
runtimeSettings ? (
|
||||
<ChannelProjectSettingsFields
|
||||
autoFocus
|
||||
@@ -1210,9 +1229,10 @@ export function ProjectSwitcher({
|
||||
loadingSshHosts ||
|
||||
dialogMode === 'settings'
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange={(event) => {
|
||||
setError(undefined)
|
||||
setRemoteHostId(event.target.value)
|
||||
}
|
||||
}}
|
||||
value={remoteHostId}
|
||||
>
|
||||
{loadingSshHosts && (
|
||||
@@ -1230,16 +1250,47 @@ export function ProjectSwitcher({
|
||||
)}
|
||||
</option>
|
||||
)}
|
||||
{sshHosts?.hosts.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name} · {host.username}@
|
||||
{host.hostname}
|
||||
</option>
|
||||
))}
|
||||
{sshHosts?.hosts.map((host) => {
|
||||
const readiness =
|
||||
remoteHostReadiness[host.id]
|
||||
const readinessLabel = readiness
|
||||
? t(
|
||||
`projectSwitcher.remote.readiness.options.${readiness.status}`
|
||||
)
|
||||
: t(
|
||||
'projectSwitcher.remote.readiness.options.loading'
|
||||
)
|
||||
return (
|
||||
<option
|
||||
disabled={readiness?.status !== 'ready'}
|
||||
key={host.id}
|
||||
value={host.id}
|
||||
>
|
||||
{host.name} · {host.username}@
|
||||
{host.hostname} · {readinessLabel}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<small>
|
||||
{t('projectSwitcher.remote.hostHelp')}
|
||||
</small>
|
||||
{remoteHostId &&
|
||||
selectedRemoteHostReadiness && (
|
||||
<small
|
||||
aria-live="polite"
|
||||
role={
|
||||
selectedRemoteHostReadiness.status ===
|
||||
'unready'
|
||||
? 'alert'
|
||||
: 'status'
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`projectSwitcher.remote.readiness.${selectedRemoteHostReadiness.status}`
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
@@ -1528,7 +1579,7 @@ export function ProjectSwitcher({
|
||||
</p>
|
||||
)}
|
||||
{dialogMode === 'settings' &&
|
||||
activeProject?.kind !== 'channel' && (
|
||||
settingsProject?.kind !== 'channel' && (
|
||||
<section
|
||||
aria-labelledby="project-danger-title"
|
||||
className="project-danger-zone"
|
||||
@@ -1561,7 +1612,7 @@ export function ProjectSwitcher({
|
||||
<span>
|
||||
{t(
|
||||
'projectSwitcher.dialog.danger.confirmation',
|
||||
{ projectName: activeProject?.name }
|
||||
{ projectName: settingsProject?.name }
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
@@ -1590,7 +1641,7 @@ export function ProjectSwitcher({
|
||||
className="danger-button"
|
||||
disabled={
|
||||
busy ||
|
||||
deleteConfirmation !== activeProject?.name
|
||||
deleteConfirmation !== settingsProject?.name
|
||||
}
|
||||
onClick={() => void deleteProject()}
|
||||
type="button"
|
||||
@@ -1616,9 +1667,9 @@ export function ProjectSwitcher({
|
||||
)}
|
||||
<div className="project-create-card__actions">
|
||||
{dialogMode === 'settings' &&
|
||||
activeProject?.kind !== 'channel' &&
|
||||
settingsProject?.kind !== 'channel' &&
|
||||
userProjects.length > 1 &&
|
||||
activeProjectId && (
|
||||
settingsProject && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
|
||||
@@ -862,7 +862,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
onAgentPackageProgress,
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async (hostId: string) => ({
|
||||
hostId,
|
||||
deletedProjects: []
|
||||
})),
|
||||
inspectHostKey: vi.fn(),
|
||||
acceptHostKey: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
|
||||
@@ -211,7 +211,7 @@ type SettingsPanelProps = {
|
||||
initialCategory?: SettingsCategoryId
|
||||
initialChannel?: ProjectChannel
|
||||
onClose: () => void
|
||||
onSshHostUpdated?: (hostId: string) => void
|
||||
onProjectsDeleted?: (projectIds: string[]) => void
|
||||
onSaved: (settings: RuntimeSettings) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onUpdateProject: (
|
||||
@@ -585,7 +585,7 @@ export function SettingsPanel({
|
||||
initialCategory,
|
||||
initialChannel,
|
||||
onClose,
|
||||
onSshHostUpdated = () => {},
|
||||
onProjectsDeleted = () => {},
|
||||
onSaved,
|
||||
onNotify = () => {},
|
||||
onUpdateProject,
|
||||
@@ -3759,8 +3759,8 @@ export function SettingsPanel({
|
||||
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
|
||||
<SshHostsSettingsSection
|
||||
onDirtyChange={setSshHostsDirty}
|
||||
onHostUpdated={onSshHostUpdated}
|
||||
onNotify={onNotify}
|
||||
onProjectsDeleted={onProjectsDeleted}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
waitFor
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type {
|
||||
RemoteEnvironmentPreparationMethod,
|
||||
RemoteEnvironmentUpdateProgress,
|
||||
SshHost,
|
||||
SshHostRemovalResult,
|
||||
SshHostRemoteEnvironment,
|
||||
SshHostValidationResult,
|
||||
SshHostsSnapshot
|
||||
@@ -67,14 +70,20 @@ const validationResult: SshHostValidationResult = {
|
||||
}
|
||||
|
||||
const getSnapshot = vi.fn<() => Promise<SshHostsSnapshot>>()
|
||||
const remove = vi.fn()
|
||||
const remove =
|
||||
vi.fn<(hostId: string) => Promise<SshHostRemovalResult>>()
|
||||
const inspectDraftHostKey = vi.fn()
|
||||
const discardCandidate = vi.fn()
|
||||
const validateAndSave = vi.fn()
|
||||
const getRemoteEnvironment =
|
||||
vi.fn<(hostId: string) => Promise<SshHostRemoteEnvironment>>()
|
||||
const updateRemoteEnvironment =
|
||||
vi.fn<(hostId: string) => Promise<void>>()
|
||||
vi.fn<
|
||||
(input: {
|
||||
hostId: string
|
||||
method: RemoteEnvironmentPreparationMethod
|
||||
}) => Promise<void>
|
||||
>()
|
||||
const cancelRemoteEnvironmentUpdate =
|
||||
vi.fn<(hostId: string) => Promise<void>>()
|
||||
let remoteEnvironmentUpdateProgressListener:
|
||||
@@ -117,7 +126,12 @@ const remoteEnvironment: SshHostRemoteEnvironment = {
|
||||
version: '1.18.9',
|
||||
architecture: 'x64'
|
||||
}
|
||||
}]
|
||||
}],
|
||||
remoteDownload: {
|
||||
available: true,
|
||||
source: 'mirror',
|
||||
packageSize: 64 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
const currentRemoteEnvironment: SshHostRemoteEnvironment = {
|
||||
@@ -129,6 +143,15 @@ const currentRemoteEnvironment: SshHostRemoteEnvironment = {
|
||||
}
|
||||
}
|
||||
|
||||
const notInstalledRemoteEnvironment: SshHostRemoteEnvironment = {
|
||||
...remoteEnvironment,
|
||||
agent: {
|
||||
...remoteEnvironment.agent,
|
||||
state: 'not-installed',
|
||||
installed: null
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
@@ -181,12 +204,28 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
|
||||
fireEvent.click(continueButton)
|
||||
}
|
||||
|
||||
async function refreshHostEnvironment(
|
||||
name = 'Build host'
|
||||
): Promise<HTMLElement> {
|
||||
const host = await screen.findByRole('region', { name })
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', {
|
||||
name: `刷新 ${name} 的远程运行环境版本`
|
||||
})
|
||||
)
|
||||
await within(host).findByText('GoodBuddy Agent')
|
||||
return host
|
||||
}
|
||||
|
||||
describe('SshHostsSettingsSection', () => {
|
||||
beforeEach(async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
vi.clearAllMocks()
|
||||
getSnapshot.mockResolvedValue(emptySnapshot)
|
||||
remove.mockResolvedValue(undefined)
|
||||
remove.mockResolvedValue({
|
||||
hostId,
|
||||
deletedProjects: []
|
||||
})
|
||||
inspectDraftHostKey.mockResolvedValue({
|
||||
candidateId,
|
||||
state: 'unverified',
|
||||
@@ -484,12 +523,12 @@ describe('SshHostsSettingsSection', () => {
|
||||
<SshHostsSettingsSection onHostUpdated={onHostUpdated} />
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
getRemoteEnvironment.mockClear()
|
||||
await screen.findByRole('button', {
|
||||
name: '编辑 Build host'
|
||||
})
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
screen.getByRole('button', {
|
||||
name: '编辑 Build host'
|
||||
})
|
||||
)
|
||||
@@ -572,6 +611,79 @@ describe('SshHostsSettingsSection', () => {
|
||||
expect(inspectButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('lists and locally removes related projects with a deleted Host', async () => {
|
||||
const remoteProject: AssistantProject = {
|
||||
id: '00000000-0000-4000-8000-000000000106',
|
||||
name: '无法连接的远程项目',
|
||||
description: '',
|
||||
rootPath: '/srv/missing',
|
||||
executionSpace: {
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/missing'
|
||||
},
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: { provider: 'opencode' },
|
||||
kind: 'user',
|
||||
status: 'active',
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
}
|
||||
getSnapshot.mockResolvedValue({
|
||||
...verifiedSnapshot,
|
||||
projectReferences: {
|
||||
[hostId]: [
|
||||
{ id: remoteProject.id, name: remoteProject.name }
|
||||
]
|
||||
}
|
||||
})
|
||||
remove.mockResolvedValue({
|
||||
hostId,
|
||||
deletedProjects: [
|
||||
{ id: remoteProject.id, name: remoteProject.name }
|
||||
]
|
||||
})
|
||||
const onProjectsDeleted = vi.fn()
|
||||
render(
|
||||
<SshHostsSettingsSection
|
||||
onProjectsDeleted={onProjectsDeleted}
|
||||
/>
|
||||
)
|
||||
const host = await screen.findByRole('region', {
|
||||
name: 'Build host'
|
||||
})
|
||||
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', { name: '删除' })
|
||||
)
|
||||
const confirmation = screen.getByRole('alertdialog')
|
||||
expect(
|
||||
within(confirmation).getByText('同时删除以下关联项目记录:')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(confirmation).getByText(remoteProject.name)
|
||||
).toBeInTheDocument()
|
||||
expect(confirmation).toHaveTextContent(
|
||||
'不会连接主机,也不会删除远端目录或内容'
|
||||
)
|
||||
fireEvent.click(
|
||||
within(confirmation).getByRole('button', {
|
||||
name: '确认删除'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(remove).toHaveBeenCalledWith(hostId)
|
||||
)
|
||||
expect(onProjectsDeleted).toHaveBeenCalledWith([
|
||||
remoteProject.id
|
||||
])
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'Build host' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers existing unverified hosts a validate action', async () => {
|
||||
getSnapshot.mockResolvedValue({
|
||||
hosts: [legacyUnverifiedHost],
|
||||
@@ -595,17 +707,74 @@ describe('SshHostsSettingsSection', () => {
|
||||
expect(
|
||||
await screen.findByText('远程运行环境')
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
||||
)
|
||||
const host = screen.getByRole('region', { name: 'Build host' })
|
||||
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
const host = await refreshHostEnvironment()
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
within(host).queryByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(host).getByText('GoodBuddy Agent')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(host).getByText('OpenCode Runtime')
|
||||
).toBeInTheDocument()
|
||||
expect(within(host).getByText('待更新')).toBeInTheDocument()
|
||||
expect(within(host).getByText('当前版本')).toBeInTheDocument()
|
||||
expect(within(host).getByText('版本匹配')).toBeInTheDocument()
|
||||
expect(within(host).getByText('0.10.4')).toBeInTheDocument()
|
||||
expect(within(host).getByText('0.11.1')).toBeInTheDocument()
|
||||
expect(within(host).getAllByText('1.18.9')).toHaveLength(2)
|
||||
expect(within(host).getByText('安装方式')).toBeInTheDocument()
|
||||
expect(
|
||||
within(host)
|
||||
.getByRole('button', {
|
||||
name: '刷新 Build host 的远程运行环境版本'
|
||||
})
|
||||
.closest('.ssh-host-environment__header')
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
within(host)
|
||||
.getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
.closest('.ssh-host-environment__toolbar')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not contact any verified Host when the section opens', async () => {
|
||||
getSnapshot.mockResolvedValue({
|
||||
hosts: [
|
||||
verifiedHost,
|
||||
{
|
||||
...verifiedHost,
|
||||
id: candidateId,
|
||||
name: 'Deploy host',
|
||||
hostname: 'deploy.example.com'
|
||||
}
|
||||
],
|
||||
secureStorageAvailable: true
|
||||
})
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
await screen.findByRole('region', { name: 'Deploy host' })
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
|
||||
await refreshHostEnvironment()
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
||||
})
|
||||
|
||||
it('keeps Host details visible and retries a failed version refresh', async () => {
|
||||
@@ -615,6 +784,14 @@ describe('SshHostsSettingsSection', () => {
|
||||
.mockResolvedValueOnce(remoteEnvironment)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
await screen.findByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '刷新 Build host 的远程运行环境版本'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('Host unreachable')
|
||||
).toBeInTheDocument()
|
||||
@@ -630,22 +807,196 @@ describe('SshHostsSettingsSection', () => {
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('places Update immediately before Refresh and disables it when versions are current', async () => {
|
||||
it.each([
|
||||
[notInstalledRemoteEnvironment, '为 Build host 安装远程环境'],
|
||||
[remoteEnvironment, '为 Build host 更新远程环境'],
|
||||
[currentRemoteEnvironment, '为 Build host 重新安装远程环境']
|
||||
])('names the single main environment action from version facts', async (
|
||||
environment,
|
||||
actionName
|
||||
) => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
getRemoteEnvironment.mockResolvedValue(currentRemoteEnvironment)
|
||||
getRemoteEnvironment.mockResolvedValue(environment)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
const host = screen.getByRole('region', { name: 'Build host' })
|
||||
const updateButton = within(host).getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
const host = await refreshHostEnvironment()
|
||||
expect(
|
||||
within(host).getByRole('button', { name: actionName })
|
||||
).toBeEnabled()
|
||||
expect(
|
||||
within(host).getAllByRole('button', {
|
||||
name: /为 Build host (?:安装|更新|重新安装)远程环境/u
|
||||
})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults to auto, supports arrow-key selection, and requests all three methods', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
const host = await refreshHostEnvironment()
|
||||
let automatic = within(host).getByRole('button', {
|
||||
name: '自动'
|
||||
})
|
||||
const refreshButton = within(host).getByRole('button', {
|
||||
name: '刷新 Build host 的远程运行环境版本'
|
||||
let hostDownload: HTMLElement
|
||||
let mainAction = within(host).getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
expect(updateButton.nextElementSibling).toBe(refreshButton)
|
||||
expect(updateButton).toBeDisabled()
|
||||
expect(updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
|
||||
expect(automatic).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(mainAction)
|
||||
await waitFor(() =>
|
||||
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
|
||||
hostId,
|
||||
method: 'auto'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(host).getByRole('button', { name: '自动' })
|
||||
).toBeEnabled()
|
||||
)
|
||||
automatic = within(host).getByRole('button', { name: '自动' })
|
||||
hostDownload = within(host).getByRole('button', {
|
||||
name: 'Host 下载'
|
||||
})
|
||||
mainAction = within(host).getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
|
||||
automatic.focus()
|
||||
fireEvent.keyDown(automatic, { key: 'ArrowRight' })
|
||||
expect(hostDownload).toHaveFocus()
|
||||
expect(hostDownload).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(mainAction)
|
||||
await waitFor(() =>
|
||||
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
|
||||
hostId,
|
||||
method: 'remote-download'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(host).getByRole('button', { name: 'Host 下载' })
|
||||
).toBeEnabled()
|
||||
)
|
||||
hostDownload = within(host).getByRole('button', {
|
||||
name: 'Host 下载'
|
||||
})
|
||||
const goodbuddyTransfer = within(host).getByRole('button', {
|
||||
name: 'GoodBuddy 传输'
|
||||
})
|
||||
mainAction = within(host).getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
|
||||
fireEvent.keyDown(hostDownload, { key: 'ArrowRight' })
|
||||
expect(goodbuddyTransfer).toHaveFocus()
|
||||
expect(goodbuddyTransfer).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(mainAction)
|
||||
await waitFor(() =>
|
||||
expect(updateRemoteEnvironment).toHaveBeenLastCalledWith({
|
||||
hostId,
|
||||
method: 'goodbuddy-transfer'
|
||||
})
|
||||
)
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('disables only an explicitly unavailable Host download method', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
getRemoteEnvironment.mockResolvedValue({
|
||||
...remoteEnvironment,
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: 'mirror',
|
||||
packageSize: 64 * 1024 * 1024,
|
||||
reason: 'source-unreachable'
|
||||
}
|
||||
})
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
const host = await refreshHostEnvironment()
|
||||
const mainAction = within(host).getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
expect(mainAction).toBeEnabled()
|
||||
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', { name: 'Host 下载' })
|
||||
)
|
||||
expect(mainAction).toBeDisabled()
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', {
|
||||
name: 'GoodBuddy 传输'
|
||||
})
|
||||
)
|
||||
expect(mainAction).toBeEnabled()
|
||||
expect(host).toHaveTextContent(
|
||||
'远程主机无法连接所选下载源(镜像节点)'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows a direct retry when its capability check fails', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
getRemoteEnvironment.mockResolvedValue({
|
||||
...currentRemoteEnvironment,
|
||||
remoteDownload: {
|
||||
available: false,
|
||||
source: 'mirror',
|
||||
packageSize: 64 * 1024 * 1024,
|
||||
reason: 'probe-failed'
|
||||
}
|
||||
})
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
const host = await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', { name: 'Host 下载' })
|
||||
)
|
||||
const directRetry = within(host).getByRole('button', {
|
||||
name: '为 Build host 重新安装远程环境'
|
||||
})
|
||||
expect(directRetry).toBeEnabled()
|
||||
expect(host).toHaveTextContent(
|
||||
'可直接重试由远程主机安装'
|
||||
)
|
||||
|
||||
fireEvent.click(directRetry)
|
||||
await waitFor(() =>
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
|
||||
hostId,
|
||||
method: 'remote-download'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fall back after an explicit Host-download failure', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
updateRemoteEnvironment.mockRejectedValueOnce(
|
||||
new Error('Remote source timed out')
|
||||
)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
const host = await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', { name: 'Host 下载' })
|
||||
)
|
||||
const directButton = within(host).getByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
|
||||
fireEvent.click(directButton)
|
||||
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledOnce()
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
|
||||
hostId,
|
||||
method: 'remote-download'
|
||||
})
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Remote source timed out'
|
||||
)
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates with phase progress, preserves cards, refreshes, and reports success once', async () => {
|
||||
@@ -661,16 +1012,18 @@ describe('SshHostsSettingsSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
const host = screen.getByRole('region', { name: 'Build host' })
|
||||
const host = await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledOnce()
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
|
||||
hostId,
|
||||
method: 'auto'
|
||||
})
|
||||
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -678,40 +1031,61 @@ describe('SshHostsSettingsSection', () => {
|
||||
name: '刷新 Build host 的远程运行环境版本'
|
||||
})
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(host).queryByRole('button', {
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(host).queryByRole('button', { name: '自动' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(host).getByRole('status')).toHaveTextContent('自动')
|
||||
expect(within(host).getByRole('status')).toHaveTextContent(
|
||||
'正在准备更新'
|
||||
'正在准备安装'
|
||||
)
|
||||
|
||||
act(() => {
|
||||
remoteEnvironmentUpdateProgressListener?.({
|
||||
hostId: candidateId,
|
||||
phase: 'agent'
|
||||
method: 'goodbuddy-transfer',
|
||||
phase: 'installing-agent'
|
||||
})
|
||||
})
|
||||
expect(within(host).getByRole('status')).toHaveTextContent('自动')
|
||||
act(() => {
|
||||
remoteEnvironmentUpdateProgressListener?.({
|
||||
hostId,
|
||||
method: 'remote-download',
|
||||
phase: 'downloading'
|
||||
})
|
||||
})
|
||||
expect(within(host).getByRole('status')).toHaveTextContent(
|
||||
'正在准备更新'
|
||||
'Host 下载'
|
||||
)
|
||||
act(() => {
|
||||
remoteEnvironmentUpdateProgressListener?.({
|
||||
hostId,
|
||||
phase: 'agent'
|
||||
method: 'remote-download',
|
||||
phase: 'installing-agent'
|
||||
})
|
||||
})
|
||||
expect(within(host).getByRole('status')).toHaveTextContent(
|
||||
'正在更新 GoodBuddy Agent'
|
||||
'正在安装 GoodBuddy Agent'
|
||||
)
|
||||
act(() => {
|
||||
remoteEnvironmentUpdateProgressListener?.({
|
||||
hostId,
|
||||
phase: 'runtime'
|
||||
method: 'remote-download',
|
||||
phase: 'installing-runtime'
|
||||
})
|
||||
})
|
||||
expect(within(host).getByRole('status')).toHaveTextContent(
|
||||
'正在更新 Runtime'
|
||||
'正在安装 Runtime'
|
||||
)
|
||||
act(() => {
|
||||
remoteEnvironmentUpdateProgressListener?.({
|
||||
hostId,
|
||||
method: 'remote-download',
|
||||
phase: 'finalizing'
|
||||
})
|
||||
})
|
||||
@@ -759,13 +1133,16 @@ describe('SshHostsSettingsSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
||||
expect(updateRemoteEnvironment).toHaveBeenCalledWith({
|
||||
hostId,
|
||||
method: 'auto'
|
||||
})
|
||||
|
||||
unmount()
|
||||
await act(async () => {
|
||||
@@ -791,10 +1168,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
unmount()
|
||||
@@ -830,18 +1207,13 @@ describe('SshHostsSettingsSection', () => {
|
||||
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
const buildHost = screen.getByRole('region', {
|
||||
name: 'Build host'
|
||||
})
|
||||
const deployHost = screen.getByRole('region', {
|
||||
name: 'Deploy host'
|
||||
})
|
||||
const buildHost = await refreshHostEnvironment()
|
||||
const deployHost =
|
||||
await refreshHostEnvironment('Deploy host')
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(
|
||||
within(buildHost).getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -855,7 +1227,7 @@ describe('SshHostsSettingsSection', () => {
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(deployHost).getByRole('button', {
|
||||
name: '更新 Deploy host 的远程运行环境版本'
|
||||
name: '为 Deploy host 更新远程环境'
|
||||
})
|
||||
).toBeDisabled()
|
||||
|
||||
@@ -884,10 +1256,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -900,13 +1272,36 @@ describe('SshHostsSettingsSection', () => {
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 重新安装远程环境'
|
||||
})
|
||||
).toBeEnabled()
|
||||
expect(onHostUpdated).not.toHaveBeenCalled()
|
||||
expect(onNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('combines a matched-version reinstall failure summary with its detail in one alert', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
getRemoteEnvironment.mockResolvedValue(currentRemoteEnvironment)
|
||||
updateRemoteEnvironment.mockRejectedValue(
|
||||
new Error('Runtime finalization failed')
|
||||
)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '为 Build host 重新安装远程环境'
|
||||
})
|
||||
)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(
|
||||
'本次重新安装未完成;正在重新检查当前版本。'
|
||||
)
|
||||
expect(alert).toHaveTextContent('Runtime finalization failed')
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancels an update, stays disabled while cancelling, and refreshes after settlement', async () => {
|
||||
getSnapshot.mockResolvedValue(verifiedSnapshot)
|
||||
const updateOperation = deferred<void>()
|
||||
@@ -920,10 +1315,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
const cancelButton = screen.getByRole('button', {
|
||||
@@ -963,10 +1358,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
@@ -1002,10 +1397,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
)
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
|
||||
await refreshHostEnvironment()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '更新 Build host 的远程运行环境版本'
|
||||
name: '为 Build host 更新远程环境'
|
||||
})
|
||||
)
|
||||
const cancelButton = screen.getByRole('button', {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
RemoteEnvironmentPreparationMethod,
|
||||
RemoteEnvironmentUpdatePhase,
|
||||
SshHost,
|
||||
SshHostConnectionTestResult,
|
||||
@@ -32,13 +33,15 @@ import { displayErrorMessage } from './error-message'
|
||||
import { SshHostDialog } from './SshHostDialog'
|
||||
import {
|
||||
DestructiveConfirmActions,
|
||||
EmptyState
|
||||
EmptyState,
|
||||
SegmentedControl
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
type SshHostsSettingsSectionProps = {
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
onHostUpdated?: (hostId: string) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onProjectsDeleted?: (projectIds: string[]) => void
|
||||
}
|
||||
|
||||
type RemoteEnvironmentLoadState = {
|
||||
@@ -49,6 +52,8 @@ type RemoteEnvironmentLoadState = {
|
||||
|
||||
type RemoteEnvironmentUpdateState = {
|
||||
hostId: string
|
||||
requestedMethod: RemoteEnvironmentPreparationMethod
|
||||
resolvedMethod?: RemoteEnvironmentPreparationMethod
|
||||
requestId: number
|
||||
phase?: RemoteEnvironmentUpdatePhase
|
||||
cancelling: boolean
|
||||
@@ -56,11 +61,17 @@ type RemoteEnvironmentUpdateState = {
|
||||
|
||||
type ActiveRemoteEnvironmentUpdate = {
|
||||
hostId: string
|
||||
requestedMethod: RemoteEnvironmentPreparationMethod
|
||||
requestId: number
|
||||
cancelRequested: boolean
|
||||
cancelError?: string
|
||||
}
|
||||
|
||||
type RemoteEnvironmentUpdateError = {
|
||||
detail: string
|
||||
reinstallAttempt: boolean
|
||||
}
|
||||
|
||||
function credentialDescription(
|
||||
host: SshHost,
|
||||
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
|
||||
@@ -81,12 +92,15 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
|
||||
export function SshHostsSettingsSection({
|
||||
onDirtyChange,
|
||||
onHostUpdated,
|
||||
onNotify
|
||||
onNotify,
|
||||
onProjectsDeleted
|
||||
}: SshHostsSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [hosts, setHosts] = useState<SshHost[]>([])
|
||||
const [secureStorageAvailable, setSecureStorageAvailable] =
|
||||
useState(false)
|
||||
const [projectReferences, setProjectReferences] =
|
||||
useState<SshHostsSnapshot['projectReferences']>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string>()
|
||||
const [editingId, setEditingId] = useState<string | 'new'>()
|
||||
@@ -99,12 +113,16 @@ export function SshHostsSettingsSection({
|
||||
const [remoteEnvironments, setRemoteEnvironments] = useState<
|
||||
Record<string, RemoteEnvironmentLoadState>
|
||||
>({})
|
||||
const [
|
||||
remoteEnvironmentPreparationMethods,
|
||||
setRemoteEnvironmentPreparationMethods
|
||||
] = useState<Record<string, RemoteEnvironmentPreparationMethod>>({})
|
||||
const [remoteEnvironmentUpdate, setRemoteEnvironmentUpdate] =
|
||||
useState<RemoteEnvironmentUpdateState>()
|
||||
const [
|
||||
remoteEnvironmentUpdateErrors,
|
||||
setRemoteEnvironmentUpdateErrors
|
||||
] = useState<Record<string, string>>({})
|
||||
] = useState<Record<string, RemoteEnvironmentUpdateError>>({})
|
||||
const remoteEnvironmentRequests = useRef(new Map<string, number>())
|
||||
const activeRemoteEnvironmentUpdate =
|
||||
useRef<ActiveRemoteEnvironmentUpdate | undefined>(undefined)
|
||||
@@ -126,24 +144,29 @@ export function SshHostsSettingsSection({
|
||||
return
|
||||
}
|
||||
return api.onRemoteEnvironmentUpdateProgress(
|
||||
({ hostId, phase }) => {
|
||||
({ hostId, method, phase }) => {
|
||||
const activeUpdate = activeRemoteEnvironmentUpdate.current
|
||||
if (
|
||||
!mounted.current ||
|
||||
!activeUpdate ||
|
||||
activeUpdate.hostId !== hostId
|
||||
activeUpdate.hostId !== hostId ||
|
||||
(activeUpdate.requestedMethod !== 'auto' &&
|
||||
activeUpdate.requestedMethod !== method)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setRemoteEnvironmentUpdate((current) => {
|
||||
if (
|
||||
!current ||
|
||||
current.requestId !== activeUpdate.requestId
|
||||
current.requestId !== activeUpdate.requestId ||
|
||||
(current.phase === phase &&
|
||||
current.resolvedMethod === method)
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
resolvedMethod: method,
|
||||
phase
|
||||
}
|
||||
})
|
||||
@@ -164,6 +187,7 @@ export function SshHostsSettingsSection({
|
||||
(snapshot: SshHostsSnapshot): void => {
|
||||
setHosts(snapshot.hosts)
|
||||
setSecureStorageAvailable(snapshot.secureStorageAvailable)
|
||||
setProjectReferences(snapshot.projectReferences)
|
||||
setError(undefined)
|
||||
},
|
||||
[]
|
||||
@@ -252,7 +276,8 @@ export function SshHostsSettingsSection({
|
||||
)
|
||||
|
||||
const updateRemoteEnvironment = async (
|
||||
host: SshHost
|
||||
host: SshHost,
|
||||
method: RemoteEnvironmentPreparationMethod
|
||||
): Promise<void> => {
|
||||
if (
|
||||
typeof api?.updateRemoteEnvironment !== 'function' ||
|
||||
@@ -264,6 +289,7 @@ export function SshHostsSettingsSection({
|
||||
remoteEnvironmentUpdateRequestId.current = requestId
|
||||
const activeUpdate: ActiveRemoteEnvironmentUpdate = {
|
||||
hostId: host.id,
|
||||
requestedMethod: method,
|
||||
requestId,
|
||||
cancelRequested: false
|
||||
}
|
||||
@@ -278,13 +304,24 @@ export function SshHostsSettingsSection({
|
||||
})
|
||||
setRemoteEnvironmentUpdate({
|
||||
hostId: host.id,
|
||||
requestedMethod: method,
|
||||
requestId,
|
||||
cancelling: false
|
||||
})
|
||||
|
||||
let succeeded = false
|
||||
const currentEnvironment = remoteEnvironments[host.id]?.value
|
||||
const reinstallingMatchedEnvironment =
|
||||
currentEnvironment !== undefined &&
|
||||
[
|
||||
currentEnvironment.agent,
|
||||
...currentEnvironment.runtimes
|
||||
].every(({ state }) => state === 'current')
|
||||
try {
|
||||
await api.updateRemoteEnvironment(host.id)
|
||||
await api.updateRemoteEnvironment({
|
||||
hostId: host.id,
|
||||
method
|
||||
})
|
||||
succeeded = true
|
||||
} catch (reason) {
|
||||
const cancelled =
|
||||
@@ -300,7 +337,11 @@ export function SshHostsSettingsSection({
|
||||
if (mounted.current) {
|
||||
setRemoteEnvironmentUpdateErrors((current) => ({
|
||||
...current,
|
||||
[host.id]: message
|
||||
[host.id]: {
|
||||
detail: message,
|
||||
reinstallAttempt:
|
||||
reinstallingMatchedEnvironment && !cancelled
|
||||
}
|
||||
}))
|
||||
} else if (!cancelled) {
|
||||
onNotify?.({
|
||||
@@ -404,7 +445,10 @@ export function SshHostsSettingsSection({
|
||||
})
|
||||
setRemoteEnvironmentUpdateErrors((current) => ({
|
||||
...current,
|
||||
[hostId]: activeUpdate.cancelError!
|
||||
[hostId]: {
|
||||
detail: activeUpdate.cancelError!,
|
||||
reinstallAttempt: false
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -420,43 +464,6 @@ export function SshHostsSettingsSection({
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
const hostIds = hosts
|
||||
.filter(
|
||||
(host) =>
|
||||
host.lastValidatedAt &&
|
||||
!remoteEnvironmentRequests.current.has(host.id)
|
||||
)
|
||||
.map((host) => host.id)
|
||||
if (hostIds.length === 0) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
let nextIndex = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
while (active) {
|
||||
const hostId = hostIds[nextIndex]
|
||||
nextIndex += 1
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
await requestRemoteEnvironment(hostId)
|
||||
}
|
||||
}
|
||||
void Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(3, hostIds.length) },
|
||||
() => worker()
|
||||
)
|
||||
)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [hosts, loading, requestRemoteEnvironment])
|
||||
|
||||
const notify = (
|
||||
dedupeKey: string,
|
||||
message: string,
|
||||
@@ -488,7 +495,10 @@ export function SshHostsSettingsSection({
|
||||
setBusyHostId(host.id)
|
||||
setError(undefined)
|
||||
try {
|
||||
await api.remove(host.id)
|
||||
const result = await api.remove(host.id)
|
||||
onProjectsDeleted?.(
|
||||
result.deletedProjects.map((project) => project.id)
|
||||
)
|
||||
setConfirmingDeleteId(undefined)
|
||||
setHosts((current) =>
|
||||
current.filter((candidate) => candidate.id !== host.id)
|
||||
@@ -509,6 +519,14 @@ export function SshHostsSettingsSection({
|
||||
delete next[host.id]
|
||||
return next
|
||||
})
|
||||
setRemoteEnvironmentPreparationMethods((current) => {
|
||||
if (!(host.id in current)) {
|
||||
return current
|
||||
}
|
||||
const next = { ...current }
|
||||
delete next[host.id]
|
||||
return next
|
||||
})
|
||||
setRemoteEnvironmentUpdateErrors((current) => {
|
||||
if (!(host.id in current)) {
|
||||
return current
|
||||
@@ -517,6 +535,14 @@ export function SshHostsSettingsSection({
|
||||
delete next[host.id]
|
||||
return next
|
||||
})
|
||||
setProjectReferences((current) => {
|
||||
if (!current || !(host.id in current)) {
|
||||
return current
|
||||
}
|
||||
const next = { ...current }
|
||||
delete next[host.id]
|
||||
return next
|
||||
})
|
||||
remoteEnvironmentRequests.current.delete(host.id)
|
||||
notify(
|
||||
`ssh-host-removed:${host.id}`,
|
||||
@@ -599,6 +625,8 @@ export function SshHostsSettingsSection({
|
||||
className="ssh-hosts-list"
|
||||
>
|
||||
{hosts.map((host) => {
|
||||
const relatedProjects =
|
||||
projectReferences?.[host.id] ?? []
|
||||
const result = testResults[host.id]
|
||||
const remoteEnvironment = remoteEnvironments[host.id]
|
||||
const hostEnvironmentUpdate =
|
||||
@@ -608,17 +636,29 @@ export function SshHostsSettingsSection({
|
||||
const remoteEnvironmentUpdateError =
|
||||
remoteEnvironmentUpdateErrors[host.id]
|
||||
const deleting = busyHostId === host.id
|
||||
const environmentNeedsUpdate = Boolean(
|
||||
remoteEnvironment?.value &&
|
||||
[
|
||||
const environmentComponents = remoteEnvironment?.value
|
||||
? [
|
||||
remoteEnvironment.value.agent,
|
||||
...remoteEnvironment.value.runtimes
|
||||
].some(
|
||||
({ state }) =>
|
||||
state === 'update-available' ||
|
||||
state === 'not-installed'
|
||||
)
|
||||
)
|
||||
]
|
||||
: []
|
||||
const environmentAction =
|
||||
environmentComponents.some(
|
||||
({ state }) => state === 'not-installed'
|
||||
)
|
||||
? 'install'
|
||||
: environmentComponents.some(
|
||||
({ state }) => state === 'update-available'
|
||||
)
|
||||
? 'update'
|
||||
: 'reinstall'
|
||||
const remoteDownload =
|
||||
remoteEnvironment?.value?.remoteDownload
|
||||
const directDownloadActionAvailable =
|
||||
remoteDownload?.available === true ||
|
||||
remoteDownload?.reason === 'probe-failed'
|
||||
const preparationMethod =
|
||||
remoteEnvironmentPreparationMethods[host.id] ?? 'auto'
|
||||
const updateApiAvailable =
|
||||
typeof api?.updateRemoteEnvironment === 'function' &&
|
||||
typeof api.cancelRemoteEnvironmentUpdate ===
|
||||
@@ -730,92 +770,149 @@ export function SshHostsSettingsSection({
|
||||
{t('sshHosts.environment.description')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="ssh-host-environment__actions">
|
||||
<button
|
||||
aria-label={t(
|
||||
'sshHosts.environment.refreshNamed',
|
||||
{ name: host.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
remoteEnvironment?.loading ||
|
||||
Boolean(hostEnvironmentUpdate)
|
||||
}
|
||||
onClick={() =>
|
||||
void requestRemoteEnvironment(host.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{remoteEnvironment?.loading ? (
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="ssh-host-environment__spinner"
|
||||
size={13}
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
)}
|
||||
{t('sshHosts.environment.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hostEnvironmentUpdate && (
|
||||
<div className="ssh-host-environment__toolbar">
|
||||
<div className="ssh-host-environment__method">
|
||||
<span>
|
||||
{t('sshHosts.environment.methodLabel')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'sshHosts.environment.methodSelectorNamed',
|
||||
{ name: host.name }
|
||||
)}
|
||||
disabled={
|
||||
!host.lastValidatedAt ||
|
||||
!remoteEnvironment?.value ||
|
||||
!updateApiAvailable ||
|
||||
Boolean(remoteEnvironment?.loading) ||
|
||||
Boolean(busyHostId) ||
|
||||
Boolean(editingId) ||
|
||||
hasActiveRemoteEnvironmentUpdate
|
||||
}
|
||||
onChange={(method) =>
|
||||
setRemoteEnvironmentPreparationMethods(
|
||||
(current) => ({
|
||||
...current,
|
||||
[host.id]: method
|
||||
})
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: t(
|
||||
'sshHosts.environment.methods.auto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'remote-download',
|
||||
label: t(
|
||||
'sshHosts.environment.methods.remote-download'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'goodbuddy-transfer',
|
||||
label: t(
|
||||
'sshHosts.environment.methods.goodbuddy-transfer'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={preparationMethod}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t(
|
||||
hostEnvironmentUpdate
|
||||
? 'sshHosts.environment.cancelUpdateNamed'
|
||||
: 'sshHosts.environment.updateNamed',
|
||||
`sshHosts.environment.actions.${environmentAction}Named`,
|
||||
{ name: host.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
className="primary-button"
|
||||
disabled={
|
||||
hostEnvironmentUpdate
|
||||
? hostEnvironmentUpdate.cancelling ||
|
||||
hostEnvironmentUpdate.phase ===
|
||||
'finalizing'
|
||||
: !host.lastValidatedAt ||
|
||||
!remoteEnvironment?.value ||
|
||||
!(
|
||||
environmentNeedsUpdate ||
|
||||
remoteEnvironmentUpdateError
|
||||
) ||
|
||||
!updateApiAvailable ||
|
||||
Boolean(remoteEnvironment?.loading) ||
|
||||
Boolean(busyHostId) ||
|
||||
Boolean(editingId) ||
|
||||
hasActiveRemoteEnvironmentUpdate
|
||||
!host.lastValidatedAt ||
|
||||
!remoteEnvironment?.value ||
|
||||
!updateApiAvailable ||
|
||||
(preparationMethod === 'remote-download' &&
|
||||
!directDownloadActionAvailable) ||
|
||||
Boolean(remoteEnvironment?.loading) ||
|
||||
Boolean(busyHostId) ||
|
||||
Boolean(editingId) ||
|
||||
hasActiveRemoteEnvironmentUpdate
|
||||
}
|
||||
onClick={() => {
|
||||
if (hostEnvironmentUpdate) {
|
||||
void cancelRemoteEnvironmentUpdate(
|
||||
host.id
|
||||
)
|
||||
} else {
|
||||
void updateRemoteEnvironment(host)
|
||||
}
|
||||
void updateRemoteEnvironment(
|
||||
host,
|
||||
preparationMethod
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{hostEnvironmentUpdate?.cancelling ? (
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="ssh-host-environment__spinner"
|
||||
size={13}
|
||||
/>
|
||||
) : hostEnvironmentUpdate ? (
|
||||
<X aria-hidden="true" size={13} />
|
||||
) : (
|
||||
<Download aria-hidden="true" size={13} />
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t(
|
||||
`sshHosts.environment.actions.${environmentAction}`
|
||||
)}
|
||||
{hostEnvironmentUpdate?.cancelling
|
||||
? t('sshHosts.environment.cancelling')
|
||||
: hostEnvironmentUpdate
|
||||
? t('sshHosts.environment.cancelUpdate')
|
||||
: t('sshHosts.environment.update')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
'sshHosts.environment.refreshNamed',
|
||||
{ name: host.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
remoteEnvironment?.loading ||
|
||||
Boolean(hostEnvironmentUpdate)
|
||||
}
|
||||
onClick={() =>
|
||||
void requestRemoteEnvironment(host.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{remoteEnvironment?.loading ? (
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="ssh-host-environment__spinner"
|
||||
size={13}
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
)}
|
||||
{t('sshHosts.environment.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteDownload &&
|
||||
!remoteDownload.available && (
|
||||
<div className="ssh-host-environment__availability">
|
||||
{t(
|
||||
`sshHosts.environment.remoteDownloadUnavailable.${remoteDownload.reason}`,
|
||||
{
|
||||
source: remoteDownload.source
|
||||
? t(
|
||||
`sshHosts.environment.sources.${remoteDownload.source}`
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
)}
|
||||
{remoteDownload.packageSize && (
|
||||
<small>
|
||||
{t(
|
||||
'sshHosts.environment.remoteDownloadPackageSize',
|
||||
{
|
||||
size: formatPackageSize(
|
||||
remoteDownload.packageSize
|
||||
)
|
||||
}
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hostEnvironmentUpdate && (
|
||||
<div
|
||||
className="ssh-host-environment__loading"
|
||||
className="ssh-host-environment__loading ssh-host-environment__loading--update"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircle
|
||||
@@ -823,13 +920,53 @@ export function SshHostsSettingsSection({
|
||||
className="ssh-host-environment__spinner"
|
||||
size={16}
|
||||
/>
|
||||
{t(
|
||||
hostEnvironmentUpdate.cancelling
|
||||
? 'sshHosts.environment.progress.cancelling'
|
||||
: hostEnvironmentUpdate.phase
|
||||
? `sshHosts.environment.progress.${hostEnvironmentUpdate.phase}`
|
||||
: 'sshHosts.environment.progress.preparing'
|
||||
)}
|
||||
<span>
|
||||
{!hostEnvironmentUpdate.cancelling && (
|
||||
<strong>
|
||||
{t(
|
||||
`sshHosts.environment.methods.${hostEnvironmentUpdate.resolvedMethod ?? hostEnvironmentUpdate.requestedMethod}`
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
<span>
|
||||
{t(
|
||||
hostEnvironmentUpdate.cancelling
|
||||
? 'sshHosts.environment.progress.cancelling'
|
||||
: hostEnvironmentUpdate.phase
|
||||
? `sshHosts.environment.progress.${hostEnvironmentUpdate.phase}`
|
||||
: 'sshHosts.environment.progress.preparing'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
aria-label={t(
|
||||
'sshHosts.environment.cancelUpdateNamed',
|
||||
{ name: host.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
hostEnvironmentUpdate.cancelling ||
|
||||
hostEnvironmentUpdate.phase === 'finalizing' ||
|
||||
hostEnvironmentUpdate.phase === 'complete'
|
||||
}
|
||||
onClick={() =>
|
||||
void cancelRemoteEnvironmentUpdate(host.id)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{hostEnvironmentUpdate.cancelling ? (
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="ssh-host-environment__spinner"
|
||||
size={13}
|
||||
/>
|
||||
) : (
|
||||
<X aria-hidden="true" size={13} />
|
||||
)}
|
||||
{hostEnvironmentUpdate.cancelling
|
||||
? t('sshHosts.environment.cancelling')
|
||||
: t('sshHosts.environment.cancelUpdate')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -838,7 +975,14 @@ export function SshHostsSettingsSection({
|
||||
className="ssh-host-environment__error"
|
||||
role="alert"
|
||||
>
|
||||
{remoteEnvironmentUpdateError}
|
||||
{remoteEnvironmentUpdateError.reinstallAttempt && (
|
||||
<strong>
|
||||
{t(
|
||||
'sshHosts.environment.errors.reinstallFailedSummary'
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
<span>{remoteEnvironmentUpdateError.detail}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -897,7 +1041,11 @@ export function SshHostsSettingsSection({
|
||||
/>
|
||||
{t('sshHosts.environment.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="ssh-host-environment__availability">
|
||||
{t('sshHosts.environment.notChecked')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -910,9 +1058,31 @@ export function SshHostsSettingsSection({
|
||||
disabled={
|
||||
deleting || Boolean(hostEnvironmentUpdate)
|
||||
}
|
||||
message={t('sshHosts.removeMessage', {
|
||||
name: host.name
|
||||
})}
|
||||
message={
|
||||
confirmingDeleteId === host.id ? (
|
||||
<div className="ssh-host-remove-confirmation">
|
||||
<p>
|
||||
{t('sshHosts.removeMessage', {
|
||||
name: host.name
|
||||
})}
|
||||
</p>
|
||||
{relatedProjects.length > 0 && (
|
||||
<>
|
||||
<strong>
|
||||
{t('sshHosts.removeProjectsHeading')}
|
||||
</strong>
|
||||
<ul>
|
||||
{relatedProjects.map((project) => (
|
||||
<li key={project.id}>
|
||||
{project.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onCancel={() =>
|
||||
setConfirmingDeleteId(undefined)
|
||||
}
|
||||
@@ -966,6 +1136,11 @@ function isCancellationError(reason: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function formatPackageSize(bytes: number): string {
|
||||
const mebibytes = bytes / (1024 * 1024)
|
||||
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
function EnvironmentVersionCard({
|
||||
expected,
|
||||
icon,
|
||||
|
||||
@@ -372,7 +372,7 @@ export function DestructiveConfirmActions({
|
||||
confirming: boolean
|
||||
disabled?: boolean
|
||||
icon?: ReactNode
|
||||
message?: string
|
||||
message?: ReactNode
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
onRequestConfirm: () => void
|
||||
|
||||
@@ -567,21 +567,6 @@ export const app = {
|
||||
brandingSaved: 'Branding saved',
|
||||
updateAvailable:
|
||||
'GoodBuddy {{version}} is available. View it in About & Updates.',
|
||||
remoteProjectActivated:
|
||||
'The remote project Agent and Runtime are ready',
|
||||
remoteProjectActivationFailed:
|
||||
'Remote project update failed: {{message}}. Check the Host and try again.',
|
||||
remoteProjectActivationUnknownError: 'Unknown error',
|
||||
remoteProjectReactivating:
|
||||
'The remote project is reconnecting. Try again shortly.',
|
||||
remoteProjectReconnectRequired:
|
||||
'The remote project must reconnect. Select the current project again before sending.',
|
||||
remoteProjectActivationCancelled:
|
||||
'Remote project connection cancelled',
|
||||
remoteProjectActivationCancelFailed:
|
||||
'Could not cancel the remote project connection: {{message}}',
|
||||
remoteProjectActivationCancelUnknownError:
|
||||
'Could not cancel the remote project connection. Try again.',
|
||||
channelConversationAutomatic:
|
||||
'Channel conversations are created automatically when the client receives a new message',
|
||||
deleteConversationCancelFailed:
|
||||
|
||||
@@ -73,31 +73,74 @@ export const settingsSections = {
|
||||
description:
|
||||
'Updating this Host may replace the Agent and Runtime and interrupt active remote work. Host configuration and project files are preserved.',
|
||||
loading: 'Loading Agent and Runtime versions…',
|
||||
update: 'Update versions',
|
||||
updateNamed: 'Update remote environment versions for {{name}}',
|
||||
notChecked:
|
||||
'Versions have not been checked. This page does not connect automatically; select Refresh versions to check.',
|
||||
methodLabel: 'Installation method',
|
||||
methodSelectorNamed:
|
||||
'Remote environment installation method for {{name}}',
|
||||
actions: {
|
||||
install: 'Install remote environment',
|
||||
installNamed: 'Install the remote environment for {{name}}',
|
||||
update: 'Update remote environment',
|
||||
updateNamed: 'Update the remote environment for {{name}}',
|
||||
reinstall: 'Reinstall',
|
||||
reinstallNamed: 'Reinstall the remote environment for {{name}}'
|
||||
},
|
||||
cancelUpdate: 'Cancel update',
|
||||
cancelUpdateNamed: 'Cancel the remote environment update for {{name}}',
|
||||
cancelling: 'Cancelling…',
|
||||
refresh: 'Refresh versions',
|
||||
refreshNamed: 'Refresh remote environment versions for {{name}}',
|
||||
progress: {
|
||||
preparing: 'Preparing the update…',
|
||||
agent: 'Updating GoodBuddy Agent…',
|
||||
runtime: 'Updating Runtime…',
|
||||
preparing: 'Preparing installation…',
|
||||
probing: 'Checking the remote Host environment…',
|
||||
downloading: 'Transferring the environment package…',
|
||||
verifying: 'Verifying the environment package…',
|
||||
applying: 'Applying the remote environment…',
|
||||
'installing-agent': 'Installing GoodBuddy Agent…',
|
||||
'installing-runtime': 'Installing Runtime…',
|
||||
'checking-health': 'Checking the remote environment…',
|
||||
finalizing: 'Finalizing the remote environment update…',
|
||||
complete: 'Remote environment installation complete',
|
||||
cancelling: 'Cancelling the remote environment update…'
|
||||
},
|
||||
methods: {
|
||||
auto: 'Automatic',
|
||||
'remote-download': 'Host download',
|
||||
'goodbuddy-transfer': 'GoodBuddy transfer'
|
||||
},
|
||||
sources: {
|
||||
github: 'GitHub',
|
||||
mirror: 'mirror node'
|
||||
},
|
||||
remoteDownloadUnavailable: {
|
||||
'package-unavailable':
|
||||
'The current source has no package for this Host. You can still install through GoodBuddy.',
|
||||
'missing-tools':
|
||||
'The remote Host lacks a system tool required to download or unpack the package. You can still install through GoodBuddy.',
|
||||
'home-unwritable':
|
||||
'The remote user directory is not writable. Fix its permissions and refresh, or install through GoodBuddy.',
|
||||
'insufficient-disk-space':
|
||||
'The remote Host does not have enough free space for the package. Free space and refresh, or install through GoodBuddy.',
|
||||
'source-unreachable':
|
||||
'The remote Host cannot reach the selected source ({{source}}). Check its network and refresh, or install through GoodBuddy.',
|
||||
'probe-failed':
|
||||
'Could not complete the remote download capability check. Retry the remote-host installation directly, refresh the check, or reinstall through GoodBuddy.'
|
||||
},
|
||||
remoteDownloadPackageSize: 'Package size: {{size}}',
|
||||
errors: {
|
||||
updateFailed: 'Could not update the remote environment. Try again.',
|
||||
cancelled: 'The remote environment update was cancelled. You can retry.',
|
||||
cancelFailed: 'Could not cancel the remote environment update.'
|
||||
cancelFailed: 'Could not cancel the remote environment update.',
|
||||
reinstallFailedSummary:
|
||||
'This reinstall did not complete. Checking the current versions again.'
|
||||
},
|
||||
installed: 'Installed on Host',
|
||||
expected: 'Required by GoodBuddy',
|
||||
notInstalled: 'Not installed',
|
||||
versionDetail: 'Linux · {{architecture}}',
|
||||
states: {
|
||||
current: 'Current',
|
||||
current: 'Version matched',
|
||||
'update-available': 'Update available',
|
||||
'not-installed': 'Not installed'
|
||||
}
|
||||
@@ -144,7 +187,8 @@ export const settingsSections = {
|
||||
}
|
||||
},
|
||||
removeMessage:
|
||||
'Deleting “{{name}}” clears the locally saved connection information and encrypted credential. Remote files are not deleted.',
|
||||
'Deleting “{{name}}” clears its locally saved connection information and encrypted credential. Related projects are removed locally without connecting to the Host or deleting remote directories or content.',
|
||||
removeProjectsHeading: 'Also remove these related project records:',
|
||||
actions: {
|
||||
add: 'Add host',
|
||||
edit: 'Edit and revalidate',
|
||||
|
||||
@@ -22,7 +22,8 @@ export const workspace = {
|
||||
remoteDetail: '{{channel}} · Remote channel · {{path}}',
|
||||
remoteChannel: 'Remote channel',
|
||||
create: 'New project',
|
||||
settings: 'Project settings'
|
||||
settings: 'Project settings',
|
||||
settingsNamed: 'Manage project {{name}}'
|
||||
},
|
||||
dialog: {
|
||||
createTitle: 'New project',
|
||||
@@ -85,6 +86,23 @@ export const workspace = {
|
||||
noHosts: 'No saved SSH hosts',
|
||||
hostHelp:
|
||||
'Host credentials remain centrally managed in Settings and are not copied into the project.',
|
||||
readiness: {
|
||||
loading: 'Reading the Host validation record…',
|
||||
ready:
|
||||
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
|
||||
unready:
|
||||
'This Host has not completed Host Key and connection validation. Validate it under Settings > SSH Hosts first.',
|
||||
error:
|
||||
'Could not read this Host’s local validation record. Check it under Settings > SSH Hosts.',
|
||||
saveBlocked:
|
||||
'The selected Host has not been validated. Validate its connection under Settings > SSH Hosts first.',
|
||||
options: {
|
||||
loading: 'Reading',
|
||||
ready: 'Validated',
|
||||
unready: 'Validation required',
|
||||
error: 'Record unavailable'
|
||||
}
|
||||
},
|
||||
rootHelp:
|
||||
'Enter or choose a canonical absolute path beginning with /.',
|
||||
directoryPicker: {
|
||||
@@ -108,13 +126,9 @@ export const workspace = {
|
||||
save: 'Save remote project',
|
||||
saving: 'Saving remote project…'
|
||||
},
|
||||
activation: {
|
||||
title: 'Connecting to remote project “{{project}}”',
|
||||
progressLabel: 'Remote project connection progress',
|
||||
stepsLabel: 'Remote project connection stages',
|
||||
cancel: 'Cancel connection',
|
||||
cancelling: 'Cancelling the remote connection…',
|
||||
cancellingAction: 'Cancelling…'
|
||||
progress: {
|
||||
progressLabel: 'Remote project save progress',
|
||||
stepsLabel: 'Remote project save stages'
|
||||
},
|
||||
phaseStatus: 'Current phase: {{phase}}',
|
||||
phases: {
|
||||
|
||||
@@ -532,18 +532,6 @@ export const app = {
|
||||
notices: {
|
||||
brandingSaved: '品牌设置已保存',
|
||||
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
|
||||
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
|
||||
remoteProjectActivationFailed:
|
||||
'远程项目更新失败:{{message}}。请检查 Host 后重试',
|
||||
remoteProjectActivationUnknownError: '未知错误',
|
||||
remoteProjectReactivating: '远程项目正在重新连接,请稍候',
|
||||
remoteProjectReconnectRequired:
|
||||
'远程项目需要重新连接,请重新选择当前项目后再发送',
|
||||
remoteProjectActivationCancelled: '已取消远程项目连接',
|
||||
remoteProjectActivationCancelFailed:
|
||||
'取消远程项目连接失败:{{message}}',
|
||||
remoteProjectActivationCancelUnknownError:
|
||||
'取消远程项目连接失败,请重试',
|
||||
channelConversationAutomatic:
|
||||
'通道项目的会话由客户端收到新消息后自动创建',
|
||||
deleteConversationCancelFailed:
|
||||
|
||||
@@ -66,31 +66,73 @@ export const settingsSections = {
|
||||
description:
|
||||
'更新 Host 可能替换 Agent 和 Runtime,并中断正在进行的远程工作;Host 配置和项目文件会保留。',
|
||||
loading: '正在读取 Agent 和 Runtime 版本…',
|
||||
update: '更新版本',
|
||||
updateNamed: '更新 {{name}} 的远程运行环境版本',
|
||||
notChecked:
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。',
|
||||
methodLabel: '安装方式',
|
||||
methodSelectorNamed: '{{name}} 的远程环境安装方式',
|
||||
actions: {
|
||||
install: '安装远程环境',
|
||||
installNamed: '为 {{name}} 安装远程环境',
|
||||
update: '更新远程环境',
|
||||
updateNamed: '为 {{name}} 更新远程环境',
|
||||
reinstall: '重新安装',
|
||||
reinstallNamed: '为 {{name}} 重新安装远程环境'
|
||||
},
|
||||
cancelUpdate: '取消更新',
|
||||
cancelUpdateNamed: '取消 {{name}} 的远程运行环境更新',
|
||||
cancelling: '正在取消…',
|
||||
refresh: '刷新版本',
|
||||
refreshNamed: '刷新 {{name}} 的远程运行环境版本',
|
||||
progress: {
|
||||
preparing: '正在准备更新…',
|
||||
agent: '正在更新 GoodBuddy Agent…',
|
||||
runtime: '正在更新 Runtime…',
|
||||
preparing: '正在准备安装…',
|
||||
probing: '正在检查远程主机环境…',
|
||||
downloading: '正在传输运行环境包…',
|
||||
verifying: '正在校验运行环境包…',
|
||||
applying: '正在应用远程运行环境…',
|
||||
'installing-agent': '正在安装 GoodBuddy Agent…',
|
||||
'installing-runtime': '正在安装 Runtime…',
|
||||
'checking-health': '正在检查远程运行环境…',
|
||||
finalizing: '正在完成远程运行环境更新…',
|
||||
complete: '远程运行环境安装完成',
|
||||
cancelling: '正在取消远程运行环境更新…'
|
||||
},
|
||||
methods: {
|
||||
auto: '自动',
|
||||
'remote-download': 'Host 下载',
|
||||
'goodbuddy-transfer': 'GoodBuddy 传输'
|
||||
},
|
||||
sources: {
|
||||
github: 'GitHub',
|
||||
mirror: '镜像节点'
|
||||
},
|
||||
remoteDownloadUnavailable: {
|
||||
'package-unavailable':
|
||||
'当前下载源没有适用于此主机的安装包。仍可通过 GoodBuddy 安装。',
|
||||
'missing-tools':
|
||||
'远程主机缺少直接下载或解包所需的系统工具。仍可通过 GoodBuddy 安装。',
|
||||
'home-unwritable':
|
||||
'远程主机的用户目录不可写,无法直接下载安装。请修复权限后刷新,或通过 GoodBuddy 安装。',
|
||||
'insufficient-disk-space':
|
||||
'远程主机没有足够的可用空间下载安装包。请释放空间后刷新,或通过 GoodBuddy 安装。',
|
||||
'source-unreachable':
|
||||
'远程主机无法连接所选下载源({{source}})。请检查网络后刷新,或通过 GoodBuddy 安装。',
|
||||
'probe-failed':
|
||||
'无法完成远程主机直连能力检查。可直接重试由远程主机安装、刷新检查,或通过 GoodBuddy 重新安装。'
|
||||
},
|
||||
remoteDownloadPackageSize: '安装包大小:{{size}}',
|
||||
errors: {
|
||||
updateFailed: '更新远程运行环境失败,请重试',
|
||||
cancelled: '远程运行环境更新已取消,可重试',
|
||||
cancelFailed: '取消远程运行环境更新失败'
|
||||
cancelFailed: '取消远程运行环境更新失败',
|
||||
reinstallFailedSummary:
|
||||
'本次重新安装未完成;正在重新检查当前版本。'
|
||||
},
|
||||
installed: 'Host 已安装',
|
||||
expected: 'GoodBuddy 所需',
|
||||
notInstalled: '尚未安装',
|
||||
versionDetail: 'Linux · {{architecture}}',
|
||||
states: {
|
||||
current: '当前版本',
|
||||
current: '版本匹配',
|
||||
'update-available': '待更新',
|
||||
'not-installed': '未安装'
|
||||
}
|
||||
@@ -137,7 +179,8 @@ export const settingsSections = {
|
||||
}
|
||||
},
|
||||
removeMessage:
|
||||
'删除“{{name}}”会清除本机保存的连接信息和加密凭据,不会删除远端文件。',
|
||||
'删除“{{name}}”会清除本机保存的连接信息和加密凭据。关联项目只删除本地记录,不会连接主机,也不会删除远端目录或内容。',
|
||||
removeProjectsHeading: '同时删除以下关联项目记录:',
|
||||
actions: {
|
||||
add: '添加主机',
|
||||
edit: '编辑',
|
||||
|
||||
@@ -19,7 +19,8 @@ export const workspace = {
|
||||
remoteDetail: '{{channel}} · 远程通道 · {{path}}',
|
||||
remoteChannel: '远程通道',
|
||||
create: '新建项目',
|
||||
settings: '项目设置'
|
||||
settings: '项目设置',
|
||||
settingsNamed: '管理项目 {{name}}'
|
||||
},
|
||||
dialog: {
|
||||
createTitle: '新建项目',
|
||||
@@ -81,6 +82,23 @@ export const workspace = {
|
||||
loadingHosts: '正在读取已保存主机…',
|
||||
noHosts: '没有已保存的 SSH 主机',
|
||||
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。',
|
||||
readiness: {
|
||||
loading: '正在读取主机验证记录…',
|
||||
ready:
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
|
||||
unready:
|
||||
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。',
|
||||
error:
|
||||
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。',
|
||||
saveBlocked:
|
||||
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。',
|
||||
options: {
|
||||
loading: '读取中',
|
||||
ready: '已验证',
|
||||
unready: '需要验证',
|
||||
error: '记录不可用'
|
||||
}
|
||||
},
|
||||
rootHelp: '请输入或选择以 / 开头的规范绝对路径。',
|
||||
directoryPicker: {
|
||||
browse: '浏览远端工作目录',
|
||||
@@ -103,13 +121,9 @@ export const workspace = {
|
||||
save: '保存远程项目',
|
||||
saving: '正在保存远程项目…'
|
||||
},
|
||||
activation: {
|
||||
title: '正在连接远程项目“{{project}}”',
|
||||
progressLabel: '远程项目连接进度',
|
||||
stepsLabel: '远程项目连接阶段',
|
||||
cancel: '取消连接',
|
||||
cancelling: '正在取消远程连接…',
|
||||
cancellingAction: '取消中…'
|
||||
progress: {
|
||||
progressLabel: '远程项目保存进度',
|
||||
stepsLabel: '远程项目保存阶段'
|
||||
},
|
||||
phaseStatus: '当前阶段:{{phase}}',
|
||||
phases: {
|
||||
|
||||
+165
-18
@@ -2468,13 +2468,18 @@ textarea:focus-visible {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.project-switcher__group > button {
|
||||
.project-switcher__menu-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-switcher__menu-item > [role="menuitemradio"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
padding: var(--space-2) 44px var(--space-2) var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
@@ -2484,35 +2489,78 @@ textarea:focus-visible {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-switcher__group > button:hover,
|
||||
.project-switcher__group > button[aria-checked="true"] {
|
||||
.project-switcher__menu-item > [role="menuitemradio"]:hover,
|
||||
.project-switcher__menu-item > [role="menuitemradio"][aria-checked="true"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.project-switcher__group > button > span {
|
||||
.project-switcher__menu-item > [role="menuitemradio"] > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-switcher__group > button b,
|
||||
.project-switcher__group > button small {
|
||||
.project-switcher__menu-item > [role="menuitemradio"] b,
|
||||
.project-switcher__menu-item > [role="menuitemradio"] small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-switcher__group > button b {
|
||||
.project-switcher__menu-item > [role="menuitemradio"] b {
|
||||
font-size: var(--font-body);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.project-switcher__group > button small {
|
||||
.project-switcher__menu-item > [role="menuitemradio"] small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-switcher__menu-settings {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.project-switcher__menu-item:hover
|
||||
.project-switcher__menu-settings,
|
||||
.project-switcher__menu-item:focus-within
|
||||
.project-switcher__menu-settings,
|
||||
.project-switcher__menu-settings:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.project-switcher__menu-settings:hover,
|
||||
.project-switcher__menu-settings:focus-visible {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.project-switcher__menu-settings {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.project-create-backdrop {
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
@@ -6982,6 +7030,25 @@ button > svg {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ssh-host-remove-confirmation {
|
||||
display: grid;
|
||||
max-width: 420px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ssh-host-remove-confirmation p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ssh-host-remove-confirmation ul {
|
||||
display: grid;
|
||||
max-height: 140px;
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
gap: var(--space-1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ssh-host-test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6999,7 +7066,7 @@ button > svg {
|
||||
|
||||
.ssh-host-environment__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -7024,16 +7091,38 @@ button > svg {
|
||||
}
|
||||
|
||||
.ssh-host-environment__summary small {
|
||||
max-width: 680px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ssh-host-environment__actions {
|
||||
.ssh-host-environment__header > .secondary-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ssh-host-environment__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ssh-host-environment__method {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ssh-host-environment__method > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ssh-host-environment__toolbar > .primary-button {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ssh-host-environment__grid {
|
||||
@@ -7045,10 +7134,10 @@ button > svg {
|
||||
.ssh-host-environment__runtime {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
background: var(--surface-raised);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -7066,7 +7155,7 @@ button > svg {
|
||||
height: 30px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--accent);
|
||||
place-items: center;
|
||||
}
|
||||
@@ -7156,10 +7245,49 @@ button > svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ssh-host-environment__loading--update {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.ssh-host-environment__loading > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ssh-host-environment__loading > span > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.ssh-host-environment__availability {
|
||||
display: grid;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ssh-host-environment__availability small {
|
||||
color: var(--text-muted);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.ssh-host-environment__error {
|
||||
display: grid;
|
||||
border: 1px solid var(--danger-border);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.ssh-host-environment__error > strong {
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
|
||||
.ssh-host-environment__spinner {
|
||||
@@ -7483,9 +7611,28 @@ button > svg {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ssh-host-environment__actions {
|
||||
.ssh-host-environment__header > .secondary-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ssh-host-environment__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ssh-host-environment__method > .segmented-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ssh-host-environment__method
|
||||
> .segmented-control
|
||||
.segmented-control__option {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ssh-host-environment__toolbar > .primary-button {
|
||||
align-self: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ssh-host-card__actions > .danger-confirm {
|
||||
|
||||
Reference in New Issue
Block a user