Files
goodbuddy/src/main/assistant/workspace-changes-service.test.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

265 lines
9.1 KiB
TypeScript

import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceAccess } from '../workspace'
import {
getWorkspaceChanges,
listWorkspaceDirectory,
readWorkspaceFile
} from './workspace-changes-service'
const execute = promisify(execFile)
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('getWorkspaceChanges', () => {
it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
await execute('git', ['init'], { cwd: directory })
expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' })
await mkdir(join(directory, 'new directory'))
const path = 'new directory/[file].txt'
await writeFile(join(directory, path), 'index\n')
expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' })
expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index')
await execute('git', ['add', '--', path], { cwd: directory })
await writeFile(join(directory, path), 'worktree\n')
const diff = await getWorkspaceChanges(directory, path)
expect(diff).toMatchObject({ available: true })
expect(diff.stagedPatch).toContain('+index')
expect(diff.patch).toContain('-index')
expect(diff.patch).toContain('+worktree')
})
it('reads deleted and renamed paths literally without including unrelated changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
const git = (...args: string[]) => execute('git', args, { cwd: directory })
await git('init')
await writeFile(join(directory, 'old name.txt'), 'original\n')
await writeFile(join(directory, 'other.txt'), 'other\n')
await git('add', '.')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
await git('mv', 'old name.txt', '[new].txt')
await writeFile(join(directory, '[new].txt'), 'edited\n')
await writeFile(join(directory, 'other.txt'), 'unrelated\n')
const renamed = await getWorkspaceChanges(directory, '[new].txt')
expect(renamed.stagedPatch).toContain('rename from old name.txt')
expect(renamed.stagedPatch).toContain('rename to [new].txt')
expect(renamed.patch).toContain('+edited')
expect(renamed.patch).not.toContain('unrelated')
await rm(join(directory, '[new].txt'))
expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original')
await git('add', '-u')
expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original')
})
it('returns tracked and untracked Git workspace changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
await execute('git', ['init'], { cwd: directory })
await writeFile(join(directory, 'tracked.txt'), 'before\n')
await execute('git', ['add', 'tracked.txt'], { cwd: directory })
await execute(
'git',
[
'-c',
'user.name=GoodBuddy Test',
'-c',
'user.email=test@goodbuddy.invalid',
'commit',
'-m',
'initial'
],
{ cwd: directory }
)
await writeFile(join(directory, 'tracked.txt'), 'after\n')
await writeFile(join(directory, 'new.txt'), 'new\n')
const changes = await getWorkspaceChanges(directory)
expect(changes).toMatchObject({
available: true,
truncated: false
})
expect(changes.status).toContain('M tracked.txt')
expect(changes.status).toContain('?? new.txt')
expect(changes.files).toEqual(
expect.arrayContaining([
{ path: 'tracked.txt', status: ' M' },
{ path: 'new.txt', status: '??' }
])
)
expect(changes.patch).toContain('-before')
expect(changes.patch).toContain('+after')
})
it('keeps file browsing available without reporting Git errors', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
const changes = await getWorkspaceChanges(directory)
expect(changes.available).toBe(false)
expect(changes.files).toEqual([])
expect(changes.error).toBeUndefined()
})
})
describe('workspace file browsing', () => {
it('delegates workspace operations through WorkspaceAccess', async () => {
const listDirectory = vi.fn(async () => ({
path: '',
entries: [
{ name: 'remote.md', path: 'remote.md', type: 'file' as const }
],
truncated: false
}))
const stat = vi.fn(async () => ({
name: 'remote.md',
path: 'remote.md',
type: 'file' as const,
size: 7,
modifiedAt: '2026-08-21T00:00:00.000Z'
}))
const readText = vi.fn(async () => ({
path: 'remote.md',
name: 'remote.md',
content: '# fake\n',
size: 7,
offsetBytes: 0,
bytesRead: 7,
truncated: false
}))
const getChanges = vi.fn(async () => ({
rootPath: '/remote',
available: false,
status: '',
patch: '',
files: [],
truncated: false
}))
const workspace = {
getIdentity: vi.fn(),
listDirectory,
stat,
readText,
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges,
dispose: vi.fn()
} as unknown as WorkspaceAccess
await expect(
listWorkspaceDirectory(workspace, '')
).resolves.toMatchObject({
entries: [{ name: 'remote.md', type: 'file' }]
})
await expect(
readWorkspaceFile(workspace, 'remote.md')
).resolves.toMatchObject({
content: '# fake\n',
mimeType: 'text/markdown'
})
await expect(getWorkspaceChanges(workspace)).resolves.toMatchObject({
rootPath: '/remote'
})
expect(listDirectory).toHaveBeenCalledWith(
expect.objectContaining({ path: '', maximumEntries: 500 })
)
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.md',
offsetBytes: 0,
maximumBytes: 256 * 1024,
allowTruncated: true
})
)
})
it('lists directories and reads bounded Markdown previews', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
await mkdir(join(directory, 'docs'))
await writeFile(join(directory, 'docs', 'guide.md'), '# 使用说明\n')
await writeFile(join(directory, 'notes.txt'), 'hello\n')
const root = await listWorkspaceDirectory(directory, '')
const docs = await listWorkspaceDirectory(directory, 'docs')
const preview = await readWorkspaceFile(directory, 'docs/guide.md')
expect(root.entries).toEqual([
{ name: 'docs', path: 'docs', type: 'directory' },
{ name: 'notes.txt', path: 'notes.txt', type: 'file' }
])
expect(docs.entries).toEqual([
{
name: 'guide.md',
path: 'docs/guide.md',
type: 'file'
}
])
expect(preview).toMatchObject({
path: 'docs/guide.md',
name: 'guide.md',
content: '# 使用说明\n',
mimeType: 'text/markdown'
})
})
it('rejects traversal, unsupported files, and invalid UTF-8', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
await expect(
readWorkspaceFile(directory, '../outside.txt')
).rejects.toThrow('相对路径')
await expect(
readWorkspaceFile(directory, 'image.bin')
).rejects.toThrow('不支持安全预览')
await expect(
readWorkspaceFile(directory, 'invalid.txt')
).rejects.toThrow('有效 UTF-8')
})
it('reads large UTF-8 files page by page without a total preview limit', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
const firstPage = 'a'.repeat(256 * 1024 - 1)
const content = `${firstPage}你\n第二页`
await writeFile(join(directory, 'large.txt'), content)
const first = await readWorkspaceFile(directory, 'large.txt')
expect(first).toMatchObject({
content: firstPage,
offsetBytes: 0,
nextOffsetBytes: 256 * 1024 - 1,
truncated: true
})
const second = await readWorkspaceFile(
directory,
'large.txt',
first.nextOffsetBytes
)
expect(second).toMatchObject({
content: '你\n第二页',
offsetBytes: first.nextOffsetBytes,
truncated: false
})
expect(`${first.content}${second.content}`).toBe(content)
})
})