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 分支并查看提交历史,返回预览后保留浏览位置;浏览器支持在工作栏内直接交互和全屏显示。
This commit is contained in:
@@ -399,6 +399,10 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
}
|
||||
|
||||
export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult> {
|
||||
const { manageWorkspace } = await import('./workspace-management')
|
||||
return manageWorkspace(await this.getWorkspace(), action, signal)
|
||||
}
|
||||
private canonicalWorkspace?: Promise<string>
|
||||
|
||||
constructor(private readonly rootPath: string) {}
|
||||
|
||||
@@ -36,6 +36,7 @@ function createLease(
|
||||
overrides: Partial<RemoteWorkspaceTransportLease> = {}
|
||||
): RemoteWorkspaceTransportLease {
|
||||
return {
|
||||
manageWorkspace: vi.fn(),
|
||||
binding: {
|
||||
hostId: binding.hostId,
|
||||
hostRevision: 2,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
REMOTE_WORKSPACE_READ_CAPABILITIES,
|
||||
REMOTE_WORKSPACE_LIMITS,
|
||||
remoteWorkspaceManageRequestSchema,
|
||||
remoteGitDiffRequestSchema,
|
||||
remoteGitDiffResultSchema,
|
||||
remoteGitStatusRequestSchema,
|
||||
@@ -80,6 +81,7 @@ export type RemoteWorkspaceTransportBinding = {
|
||||
* exposes neither an SSH connection nor a generic JSON-RPC method.
|
||||
*/
|
||||
export interface RemoteWorkspaceTransportLease {
|
||||
manageWorkspace(request: import('zod').infer<typeof remoteWorkspaceManageRequestSchema>, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult>
|
||||
readonly binding: RemoteWorkspaceTransportBinding
|
||||
validateWorkspace(
|
||||
request: RemoteWorkspaceValidateRequest,
|
||||
@@ -266,6 +268,16 @@ function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
||||
}
|
||||
|
||||
export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
async manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult> {
|
||||
const opened = await this.open(signal)
|
||||
const validation = remoteWorkspaceValidateResultSchema.parse(await opened.lease.validateWorkspace({ remoteRootPath: this.binding.remoteRootPath, requestedAccess: 'read-write', requiredCapabilities: [] }, signal))
|
||||
const handle = validation.handle
|
||||
try {
|
||||
return await opened.lease.manageWorkspace(remoteWorkspaceManageRequestSchema.parse({ workspaceId: handle.workspaceId, generation: handle.generation, action }), signal)
|
||||
} finally {
|
||||
await opened.lease.closeWorkspace({ workspaceId: handle.workspaceId, generation: handle.generation })
|
||||
}
|
||||
}
|
||||
private opening?: Promise<OpenRemoteWorkspace>
|
||||
private opened?: OpenRemoteWorkspace
|
||||
private disposed = false
|
||||
|
||||
@@ -27,6 +27,8 @@ implements WorkspaceAccess {
|
||||
throw new Error(this.message)
|
||||
}
|
||||
|
||||
async manage(): Promise<never> { return this.unsupported() }
|
||||
|
||||
async getIdentity(): Promise<WorkspaceIdentity> {
|
||||
return this.unsupported()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export type WorkspaceChangesInput = {
|
||||
export type { WorkspaceChanges, WorkspaceChangedFile }
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
manage(action: import('../../shared/workspace-management-contracts').WorkspaceManagementAction, signal?: AbortSignal): Promise<import('../../shared/workspace-management-contracts').WorkspaceManagementResult>
|
||||
getIdentity(): Promise<WorkspaceIdentity>
|
||||
listDirectory(input: WorkspaceDirectoryInput): Promise<DirectoryPage>
|
||||
stat(input: WorkspacePathInput): Promise<WorkspaceEntry>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { manageWorkspace } from './workspace-management'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
|
||||
async function repository(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-management-'))
|
||||
roots.push(root)
|
||||
git(root, 'init', '-b', 'main')
|
||||
return root
|
||||
}
|
||||
function git(root: string, ...args: string[]): string {
|
||||
return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim()
|
||||
}
|
||||
it('creates, moves, inspects and deletes without overwriting an existing file', async () => {
|
||||
const root = await repository()
|
||||
await manageWorkspace(root, { kind: 'createDirectory', path: 'docs' })
|
||||
await manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })
|
||||
await writeFile(join(root, 'docs/test.txt'), 'keep')
|
||||
await expect(manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })).rejects.toThrow()
|
||||
await manageWorkspace(root, { kind: 'move', path: 'docs/test.txt', destination: 'docs/new.txt' })
|
||||
expect(await readFile(join(root, 'docs/new.txt'), 'utf8')).toBe('keep')
|
||||
await expect(manageWorkspace(root, { kind: 'properties', path: 'docs/new.txt' })).resolves.toMatchObject({ kind: 'properties', size: 4, type: 'file' })
|
||||
await expect(manageWorkspace(root, { kind: 'delete', path: '../outside' })).rejects.toThrow()
|
||||
await manageWorkspace(root, { kind: 'delete', path: 'docs' })
|
||||
})
|
||||
it('reads unborn history, initial commit files and literal paths, and switches with dirty files intact', async () => {
|
||||
const root = await repository()
|
||||
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ commits: [] })
|
||||
await writeFile(join(root, '[test].txt'), 'initial\n')
|
||||
git(root, 'add', '.')
|
||||
git(root, 'commit', '-m', 'Initial subject')
|
||||
const oid = git(root, 'rev-parse', 'HEAD')
|
||||
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] })
|
||||
await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] })
|
||||
await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') })
|
||||
await writeFile(join(root, '[test].txt'), 'dirty\n')
|
||||
await manageWorkspace(root, { kind: 'createBranch', branch: 'feature/test' })
|
||||
await manageWorkspace(root, { kind: 'switchBranch', branch: 'main', remote: false })
|
||||
expect(await readFile(join(root, '[test].txt'), 'utf8')).toBe('dirty\n')
|
||||
expect(git(root, 'stash', 'list')).toBe('')
|
||||
await expect(manageWorkspace(root, { kind: 'branches' })).resolves.toMatchObject({ current: 'main', branches: expect.arrayContaining([{ name: 'feature/test', remote: false }]) })
|
||||
})
|
||||
it('reports switch conflicts without discarding dirty content and pages stable history', async () => {
|
||||
const root = await repository()
|
||||
await writeFile(join(root, 'test.txt'), 'initial')
|
||||
git(root, 'add', '.'); git(root, 'commit', '-m', 'initial')
|
||||
git(root, 'switch', '-c', 'other')
|
||||
await writeFile(join(root, 'test.txt'), 'other')
|
||||
git(root, 'commit', '-am', 'other')
|
||||
git(root, 'switch', 'main')
|
||||
await writeFile(join(root, 'test.txt'), 'dirty')
|
||||
await expect(manageWorkspace(root, { kind: 'switchBranch', branch: 'other', remote: false })).rejects.toThrow(/overwritten/)
|
||||
expect(await readFile(join(root, 'test.txt'), 'utf8')).toBe('dirty')
|
||||
for (let index = 0; index < 51; index++) git(root, 'commit', '--allow-empty', '-m', `page ${index}`)
|
||||
const first = await manageWorkspace(root, { kind: 'history', offset: 0 })
|
||||
expect(first.kind).toBe('history')
|
||||
if (first.kind !== 'history') return
|
||||
expect(first.commits).toHaveLength(50)
|
||||
expect(first.hasMore).toBe(true)
|
||||
const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head })
|
||||
expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] })
|
||||
}, 30_000)
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
|
||||
import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access'
|
||||
|
||||
// Shared by the local workspace and the Agent after resolving its workspace handle.
|
||||
export async function manageWorkspace(rootPath: string, input: WorkspaceManagementAction, signal?: AbortSignal): Promise<WorkspaceManagementResult> {
|
||||
const action = workspaceManagementActionSchema.parse(input)
|
||||
signal?.throwIfAborted()
|
||||
const root = await getCanonicalWorkspace(rootPath)
|
||||
const git = (args: string[]): Promise<string> => new Promise((resolveOutput, reject) => {
|
||||
execFile('git', ['--no-pager', '--literal-pathspecs', '-C', root, ...args], {
|
||||
encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 4 * 1024 * 1024,
|
||||
signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat' }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) reject(new Error(stderr.trim() || error.message))
|
||||
else resolveOutput(stdout)
|
||||
})
|
||||
})
|
||||
if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) {
|
||||
const target = resolve(root, action.path)
|
||||
const parent = await realpath(dirname(target))
|
||||
if (target === root || !isPathInside(root, target) || !isPathInside(root, parent)) throw new Error('Path is outside the workspace')
|
||||
if (action.kind === 'createFile') {
|
||||
const handle = await open(target, 'wx')
|
||||
await handle.close()
|
||||
} else if (action.kind === 'createDirectory') {
|
||||
await mkdir(target)
|
||||
} else {
|
||||
const metadata = await lstat(target)
|
||||
if (metadata.isSymbolicLink()) throw new Error('Cannot modify a symbolic link through workspace management')
|
||||
if (action.kind === 'properties') return { kind: 'properties', path: target, type: metadata.isDirectory() ? 'directory' : metadata.isFile() ? 'file' : 'other', size: metadata.size, modifiedAt: metadata.mtime.toISOString() }
|
||||
if (action.kind === 'delete') await rm(target, { recursive: metadata.isDirectory() })
|
||||
if (action.kind === 'move') {
|
||||
const destination = resolve(root, action.destination)
|
||||
const destinationParent = await realpath(dirname(destination))
|
||||
if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace')
|
||||
try { await lstat(destination) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
signal?.throwIfAborted()
|
||||
await rename(target, destination)
|
||||
return { kind: 'done' }
|
||||
}
|
||||
throw new Error('Destination already exists')
|
||||
}
|
||||
}
|
||||
return { kind: 'done' }
|
||||
}
|
||||
switch (action.kind) {
|
||||
case 'branches': {
|
||||
const current = (await git(['branch', '--show-current'])).trim()
|
||||
const output = await git(['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/heads', 'refs/remotes'])
|
||||
return { kind: 'branches', current, branches: output.trim().split('\n').filter(Boolean).flatMap((line) => {
|
||||
const [ref, symbolic] = line.split('\0')
|
||||
if (!ref || symbolic) return []
|
||||
return [{ name: ref.replace(/^refs\/(heads|remotes)\//, ''), remote: ref.startsWith('refs/remotes/') }]
|
||||
}) }
|
||||
}
|
||||
case 'createBranch':
|
||||
await git(['check-ref-format', '--branch', action.branch])
|
||||
await git(['switch', '-c', action.branch])
|
||||
return { kind: 'done' }
|
||||
case 'switchBranch':
|
||||
await git(['check-ref-format', '--branch', action.branch])
|
||||
await git(action.remote ? ['switch', '--track', action.branch] : ['switch', action.branch])
|
||||
return { kind: 'done' }
|
||||
case 'fetch':
|
||||
await git(['fetch', '--all'])
|
||||
return { kind: 'done' }
|
||||
case 'history': {
|
||||
let head = action.head
|
||||
if (!head) {
|
||||
const refs = (await git(['rev-parse', '--verify', '--quiet', 'HEAD']).catch(async (error: unknown) => {
|
||||
// An unborn branch has no commits; other repository errors remain visible.
|
||||
await git(['symbolic-ref', 'HEAD'])
|
||||
if ((await git(['rev-list', '--all', '--max-count=1'])).trim()) throw error
|
||||
return ''
|
||||
})).trim()
|
||||
if (!refs) return { kind: 'history', commits: [], hasMore: false }
|
||||
head = refs
|
||||
}
|
||||
const output = await git(['log', head, `--skip=${action.offset}`, '-n', '51', '--format=%H%x00%s%x00%an%x00%aI%x00%D%x00'])
|
||||
const fields = output.split('\0')
|
||||
const commits: Extract<WorkspaceManagementResult, { kind: 'history' }>['commits'] = []
|
||||
for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! })
|
||||
return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 }
|
||||
}
|
||||
case 'commitFiles': {
|
||||
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid])
|
||||
const fields = output.split('\0')
|
||||
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
|
||||
for (let index = 0; index + 1 < fields.length;) {
|
||||
const status = fields[index++]!
|
||||
const first = fields[index++]!
|
||||
const renamed = /^[RC]/.test(status)
|
||||
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
|
||||
}
|
||||
return { kind: 'commitFiles', files }
|
||||
}
|
||||
case 'commitDiff': {
|
||||
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path])
|
||||
return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 }
|
||||
}
|
||||
default: throw new Error('Unsupported workspace action')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user