feat: 添加工作空间面板 - 右侧终端/文件浏览器
- 新增主进程 workspace.ts:spawn 进程管理,流式输出,安全检查 - IPC 双向通信:workspace:exec(on/send 模式,无超时限制) - preload 暴露 workspace API - 渲染进程 workspace-panel.ts:多终端 Tab、ANSI 颜色渲染、文件浏览器 - 设置面板添加工作空间目录配置 - 启动时自动创建 workspace 目录 - 窗口关闭时自动清理所有子进程 - 帮助文档更新
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Workspace - 终端进程管理器
|
||||
* 管理长时间运行的子进程,通过 IPC 流式推送输出
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
||||
|
||||
export const DEFAULT_WORKSPACE_DIR = path.join(app.getPath('userData'), 'workspace');
|
||||
|
||||
/** 活跃进程 Map */
|
||||
const activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
/** 确保工作空间目录存在 */
|
||||
export function ensureWorkspaceDir(): void {
|
||||
if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) {
|
||||
fs.mkdirSync(DEFAULT_WORKSPACE_DIR, { recursive: true });
|
||||
console.log('[Workspace] 创建默认工作空间目录:', DEFAULT_WORKSPACE_DIR);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前工作空间目录(支持用户自定义) */
|
||||
let _workspaceDir = DEFAULT_WORKSPACE_DIR;
|
||||
|
||||
export function getWorkspaceDir(): string {
|
||||
return _workspaceDir;
|
||||
}
|
||||
|
||||
export function setWorkspaceDir(dir: string): void {
|
||||
const resolved = path.resolve(dir);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
}
|
||||
_workspaceDir = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个命令进程
|
||||
* @param id 唯一标识,用于后续 kill
|
||||
* @param command 要执行的 shell 命令
|
||||
* @param cwd 工作目录,默认 workspace
|
||||
* @param onOutput 输出回调 (type, data)
|
||||
* @param onExit 退出回调 (code)
|
||||
*/
|
||||
export function startProcess(
|
||||
id: string,
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
onOutput: (type: 'stdout' | 'stderr', data: string) => void,
|
||||
onExit: (code: number | null) => void
|
||||
): { success: boolean; error?: string } {
|
||||
// 安全检查
|
||||
const cmdCheck = checkCommandAllowed(command);
|
||||
if (!cmdCheck.ok) {
|
||||
return { success: false, error: cmdCheck.reason };
|
||||
}
|
||||
|
||||
const workDir = cwd ? path.resolve(cwd) : _workspaceDir;
|
||||
const dirCheck = checkPathAllowed(workDir, 'read');
|
||||
if (!dirCheck.ok) {
|
||||
return { success: false, error: dirCheck.reason };
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(workDir)) {
|
||||
return { success: false, error: `工作目录不存在: ${workDir}` };
|
||||
}
|
||||
|
||||
// 杀掉同 id 的旧进程
|
||||
if (activeProcesses.has(id)) {
|
||||
try { activeProcesses.get(id)!.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
activeProcesses.delete(id);
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn('bash', ['-c', command], {
|
||||
cwd: workDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
activeProcesses.set(id, proc);
|
||||
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
onOutput('stdout', data.toString());
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
onOutput('stderr', data.toString());
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
activeProcesses.delete(id);
|
||||
onExit(code);
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
activeProcesses.delete(id);
|
||||
onOutput('stderr', `进程启动失败: ${err.message}\n`);
|
||||
onExit(1);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止指定进程 */
|
||||
export function killProcess(id: string): boolean {
|
||||
const proc = activeProcesses.get(id);
|
||||
if (!proc) return false;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
activeProcesses.delete(id);
|
||||
return true;
|
||||
} catch {
|
||||
activeProcesses.delete(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止所有进程(窗口关闭时调用) */
|
||||
export function killAllProcesses(): void {
|
||||
for (const [id, proc] of activeProcesses) {
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
}
|
||||
activeProcesses.clear();
|
||||
}
|
||||
|
||||
/** 列出工作空间目录内容 */
|
||||
export function listWorkspaceDir(dirPath?: string): { success: boolean; entries?: Array<{ name: string; type: string; size: number | null; modified: string }>; error?: string } {
|
||||
try {
|
||||
const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir;
|
||||
|
||||
// 安全检查:必须在 workspace 范围内
|
||||
if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) {
|
||||
return { success: false, error: '只能浏览工作空间目录内的文件' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return { success: false, error: '目录不存在' };
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(targetDir, { withFileTypes: true });
|
||||
const entries = items
|
||||
.filter(item => !item.name.startsWith('.'))
|
||||
.map(item => {
|
||||
const fullPath = path.join(targetDir, item.name);
|
||||
const stat = fs.statSync(fullPath);
|
||||
return {
|
||||
name: item.name,
|
||||
type: item.isDirectory() ? 'directory' : 'file',
|
||||
size: item.isFile() ? stat.size : null,
|
||||
modified: stat.mtime.toISOString()
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 目录优先,然后按名称排序
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return { success: true, entries };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查进程是否还活着 */
|
||||
export function isProcessAlive(id: string): boolean {
|
||||
return activeProcesses.has(id);
|
||||
}
|
||||
|
||||
/** 获取活跃进程数量 */
|
||||
export function getActiveProcessCount(): number {
|
||||
return activeProcesses.size;
|
||||
}
|
||||
Reference in New Issue
Block a user