feat: 添加工作空间面板 - 右侧终端/文件浏览器
- 新增主进程 workspace.ts:spawn 进程管理,流式输出,安全检查 - IPC 双向通信:workspace:exec(on/send 模式,无超时限制) - preload 暴露 workspace API - 渲染进程 workspace-panel.ts:多终端 Tab、ANSI 颜色渲染、文件浏览器 - 设置面板添加工作空间目录配置 - 启动时自动创建 workspace 目录 - 窗口关闭时自动清理所有子进程 - 帮助文档更新
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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