Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
359 lines
8.5 KiB
TypeScript
359 lines
8.5 KiB
TypeScript
import spawn from 'cross-spawn'
|
|
import { basename, extname, join } from 'node:path'
|
|
import { stat } from 'node:fs/promises'
|
|
import type {
|
|
WorkspaceChangedFile,
|
|
WorkspaceChanges,
|
|
WorkspaceDirectoryListing,
|
|
WorkspaceFilePreview
|
|
} from '../../shared/assistant-contracts'
|
|
import {
|
|
getCanonicalWorkspace,
|
|
listBoundedDirectoryEntries,
|
|
readBoundedUtf8File,
|
|
resolveExistingWorkspacePath
|
|
} from '../workspace-file-access'
|
|
|
|
const MAX_OUTPUT_BYTES = 512 * 1024
|
|
const COMMAND_TIMEOUT_MS = 10_000
|
|
const MAX_DIRECTORY_ENTRIES = 500
|
|
const MAX_CHANGED_FILES = 2_000
|
|
const MAX_PREVIEW_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'
|
|
])
|
|
|
|
type CommandResult = {
|
|
code: number | null
|
|
stdout: string
|
|
stderr: string
|
|
truncated: boolean
|
|
}
|
|
|
|
function runGit(
|
|
rootPath: string,
|
|
args: string[]
|
|
): Promise<CommandResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('git', args, {
|
|
cwd: rootPath,
|
|
shell: false,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
const stdout: Buffer[] = []
|
|
const stderr: Buffer[] = []
|
|
let bytes = 0
|
|
let truncated = false
|
|
const capture = (target: Buffer[], chunk: Buffer | string): void => {
|
|
const buffer = Buffer.from(chunk)
|
|
const remaining = MAX_OUTPUT_BYTES - bytes
|
|
if (remaining <= 0) {
|
|
truncated = true
|
|
return
|
|
}
|
|
target.push(buffer.subarray(0, remaining))
|
|
bytes += Math.min(buffer.byteLength, remaining)
|
|
truncated ||= buffer.byteLength > remaining
|
|
}
|
|
child.stdout?.on('data', (chunk: Buffer | string) =>
|
|
capture(stdout, chunk)
|
|
)
|
|
child.stderr?.on('data', (chunk: Buffer | string) =>
|
|
capture(stderr, chunk)
|
|
)
|
|
const timeout = setTimeout(() => {
|
|
child.kill()
|
|
reject(new Error('读取文件更改超时'))
|
|
}, COMMAND_TIMEOUT_MS)
|
|
child.once('error', (error) => {
|
|
clearTimeout(timeout)
|
|
reject(error)
|
|
})
|
|
child.once('close', (code) => {
|
|
clearTimeout(timeout)
|
|
resolve({
|
|
code,
|
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
truncated
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
function pathSegments(inputPath: string, allowRoot: boolean): string[] {
|
|
const normalized = inputPath.replaceAll('\\', '/')
|
|
if (allowRoot && normalized === '') {
|
|
return []
|
|
}
|
|
if (
|
|
!normalized ||
|
|
normalized.startsWith('/') ||
|
|
/^[a-zA-Z]:\//u.test(normalized)
|
|
) {
|
|
throw new Error('路径必须是工作区内的相对路径')
|
|
}
|
|
const segments = normalized.split('/')
|
|
if (
|
|
segments.some(
|
|
(segment) =>
|
|
!segment || segment === '.' || segment === '..' || segment.includes('\0')
|
|
)
|
|
) {
|
|
throw new Error('路径必须是工作区内的相对路径')
|
|
}
|
|
return segments
|
|
}
|
|
|
|
async function resolveWorkspacePath(
|
|
rootPath: string,
|
|
inputPath: string,
|
|
expected: 'file' | 'directory'
|
|
): Promise<{ canonicalPath: string; path: string }> {
|
|
const canonicalRoot = await getCanonicalWorkspace(rootPath)
|
|
const segments = pathSegments(inputPath, expected === 'directory')
|
|
const canonicalPath = await resolveExistingWorkspacePath(
|
|
canonicalRoot,
|
|
segments,
|
|
expected
|
|
)
|
|
return {
|
|
canonicalPath,
|
|
path: segments.join('/')
|
|
}
|
|
}
|
|
|
|
export async function resolveWorkspaceEntryPath(
|
|
rootPath: string,
|
|
inputPath: string,
|
|
expected: 'file' | 'directory'
|
|
): Promise<string> {
|
|
return (await resolveWorkspacePath(rootPath, inputPath, expected))
|
|
.canonicalPath
|
|
}
|
|
|
|
function parseChangedFiles(status: string): {
|
|
files: WorkspaceChangedFile[]
|
|
truncated: boolean
|
|
} {
|
|
const records = status.split('\0')
|
|
const files: WorkspaceChangedFile[] = []
|
|
let index = 0
|
|
while (index < records.length && files.length < MAX_CHANGED_FILES) {
|
|
const record = records[index]
|
|
index += 1
|
|
if (!record) {
|
|
continue
|
|
}
|
|
const statusCode = record.slice(0, 2)
|
|
const path = record.slice(3)
|
|
if (!path) {
|
|
continue
|
|
}
|
|
const renamed = statusCode.includes('R') || statusCode.includes('C')
|
|
const previousPath = renamed ? records[index] : undefined
|
|
if (renamed) {
|
|
index += 1
|
|
}
|
|
files.push({
|
|
path,
|
|
status: statusCode,
|
|
...(previousPath ? { previousPath } : {})
|
|
})
|
|
}
|
|
return {
|
|
files,
|
|
truncated: index < records.length - 1
|
|
}
|
|
}
|
|
|
|
function formatChangedFiles(files: WorkspaceChangedFile[]): string {
|
|
return files
|
|
.map((file) =>
|
|
file.previousPath
|
|
? `${file.status} ${file.previousPath} -> ${file.path}`
|
|
: `${file.status} ${file.path}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
export async function getWorkspaceChanges(
|
|
rootPath: string
|
|
): Promise<WorkspaceChanges> {
|
|
if (!rootPath.trim()) {
|
|
return {
|
|
rootPath,
|
|
available: false,
|
|
status: '',
|
|
patch: '',
|
|
files: [],
|
|
truncated: false,
|
|
error: '项目尚未配置工作区目录'
|
|
}
|
|
}
|
|
const gitMetadata = await stat(join(rootPath, '.git')).catch(
|
|
() => undefined
|
|
)
|
|
if (!gitMetadata) {
|
|
return {
|
|
rootPath,
|
|
available: false,
|
|
status: '',
|
|
patch: '',
|
|
files: [],
|
|
truncated: false
|
|
}
|
|
}
|
|
try {
|
|
const [status, patch] = await Promise.all([
|
|
runGit(rootPath, [
|
|
'status',
|
|
'--porcelain=v1',
|
|
'-z',
|
|
'--untracked-files=normal'
|
|
]),
|
|
runGit(rootPath, ['diff', '--no-ext-diff', '--no-color', 'HEAD'])
|
|
])
|
|
if (status.code !== 0 || patch.code !== 0) {
|
|
const detail = status.stderr || patch.stderr
|
|
return {
|
|
rootPath,
|
|
available: false,
|
|
status: '',
|
|
patch: '',
|
|
files: [],
|
|
truncated: status.truncated || patch.truncated,
|
|
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
|
|
}
|
|
}
|
|
const changedFiles = parseChangedFiles(status.stdout)
|
|
return {
|
|
rootPath,
|
|
available: true,
|
|
status: formatChangedFiles(changedFiles.files),
|
|
patch: patch.stdout,
|
|
files: changedFiles.files,
|
|
truncated:
|
|
status.truncated || patch.truncated || changedFiles.truncated
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
rootPath,
|
|
available: false,
|
|
status: '',
|
|
patch: '',
|
|
files: [],
|
|
truncated: false,
|
|
error:
|
|
error instanceof Error ? error.message : '无法读取 Git 工作区'
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function listWorkspaceDirectory(
|
|
rootPath: string,
|
|
inputPath: string
|
|
): Promise<WorkspaceDirectoryListing> {
|
|
const directory = await resolveWorkspacePath(
|
|
rootPath,
|
|
inputPath,
|
|
'directory'
|
|
)
|
|
const listing = await listBoundedDirectoryEntries(
|
|
directory.canonicalPath,
|
|
MAX_DIRECTORY_ENTRIES,
|
|
(entry) =>
|
|
entry.name !== '.git' && (entry.isDirectory() || entry.isFile())
|
|
)
|
|
const entries = listing.entries.sort((left, right) => {
|
|
if (left.isDirectory() !== right.isDirectory()) {
|
|
return left.isDirectory() ? -1 : 1
|
|
}
|
|
return left.name.localeCompare(right.name)
|
|
})
|
|
return {
|
|
path: directory.path,
|
|
entries: entries.map((entry) => ({
|
|
name: entry.name,
|
|
path: [directory.path, entry.name].filter(Boolean).join('/'),
|
|
type: entry.isDirectory() ? 'directory' : 'file'
|
|
})),
|
|
truncated: listing.truncated
|
|
}
|
|
}
|
|
|
|
export async function readWorkspaceFile(
|
|
rootPath: string,
|
|
inputPath: string
|
|
): Promise<WorkspaceFilePreview> {
|
|
const file = await resolveWorkspacePath(rootPath, inputPath, 'file')
|
|
const name = basename(file.canonicalPath)
|
|
const extension = extname(name).toLowerCase()
|
|
if (
|
|
!previewExtensions.has(extension) &&
|
|
!previewFileNames.has(name.toLowerCase())
|
|
) {
|
|
throw new Error('当前文件类型不支持安全预览')
|
|
}
|
|
const preview = await readBoundedUtf8File(
|
|
file.canonicalPath,
|
|
MAX_PREVIEW_BYTES,
|
|
'工作区文件超过 256KB 预览限制',
|
|
'工作区文件不是有效 UTF-8 文本'
|
|
)
|
|
return {
|
|
path: file.path,
|
|
name,
|
|
content: preview.content,
|
|
mimeType:
|
|
extension === '.md' || extension === '.markdown'
|
|
? 'text/markdown'
|
|
: extension === '.json'
|
|
? 'application/json'
|
|
: 'text/plain',
|
|
size: preview.size
|
|
}
|
|
}
|