feat: 添加工作空间面板 - 右侧终端/文件浏览器

- 新增主进程 workspace.ts:spawn 进程管理,流式输出,安全检查
- IPC 双向通信:workspace:exec(on/send 模式,无超时限制)
- preload 暴露 workspace API
- 渲染进程 workspace-panel.ts:多终端 Tab、ANSI 颜色渲染、文件浏览器
- 设置面板添加工作空间目录配置
- 启动时自动创建 workspace 目录
- 窗口关闭时自动清理所有子进程
- 帮助文档更新
This commit is contained in:
thzxx
2026-04-07 18:30:45 +08:00
parent 9485b4c09e
commit 0e1f216aa4
10 changed files with 1555 additions and 0 deletions
+58
View File
@@ -17,6 +17,7 @@ import {
handleRunCommand
} from './tool-handlers.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
@@ -108,4 +109,61 @@ export function setupIPC(): void {
ipcMain.handle('tool:setAllowedDirs', (_, dirs: string[]) => {
setAllowedDirs(dirs);
});
// ── Workspace IPC ──
// 获取工作空间目录
ipcMain.handle('workspace:getDir', () => {
return { dir: getWorkspaceDir(), defaultDir: getWorkspaceDir() };
});
// 设置工作空间目录
ipcMain.handle('workspace:setDir', (_, dir: string) => {
try {
setWorkspaceDir(dir);
return { success: true, dir: getWorkspaceDir() };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
// 列出工作空间目录
ipcMain.handle('workspace:listDir', (_, dirPath?: string) => {
return listWorkspaceDir(dirPath);
});
// 读取工作空间文件(复用 tool-handlers
ipcMain.handle('workspace:readFile', async (_, filePath: string) => {
return handleReadFile({ path: filePath });
});
// 启动命令(流式输出,通过 workspace:output 事件推送)
ipcMain.on('workspace:exec', (event, { id, command, cwd }: { id: string; command: string; cwd?: string }) => {
console.log('[IPC] workspace:exec', id, command.slice(0, 200));
const result = startProcess(
id,
command,
cwd,
(type, data) => {
// 流式推送输出到渲染进程
event.sender.send('workspace:output', { id, type, data });
},
(code) => {
// 通知进程结束
event.sender.send('workspace:exit', { id, code });
}
);
if (!result.success) {
event.sender.send('workspace:output', { id, type: 'stderr', data: result.error + '\n' });
event.sender.send('workspace:exit', { id, code: 1 });
}
});
// 停止命令
ipcMain.on('workspace:kill', (_, id: string) => {
console.log('[IPC] workspace:kill', id);
killProcess(id);
});
}
+4
View File
@@ -9,6 +9,7 @@ import { setupIPC } from './ipc.js';
import { createTray } from './tray.js';
import { createMenu } from './menu.js';
import { showNotification } from './utils.js';
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
const APP_NAME = 'Metona Ollama';
const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png');
@@ -143,6 +144,7 @@ if (!gotTheLock) {
}
app.whenReady().then(() => {
ensureWorkspaceDir();
setupIPC();
createMainWindow();
createTray();
@@ -165,6 +167,8 @@ app.on('window-all-closed', () => {
app.on('before-quit', () => {
isQuitting = true;
// 清理所有工作空间进程
killAllProcesses();
// 通知渲染进程释放显存
mainWindow?.webContents.send('app-quit');
});
+18
View File
@@ -38,5 +38,23 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
},
workspace: {
getDir: () => ipcRenderer.invoke('workspace:getDir'),
setDir: (dir: string) => ipcRenderer.invoke('workspace:setDir', dir),
listDir: (dirPath?: string) => ipcRenderer.invoke('workspace:listDir', dirPath),
readFile: (filePath: string) => ipcRenderer.invoke('workspace:readFile', filePath),
exec: (params: { id: string; command: string; cwd?: string }) => {
ipcRenderer.send('workspace:exec', params);
},
kill: (id: string) => {
ipcRenderer.send('workspace:kill', id);
},
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => {
ipcRenderer.on('workspace:output', (_: unknown, data: { id: string; type: 'stdout' | 'stderr'; data: string }) => callback(data));
},
onExit: (callback: (data: { id: string; code: number | null }) => void) => {
ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data));
}
}
});
+181
View File
@@ -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;
}