Files
goodbuddy/src/agent-daemon/workspace-protocol-methods.ts
T
mesalogo f669e490b8 feat: add workspace file management and embedded browsing
Users previously had to leave the workbar to manage project files and inspect Git branches or commit history. Add file and directory creation, rename, move, delete and properties, plus searchable branches, branch creation and switching, explicit fetch, and commit file diffs.

Keep file previews in the right workspace pane with directory expansion, selection, scroll and focus preserved on return. Add compact action menus, directory pickers, preview refresh and source switching. Embed the shared browser session directly in the workbar with navigation, resizing and fullscreen controls instead of a separate interaction window.

Moves stay within the current workspace, deletion is permanent after confirmation, and Git switch reports conflicts without discarding or stashing changes. Typecheck, lint and staged whitespace checks pass; existing full-suite failures and the blocked real remote directory validation remain recorded in the workbar progress document.

Release note: 可在右侧工作区管理文件、切换 Git 分支并查看提交历史,返回预览后保留浏览位置;浏览器支持在工作栏内直接交互和全屏显示。
2026-09-09 15:47:24 +08:00

323 lines
9.9 KiB
TypeScript

import {
remoteWorkspaceManageRequestSchema,
remoteGitDiffRequestSchema,
remoteGitDiffResultSchema,
remoteGitStatusRequestSchema,
remoteGitStatusResultSchema,
remoteWorkspaceCloseRequestSchema,
remoteWorkspaceCloseResultSchema,
remoteWorkspaceListRequestSchema,
remoteWorkspaceListResultSchema,
remoteWorkspaceOpenRequestSchema,
remoteWorkspaceOpenResultSchema,
remoteWorkspaceReadTextRequestSchema,
remoteWorkspaceReadTextResultSchema,
remoteWorkspaceResumeRequestSchema,
remoteWorkspaceResumeResultSchema,
remoteWorkspaceSearchRequestSchema,
remoteWorkspaceSearchResultSchema,
remoteWorkspaceStatRequestSchema,
remoteWorkspaceStatResultSchema,
remoteWorkspaceValidateRequestSchema,
remoteWorkspaceValidateResultSchema
} from '../shared/remote-agent-contracts'
import type {
ProtocolMethodContext,
ProtocolMethodHandler
} from './protocol-server'
import type { WorkspaceGitService } from './workspace-git-service'
import { manageWorkspace } from '../main/workspace/workspace-management'
import { workspaceManagementResultSchema } from '../shared/workspace-management-contracts'
import type { WorkspaceRegistry } from './workspace-registry'
import {
WorkspaceServiceError,
type WorkspaceIoOptions
} from './workspace-path-access'
const DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000
const MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS = 120_000
export const WORKSPACE_PROTOCOL_METHODS = [
'workspace/manage',
'workspace/validate',
'workspace/open',
'workspace/resume',
'workspace/close',
'workspace/list',
'workspace/stat',
'workspace/readText',
'workspace/search',
'git/status',
'git/diff',
'workspace/writeTextAtomic',
'workspace/applyChangeSet',
'git/operation'
] as const
export type WorkspaceProtocolMethodMap = Readonly<
Record<(typeof WORKSPACE_PROTOCOL_METHODS)[number], ProtocolMethodHandler>
>
export function createWorkspaceProtocolMethods(options: {
workspaces: WorkspaceRegistry
git: WorkspaceGitService
requestTimeoutMs?: number
}): WorkspaceProtocolMethodMap {
const timeoutMs = boundedTimeout(
options.requestTimeoutMs ??
DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS
)
const boundConnectionSignals = new WeakSet<AbortSignal>()
const invoke = async <T>(
context: ProtocolMethodContext,
action: (io: WorkspaceIoOptions) => Promise<T>
): Promise<T> => {
bindControllerLifetime(
options.workspaces,
context,
boundConnectionSignals
)
return await withDeadline(timeoutMs, action, context.signal)
}
return {
'workspace/manage': async (params, context) => {
const request = remoteWorkspaceManageRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(request.workspaceId, request.generation, context.controller, io)
if (workspace.handle.access !== 'read-write') throw new WorkspaceServiceError('Workspace management requires read-write access', 'read-only')
await workspace.access.assertCurrent(io)
return workspaceManagementResultSchema.parse(await manageWorkspace(workspace.access.root.canonicalPath, request.action, io.signal))
})
},
'workspace/validate': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceValidateResultSchema.parse(
await options.workspaces.validate(
remoteWorkspaceValidateRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/open': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceOpenResultSchema.parse(
await options.workspaces.open(
remoteWorkspaceOpenRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/resume': async (params, context) =>
await invoke(context, async (io) =>
remoteWorkspaceResumeResultSchema.parse(
await options.workspaces.resume(
remoteWorkspaceResumeRequestSchema.parse(params),
context.controller,
io
)
)
),
'workspace/close': async (params, context) => {
const request = remoteWorkspaceCloseRequestSchema.parse(params)
return await invoke(context, async () =>
remoteWorkspaceCloseResultSchema.parse(
await options.workspaces.close(
request.workspaceId,
request.generation,
context.controller
)
)
)
},
'workspace/list': async (params, context) => {
const request = remoteWorkspaceListRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceListResultSchema.parse(
await workspace.access.list(request, io)
)
})
},
'workspace/stat': async (params, context) => {
const request = remoteWorkspaceStatRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceStatResultSchema.parse(
await workspace.access.stat(request.relativePath, io)
)
})
},
'workspace/readText': async (params, context) => {
const request = remoteWorkspaceReadTextRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceReadTextResultSchema.parse(
await workspace.access.readText(request, io)
)
})
},
'workspace/search': async (params, context) => {
const request = remoteWorkspaceSearchRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
return remoteWorkspaceSearchResultSchema.parse(
await workspace.access.search(request, io)
)
})
},
'git/status': async (params, context) => {
const request = remoteGitStatusRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
assertGitAvailable(workspace.handle.git)
return remoteGitStatusResultSchema.parse(
await options.git.status(workspace.access, request, io)
)
})
},
'git/diff': async (params, context) => {
const request = remoteGitDiffRequestSchema.parse(params)
return await invoke(context, async (io) => {
const workspace = await options.workspaces.get(
request.workspaceId,
request.generation,
context.controller,
io
)
assertGitAvailable(workspace.handle.git)
return remoteGitDiffResultSchema.parse(
await options.git.diff(workspace.access, request, io)
)
})
},
'workspace/writeTextAtomic': readOnlyUnavailable,
'workspace/applyChangeSet': readOnlyUnavailable,
'git/operation': readOnlyUnavailable
}
}
function bindControllerLifetime(
workspaces: WorkspaceRegistry,
context: ProtocolMethodContext,
boundSignals: WeakSet<AbortSignal>
): void {
const signal = context.signal
if (signal === undefined || boundSignals.has(signal)) {
return
}
boundSignals.add(signal)
const closeController = (): void => {
workspaces.closeController(context.controller)
}
if (signal.aborted) {
closeController()
} else {
signal.addEventListener('abort', closeController, { once: true })
}
}
async function readOnlyUnavailable(): Promise<never> {
throw new WorkspaceServiceError(
'Remote workspace mutation is unavailable in this read-only service',
'read-only'
)
}
function assertGitAvailable(
availability: 'available' | 'not-a-repository' | 'unavailable'
): void {
if (availability !== 'available') {
throw new WorkspaceServiceError(
availability === 'not-a-repository'
? 'Workspace is not a Git repository'
: 'Git read service is unavailable',
'git-unavailable'
)
}
}
async function withDeadline<T>(
timeoutMs: number,
action: (options: WorkspaceIoOptions) => Promise<T>,
upstreamSignal?: AbortSignal
): Promise<T> {
const controller = new AbortController()
const abortFromUpstream = (): void => {
controller.abort(
new WorkspaceServiceError(
'Workspace operation was aborted',
'aborted'
)
)
}
if (upstreamSignal?.aborted === true) {
abortFromUpstream()
} else {
upstreamSignal?.addEventListener('abort', abortFromUpstream, {
once: true
})
}
const deadlineAt = Date.now() + timeoutMs
const timer = setTimeout(() => {
controller.abort(
new WorkspaceServiceError(
'Workspace operation deadline exceeded',
'deadline-exceeded'
)
)
}, timeoutMs)
timer.unref()
try {
const result = await action({
signal: controller.signal,
deadlineAt
})
if (controller.signal.aborted) {
throw controller.signal.reason
}
return result
} finally {
clearTimeout(timer)
upstreamSignal?.removeEventListener('abort', abortFromUpstream)
}
}
function boundedTimeout(value: number): number {
if (
!Number.isSafeInteger(value) ||
value < 1 ||
value > MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS
) {
throw new RangeError('Invalid workspace request timeout')
}
return value
}