Files
goodbuddy/src/main/assistant/workspace-changes-service.ts
T
mesalogo d08ba33c3e fix: complete subagent output and file workspace interactions
OpenCode child cards now show ordered text, reasoning and tool progress,
with the final result displayed separately. Local SDK and remote ACP paths
share child tracking, including progress retained across recovery and cancellation.
Remote live progress requires an Agent package containing the event plugin;
older Agent packages still expose final results without inventing missing history.

Knowledge-library rows remain clickable when native label activation changes
focus. File workspace changes use colored Git status letters and per-file
staged/unstaged diffs, including deleted, renamed and untracked files.
Refreshing reloads the selected diff and expanded directories, and more than
50 changes remain accessible through incremental loading.

Validated with 3552 passing tests, typecheck, lint, desktop build, current-source
Linux Host scenarios and isolated Windows desktop UI interactions.

Release note: 展开 OpenCode 子代理卡片可查看执行过程、工具详情与独立最终结果;知识库支持整行勾选。工作区支持单文件暂存与未暂存差异,刷新会更新当前差异,更多变更可继续加载。远端实时过程需要更新 GoodBuddy Agent。
2026-09-06 22:13:45 +08:00

199 lines
4.6 KiB
TypeScript

import { extname } from 'node:path'
import type {
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import {
LocalWorkspaceAccess,
type WorkspaceAccess
} from '../workspace'
const MAX_DIRECTORY_ENTRIES = 500
const PREVIEW_PAGE_BYTES = 256 * 1024
const previewExtensions = new Set([
'.c',
'.cpp',
'.cs',
'.css',
'.csv',
'.go',
'.h',
'.hpp',
'.html',
'.ini',
'.java',
'.js',
'.json',
'.jsx',
'.kt',
'.kts',
'.log',
'.md',
'.markdown',
'.php',
'.ps1',
'.py',
'.rb',
'.rs',
'.sh',
'.sql',
'.svelte',
'.toml',
'.ts',
'.tsx',
'.txt',
'.vue',
'.xml',
'.yaml',
'.yml'
])
const previewFileNames = new Set([
'dockerfile',
'license',
'makefile',
'notice',
'readme'
])
export class WorkspaceChangesService {
constructor(private readonly workspace: WorkspaceAccess) {}
getChanges(signal?: AbortSignal, path?: string): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal, path })
}
async listDirectory(
inputPath: string,
signal?: AbortSignal
): Promise<WorkspaceDirectoryListing> {
const listing = await this.workspace.listDirectory({
path: inputPath,
maximumEntries: MAX_DIRECTORY_ENTRIES,
includeGit: false,
includeOther: false,
signal
})
return {
path: listing.path,
entries: listing.entries.map((entry) => {
if (entry.type === 'other') {
throw new Error('工作区目录返回了不支持的条目类型')
}
return {
name: entry.name,
path: entry.path,
type: entry.type
}
}),
truncated: listing.truncated
}
}
async readFile(
inputPath: string,
offsetBytes = 0,
signal?: AbortSignal
): Promise<WorkspaceFilePreview> {
const entry = await this.workspace.stat({
path: inputPath,
signal
})
if (entry.type !== 'file') {
throw new Error('目标不是普通文件')
}
const extension = extname(entry.name).toLowerCase()
if (
!previewExtensions.has(extension) &&
!previewFileNames.has(entry.name.toLowerCase())
) {
throw new Error('当前文件类型不支持安全预览')
}
const preview = await this.workspace.readText({
path: inputPath,
offsetBytes,
maximumBytes: PREVIEW_PAGE_BYTES,
allowTruncated: true,
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
signal
})
return {
path: preview.path,
name: preview.name,
content: preview.content,
offsetBytes: preview.offsetBytes,
nextOffsetBytes: preview.offsetBytes + preview.bytesRead,
truncated: preview.truncated,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
: extension === '.json'
? 'application/json'
: 'text/plain',
size: preview.size
}
}
}
async function withLocalWorkspace<T>(
rootPath: string,
operation: (workspace: LocalWorkspaceAccess) => Promise<T>
): Promise<T> {
const workspace = new LocalWorkspaceAccess(rootPath)
try {
return await operation(workspace)
} finally {
await workspace.dispose()
}
}
export async function resolveWorkspaceEntryPath(
rootPath: string,
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
return withLocalWorkspace(rootPath, (workspace) =>
workspace.resolveEntryPath(inputPath, expected)
)
}
export async function getWorkspaceChanges(
workspace: string | WorkspaceAccess,
path?: string
): Promise<WorkspaceChanges> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).getChanges(undefined, path)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).getChanges(undefined, path)
)
}
export async function listWorkspaceDirectory(
workspace: string | WorkspaceAccess,
inputPath: string
): Promise<WorkspaceDirectoryListing> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).listDirectory(inputPath)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).listDirectory(inputPath)
)
}
export async function readWorkspaceFile(
workspace: string | WorkspaceAccess,
inputPath: string,
offsetBytes = 0
): Promise<WorkspaceFilePreview> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).readFile(
inputPath,
offsetBytes
)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).readFile(inputPath, offsetBytes)
)
}