diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b7dc9ea..0ecca14 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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); + }); } diff --git a/src/main/main.ts b/src/main/main.ts index b466956..5819e43 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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'); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 8c5b942..a6fd1a1 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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)); + } } }); diff --git a/src/main/workspace.ts b/src/main/workspace.ts new file mode 100644 index 0000000..6639d5d --- /dev/null +++ b/src/main/workspace.ts @@ -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(); + +/** 确保工作空间目录存在 */ +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; +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index dd0a775..8f4cb07 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -167,6 +167,53 @@ export function initSettingsModal(): void { logSetting('Agent 记忆', toggleMemory.checked ? '开启' : '关闭'); }); } + + // ── 工作空间目录设置 ── + const inputWorkspaceDir = document.querySelector('#inputWorkspaceDir') as HTMLInputElement; + const btnBrowseWorkspace = document.querySelector('#btnBrowseWorkspace') as HTMLButtonElement; + if (inputWorkspaceDir) { + // 加载当前工作空间目录 + const bridge = (window as any).metonaDesktop; + if (bridge?.isDesktop) { + bridge.workspace.getDir().then((info: { dir: string }) => { + inputWorkspaceDir.value = info.dir; + }).catch(() => {}); + } + } + if (btnBrowseWorkspace) { + btnBrowseWorkspace.addEventListener('click', async () => { + const bridge = (window as any).metonaDesktop; + if (!bridge?.isDesktop) return; + const paths = await bridge.dialog.openFile({ + filters: [{ name: '文件夹', extensions: ['*'] }] + }); + // Electron openFile 对话框不能直接选文件夹,这里用 saveFile 代替 + // 或者让用户手动输入路径 + showToast('请在输入框中直接修改路径', 'info'); + }); + // 双击输入框解锁编辑 + inputWorkspaceDir.addEventListener('dblclick', () => { + inputWorkspaceDir.removeAttribute('readonly'); + inputWorkspaceDir.focus(); + }); + inputWorkspaceDir.addEventListener('blur', async () => { + inputWorkspaceDir.setAttribute('readonly', ''); + const newDir = inputWorkspaceDir.value.trim(); + if (!newDir) return; + const bridge = (window as any).metonaDesktop; + if (!bridge?.isDesktop) return; + const result = await bridge.workspace.setDir(newDir); + if (result.success) { + showToast('工作空间目录已更新', 'success'); + logSetting('工作空间目录', result.dir); + } else { + showToast(`设置失败: ${result.error}`, 'error'); + // 恢复原值 + const info = await bridge.workspace.getDir(); + inputWorkspaceDir.value = info.dir; + } + }); + } } export function openSettingsModal(): void { diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts new file mode 100644 index 0000000..36e57d0 --- /dev/null +++ b/src/renderer/components/workspace-panel.ts @@ -0,0 +1,714 @@ +/** + * Workspace Panel — 右侧工作空间面板 + * 包含命令行和文件两个 Tab + */ + +import { logInfo, logError, logDebug, logWarn } from '../services/log-service.js'; +import type { WorkspaceFileEntry } from '../types.js'; + +// ── 状态 ── +interface TerminalSession { + id: string; + title: string; + lines: TerminalLine[]; + running: boolean; + autoScroll: boolean; +} + +interface TerminalLine { + type: 'stdout' | 'stderr' | 'system'; + text: string; +} + +interface FileNode { + name: string; + type: 'file' | 'directory'; + size: number | null; + modified: string; + expanded?: boolean; + children?: FileNode[]; + loaded?: boolean; +} + +let panelVisible = false; +let activeTab: 'terminal' | 'files' = 'terminal'; +let terminalSessions: TerminalSession[] = []; +let activeTerminalId = ''; +let workspaceDir = ''; +let currentFileDir = ''; +let fileTree: FileNode[] = []; +let previewFile: { name: string; content: string; path: string } | null = null; +let _counter = 0; + +function genId(): string { + return `ws_${Date.now()}_${++_counter}`; +} + +// ── ANSI 颜色处理 ── +const ANSI_COLORS: Record = { + '30': '#666', '31': '#ff6b68', '32': '#5fb55f', '33': '#f5a742', + '34': '#6db5f5', '35': '#d381d3', '36': '#38c9c9', '37': '#ccc', + '90': '#888', '91': '#ff8a8a', '92': '#7ed97e', '93': '#ffd580', + '94': '#8ec8ff', '95': '#e8a0e8', '96': '#5ee8e8', '97': '#fff', +}; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function ansiToHtml(raw: string): string { + // 先转义 HTML + let text = escapeHtml(raw); + + // 处理 ANSI 转义序列 + // \x1b[XXm 颜色, \x1b[0m 重置, \x1b[1m 加粗, \x1b[K 清除行尾 + let result = ''; + let openSpan = false; + let i = 0; + + while (i < text.length) { + // 匹配 \x1b[...m 或 \x1b[...K + if (text[i] === '\x1b' && text[i + 1] === '[') { + const end = text.indexOf('m', i + 2); + const endK = text.indexOf('K', i + 2); + const seqEnd = end !== -1 && (endK === -1 || end < endK) ? end : endK; + + if (seqEnd !== -1 && seqEnd - i < 20) { + const codes = text.substring(i + 2, seqEnd).split(';'); + + // 忽略 \x1b[K (清除行) + if (text[seqEnd] === 'K') { + i = seqEnd + 1; + continue; + } + + if (openSpan) { + result += ''; + openSpan = false; + } + + // 检查是否有颜色代码 + let color = ''; + let bold = false; + for (const code of codes) { + if (code === '0' || code === '') { + // reset + } else if (code === '1') { + bold = true; + } else if (ANSI_COLORS[code]) { + color = ANSI_COLORS[code]; + } + } + + if (color || bold) { + const styles: string[] = []; + if (color) styles.push(`color:${color}`); + if (bold) styles.push('font-weight:bold'); + result += ``; + openSpan = true; + } + + i = seqEnd + 1; + continue; + } + } + + // 处理回车符(ollama pull 等用 \r 更新进度) + if (text[i] === '\r' && text[i + 1] !== '\n') { + // \r 后面不是 \n → 进度更新,忽略之前的内容 + // 找到上一个 \n 或开头 + const lastNewline = result.lastIndexOf('\n'); + if (lastNewline !== -1) { + result = result.substring(0, lastNewline + 1); + } else { + // 清除当前行(通过移除到最后一个 之后的内容太复杂,简化处理) + const spanStart = result.lastIndexOf('><'); + if (spanStart !== -1) { + // 不处理,保留 + } + } + i++; + continue; + } + + result += text[i]; + i++; + } + + if (openSpan) result += ''; + return result; +} + +// ── 初始化 ── +export async function initWorkspacePanel(): Promise { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) { + logDebug('非桌面环境,跳过工作空间面板初始化'); + return; + } + + // 获取工作空间目录 + try { + const dirInfo = await bridge.workspace.getDir(); + workspaceDir = dirInfo.dir; + currentFileDir = workspaceDir; + logInfo('工作空间目录', workspaceDir); + } catch (err) { + logError('获取工作空间目录失败', (err as Error).message); + } + + // 监听进程输出 + bridge.workspace.onOutput((data) => { + appendTerminalOutput(data.id, data.type, data.data); + }); + + bridge.workspace.onExit((data) => { + handleProcessExit(data.id, data.code); + }); + + // 绑定 UI 事件 + bindEvents(); + + // 创建默认终端 + createTerminalSession('终端 1'); + + logInfo('工作空间面板已初始化'); +} + +function bindEvents(): void { + // 切换面板显示 + document.querySelector('#btnWorkspace')?.addEventListener('click', togglePanel); + document.querySelector('#btnWorkspaceClose')?.addEventListener('click', () => { + if (panelVisible) togglePanel(); + }); + + // Tab 切换 + document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal')); + document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files')); + + // 终端输入 + const termInput = document.querySelector('#wsTermInput') as HTMLInputElement; + termInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const cmd = termInput.value.trim(); + if (cmd) { + executeCommand(cmd); + termInput.value = ''; + } + } + }); + + // 执行按钮 + document.querySelector('#wsExecBtn')?.addEventListener('click', () => { + const cmd = termInput?.value.trim(); + if (cmd) { + executeCommand(cmd); + termInput.value = ''; + } + }); + + // 停止按钮 + document.querySelector('#wsStopBtn')?.addEventListener('click', killCurrentProcess); + + // 清空按钮 + document.querySelector('#wsClearBtn')?.addEventListener('click', clearTerminal); + + // 新建终端 Tab + document.querySelector('#wsNewTabBtn')?.addEventListener('click', () => { + const num = terminalSessions.length + 1; + createTerminalSession(`终端 ${num}`); + }); + + // 文件刷新 + document.querySelector('#wsFileRefreshBtn')?.addEventListener('click', () => { + loadFiles(currentFileDir || workspaceDir); + }); + + // 文件返回上级 + document.querySelector('#wsFileUpBtn')?.addEventListener('click', () => { + if (currentFileDir && currentFileDir !== workspaceDir) { + const parent = currentFileDir.replace(/[/\\][^/\\]+$/, ''); + loadFiles(parent); + } + }); + + // 关闭预览 + document.querySelector('#wsPreviewClose')?.addEventListener('click', () => { + previewFile = null; + renderPanel(); + }); + + // 拖拽调整宽度 + setupResizer(); +} + +function setupResizer(): void { + const resizer = document.querySelector('#wsResizer') as HTMLElement; + const panel = document.querySelector('#workspacePanel') as HTMLElement; + if (!resizer || !panel) return; + + let startX = 0; + let startWidth = 0; + + resizer.addEventListener('mousedown', (e) => { + e.preventDefault(); + startX = e.clientX; + startWidth = panel.offsetWidth; + + const onMouseMove = (e: MouseEvent) => { + const delta = startX - e.clientX; // 向左拖拽增大宽度 + const newWidth = Math.min(700, Math.max(300, startWidth + delta)); + panel.style.width = newWidth + 'px'; + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); +} + +// ── 面板控制 ── +export function togglePanel(): void { + panelVisible = !panelVisible; + const panel = document.querySelector('#workspacePanel') as HTMLElement; + const btn = document.querySelector('#btnWorkspace') as HTMLElement; + const mainWrap = document.querySelector('#mainWrap') as HTMLElement; + + if (panel) panel.style.display = panelVisible ? '' : 'none'; + if (btn) btn.classList.toggle('active', panelVisible); + if (mainWrap) mainWrap.classList.toggle('with-workspace', panelVisible); + + if (panelVisible) { + if (activeTab === 'files') loadFiles(currentFileDir || workspaceDir); + const termInput = document.querySelector('#wsTermInput') as HTMLElement; + termInput?.focus(); + } +} + +function switchTab(tab: 'terminal' | 'files'): void { + activeTab = tab; + renderPanel(); + + if (tab === 'files') { + loadFiles(currentFileDir || workspaceDir); + } +} + +// ── 终端管理 ── +function createTerminalSession(title: string): string { + const session: TerminalSession = { + id: genId(), + title, + lines: [], + running: false, + autoScroll: true + }; + terminalSessions.push(session); + activeTerminalId = session.id; + renderPanel(); + return session.id; +} + +function getActiveSession(): TerminalSession | undefined { + return terminalSessions.find(s => s.id === activeTerminalId); +} + +function executeCommand(command: string): void { + const session = getActiveSession(); + if (!session) return; + + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return; + + // 显示命令 + session.lines.push({ type: 'system', text: `$ ${command}` }); + session.running = true; + session.autoScroll = true; + + renderTerminal(); + + const cwd = currentFileDir || workspaceDir; + bridge.workspace.exec({ id: session.id, command, cwd }); + logInfo('工作空间执行命令', command.slice(0, 100)); +} + +function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void { + const session = terminalSessions.find(s => s.id === sessionId); + if (!session) return; + + // 按行分割,处理 \r 进度更新 + const lines = data.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i < lines.length - 1 || line) { + // 处理 \r 覆盖(进度条效果) + if (line.includes('\r') && !line.endsWith('\n')) { + const parts = line.split('\r'); + const lastPart = parts[parts.length - 1]; + if (session.lines.length > 0 && session.lines[session.lines.length - 1].type !== 'system') { + session.lines[session.lines.length - 1] = { type, text: lastPart }; + } else { + session.lines.push({ type, text: lastPart }); + } + } else { + const cleanLine = line.replace(/\r/g, ''); + if (cleanLine) session.lines.push({ type, text: cleanLine }); + } + } + } + + // 限制最大行数 + if (session.lines.length > 3000) { + session.lines = session.lines.slice(-2000); + } + + renderTerminal(); +} + +function handleProcessExit(sessionId: string, code: number | null): void { + const session = terminalSessions.find(s => s.id === sessionId); + if (!session) return; + + session.running = false; + const exitMsg = code === 0 ? '✓ 进程正常退出' : `✗ 进程退出 (code: ${code})`; + session.lines.push({ type: 'system', text: exitMsg }); + + renderTerminal(); + + // 更新停止按钮状态 + updateStopBtnState(); +} + +function killCurrentProcess(): void { + const session = getActiveSession(); + if (!session || !session.running) return; + + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return; + + bridge.workspace.kill(session.id); + session.lines.push({ type: 'system', text: '■ 已发送终止信号' }); + renderTerminal(); +} + +function clearTerminal(): void { + const session = getActiveSession(); + if (!session) return; + session.lines = []; + renderTerminal(); +} + +function closeTerminalTab(sessionId: string): void { + if (terminalSessions.length <= 1) return; // 至少保留一个 + + const session = terminalSessions.find(s => s.id === sessionId); + if (session?.running) { + window.metonaDesktop?.workspace.kill(sessionId); + } + + terminalSessions = terminalSessions.filter(s => s.id !== sessionId); + if (activeTerminalId === sessionId) { + activeTerminalId = terminalSessions[0].id; + } + renderPanel(); +} + +// ── 文件浏览器 ── +async function loadFiles(dirPath: string): Promise { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return; + + try { + const result = await bridge.workspace.listDir(dirPath); + if (result.success && result.entries) { + currentFileDir = dirPath; + fileTree = result.entries.map(e => ({ + name: e.name, + type: e.type, + size: e.size, + modified: e.modified + })); + } + } catch (err) { + logError('加载目录失败', (err as Error).message); + } + + renderPanel(); +} + +async function openFile(filePath: string, fileName: string): Promise { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return; + + try { + const result = await bridge.workspace.readFile(filePath); + if (result.success && result.content !== undefined) { + previewFile = { name: fileName, content: result.content, path: filePath }; + renderPanel(); + } + } catch (err) { + logError('读取文件失败', (err as Error).message); + } +} + +function navigateToDir(dirName: string): void { + const newPath = currentFileDir + '/' + dirName; + loadFiles(newPath.replace(/\/+/g, '/')); +} + +// ── 渲染 ── +export function renderPanel(): void { + const panel = document.querySelector('#workspacePanel') as HTMLElement; + if (!panel) return; + + const termTab = document.querySelector('#wsTabTerminal') as HTMLElement; + const filesTab = document.querySelector('#wsTabFiles') as HTMLElement; + + if (termTab) termTab.classList.toggle('active', activeTab === 'terminal'); + if (filesTab) filesTab.classList.toggle('active', activeTab === 'files'); + + const termContent = document.querySelector('#wsTerminalContent') as HTMLElement; + const filesContent = document.querySelector('#wsFilesContent') as HTMLElement; + + if (termContent) termContent.style.display = activeTab === 'terminal' ? '' : 'none'; + if (filesContent) filesContent.style.display = activeTab === 'files' ? '' : 'none'; + + if (activeTab === 'terminal') { + renderTerminal(); + renderTerminalTabs(); + } else { + renderFileList(); + } + + updateStopBtnState(); +} + +function renderTerminal(): void { + const container = document.querySelector('#wsTermOutput') as HTMLElement; + if (!container) return; + + const session = getActiveSession(); + if (!session) { + container.innerHTML = '
无终端会话
'; + return; + } + + // 使用 DocumentFragment 优化渲染 + const fragment = document.createDocumentFragment(); + const wrapper = document.createElement('div'); + + for (const line of session.lines) { + const div = document.createElement('div'); + div.className = `ws-term-line ws-term-${line.type}`; + div.innerHTML = ansiToHtml(line.text); + wrapper.appendChild(div); + } + + container.innerHTML = wrapper.innerHTML; + + // 自动滚动到底部 + if (session.autoScroll) { + container.scrollTop = container.scrollHeight; + } + + // 监听滚动事件 + container.onscroll = () => { + const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + session.autoScroll = atBottom; + }; +} + +function renderTerminalTabs(): void { + const tabBar = document.querySelector('#wsTermTabBar') as HTMLElement; + if (!tabBar) return; + + tabBar.innerHTML = terminalSessions.map(s => { + const active = s.id === activeTerminalId; + const running = s.running ? ' ●' : ''; + const closeBtn = terminalSessions.length > 1 + ? `` + : ''; + return `
+ ${escapeHtml(s.title)}${running} + ${closeBtn} +
`; + }).join(''); + + // 绑定 tab 点击事件 + tabBar.querySelectorAll('.ws-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const closeBtn = target.closest('.ws-tab-close') as HTMLElement; + if (closeBtn) { + e.stopPropagation(); + closeTerminalTab(closeBtn.dataset.close!); + return; + } + const sessionId = (tab as HTMLElement).dataset.session; + if (sessionId) { + activeTerminalId = sessionId; + renderPanel(); + } + }); + }); +} + +function renderFileList(): void { + const container = document.querySelector('#wsFileList') as HTMLElement; + if (!container) return; + + // 更新路径显示 + const pathDisplay = document.querySelector('#wsFilePath') as HTMLElement; + if (pathDisplay) { + const relPath = currentFileDir.replace(workspaceDir, '~'); + pathDisplay.textContent = relPath || '~'; + pathDisplay.title = currentFileDir; + } + + if (fileTree.length === 0) { + container.innerHTML = '
空目录
'; + return; + } + + container.innerHTML = fileTree.map(entry => { + const icon = entry.type === 'directory' ? '📁' : getFileIcon(entry.name); + const size = entry.size !== null ? formatSize(entry.size) : ''; + const fullPath = (currentFileDir + '/' + entry.name).replace(/\/+/g, '/'); + + return `
+ ${icon} + ${escapeHtml(entry.name)} + ${size} +
`; + }).join(''); + + // 绑定点击事件 + container.querySelectorAll('.ws-file-item').forEach(item => { + item.addEventListener('click', () => { + const el = item as HTMLElement; + const type = el.dataset.type; + const name = el.dataset.name!; + const filePath = el.dataset.path!; + + if (type === 'directory') { + navigateToDir(name); + } else { + openFile(filePath, name); + } + }); + + item.addEventListener('dblclick', () => { + const el = item as HTMLElement; + if (el.dataset.type === 'directory') { + navigateToDir(el.dataset.name!); + } + }); + }); + + // 渲染预览 + renderPreview(); +} + +function renderPreview(): void { + const previewContainer = document.querySelector('#wsPreview') as HTMLElement; + if (!previewContainer) return; + + if (!previewFile) { + previewContainer.style.display = 'none'; + return; + } + + previewContainer.style.display = ''; + const previewTitle = document.querySelector('#wsPreviewTitle') as HTMLElement; + const previewContent = document.querySelector('#wsPreviewContent') as HTMLElement; + + if (previewTitle) previewTitle.textContent = previewFile.name; + + if (previewContent) { + const escaped = escapeHtml(previewFile.content); + const lines = escaped.split('\n'); + const numbered = lines.map((line, i) => + `${i + 1}${line}` + ).join('\n'); + previewContent.innerHTML = `
${numbered}
`; + } +} + +function updateStopBtnState(): void { + const stopBtn = document.querySelector('#wsStopBtn') as HTMLButtonElement; + if (!stopBtn) return; + const session = getActiveSession(); + const running = session?.running || false; + stopBtn.disabled = !running; + stopBtn.classList.toggle('active', running); +} + +// ── 工具函数 ── +function getFileIcon(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const iconMap: Record = { + ts: '🔷', js: '🟡', json: '📋', md: '📝', txt: '📄', + py: '🐍', rs: '🦀', go: '🔵', java: '☕', c: '📄', cpp: '📄', + html: '🌐', css: '🎨', svg: '🖼️', png: '🖼️', jpg: '🖼️', gif: '🖼️', + sh: '⚡', bat: '⚡', yaml: '⚙️', yml: '⚙️', toml: '⚙️', + xml: '📋', csv: '📊', zip: '📦', tar: '📦', gz: '📦', + }; + return iconMap[ext] || '📄'; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +// ── 外部调用接口 ── + +/** 从聊天区域调用:在工作空间执行命令 */ +export function execInWorkspace(command: string): void { + // 确保面板可见 + if (!panelVisible) togglePanel(); + + // 切换到终端 tab + activeTab = 'terminal'; + + // 使用当前活跃终端或创建新的 + const session = getActiveSession(); + if (session?.running) { + // 当前终端有进程在跑,新建一个 + const num = terminalSessions.length + 1; + createTerminalSession(`终端 ${num}`); + } + + renderPanel(); + executeCommand(command); +} + +/** 获取工作空间目录 */ +export function getWorkspaceDirPath(): string { + return workspaceDir; +} + +/** 设置工作空间目录 */ +export async function setWorkspaceDirPath(dir: string): Promise { + const bridge = window.metonaDesktop; + if (!bridge?.isDesktop) return false; + + const result = await bridge.workspace.setDir(dir); + if (result.success && result.dir) { + workspaceDir = result.dir; + currentFileDir = workspaceDir; + logInfo('工作空间目录已更新', workspaceDir); + return true; + } + return false; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 8b8f639..fd5f60a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -65,6 +65,12 @@ + + + + + + + +
+
+
+
+ + + + + +
+
+ + + + + +
+ +
+ + +
+

工作空间用于存放 AI 执行命令时的工作文件。命令行默认在此目录执行。

+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index f225f5b..7a627fd 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -25,6 +25,7 @@ import { initMemoryManager } from './services/memory-manager.js'; import { setToolEnabled } from './services/tool-registry.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initMemoryPanel } from './components/memory-panel.js'; +import { initWorkspacePanel } from './components/workspace-panel.js'; import { initLogPanel } from './services/log-service.js'; import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js'; import type { ChatSession } from './types.js'; @@ -202,6 +203,7 @@ async function init(): Promise { initHelpModal(); initToolConfirmModal(); initMemoryPanel(); + initWorkspacePanel(); setupDesktopIntegration(); bindGlobalEvents(); diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 63c05c4..317b9e3 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -2659,3 +2659,419 @@ html, body { color: var(--accent-cyan, #00f5d4); opacity: 0.7; } + +/* ═══════════════ 工作空间面板 ═══════════════ */ + +.workspace-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 420px; + background: var(--bg-solid, #202020); + border-left: 1px solid var(--border-default, rgba(255,255,255,0.08)); + display: flex; + flex-direction: column; + z-index: 10; + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; +} + +.ws-resizer { + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 11; +} + +.ws-resizer:hover { + background: var(--accent, #60CDFF); + opacity: 0.3; +} + +.ws-header { + display: flex; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border-default, rgba(255,255,255,0.08)); + background: var(--bg-layer-alt, #2a2a2a); + min-height: 44px; + gap: 8px; +} + +.ws-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #fff); + white-space: nowrap; + font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; +} + +.ws-tabs { + display: flex; + gap: 2px; + margin-left: auto; +} + +.ws-tab { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary, #999); + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; + transition: all 0.15s; +} + +.ws-tab:hover { + background: var(--bg-card-hover, #323232); + color: var(--text-primary, #fff); +} + +.ws-tab.active { + background: var(--bg-layer, #383838); + color: var(--accent, #60CDFF); + border-color: var(--border-strong, rgba(255,255,255,0.12)); +} + +.ws-toggle-btn { + margin-left: 4px; + padding: 4px; +} + +/* ── 终端内容区 ── */ + +.ws-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ws-term-tab-bar { + display: flex; + gap: 1px; + padding: 4px 8px 0; + background: var(--bg-layer-alt, #2a2a2a); + border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); + overflow-x: auto; + min-height: 30px; +} + +.ws-term-tab-bar .ws-tab { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px 4px 0 0; + border-bottom: none; + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.ws-term-tab-bar .ws-tab.active { + background: var(--bg-solid, #202020); + border-color: var(--border-default, rgba(255,255,255,0.08)); + border-bottom: 1px solid var(--bg-solid, #202020); +} + +.ws-tab-title { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-tab-close { + background: none; + border: none; + color: var(--text-tertiary, #666); + cursor: pointer; + font-size: 10px; + padding: 0 2px; + line-height: 1; + border-radius: 2px; +} + +.ws-tab-close:hover { + background: rgba(255,255,255,0.1); + color: var(--text-primary, #fff); +} + +.ws-term-output { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + font-size: 12px; + line-height: 1.5; + color: var(--text-primary, #fff); + background: var(--bg-solid, #202020); + word-break: break-all; + white-space: pre-wrap; +} + +.ws-term-placeholder { + color: var(--text-tertiary, #666); + text-align: center; + padding: 40px 20px; + font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; +} + +.ws-term-line { + min-height: 1.5em; +} + +.ws-term-stdout { + color: #ccc; +} + +.ws-term-stderr { + color: #ff6b68; +} + +.ws-term-system { + color: var(--accent, #60CDFF); + font-weight: 500; + opacity: 0.9; +} + +.ws-term-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-top: 1px solid var(--border-default, rgba(255,255,255,0.08)); + background: var(--bg-layer-alt, #2a2a2a); +} + +.ws-term-input { + flex: 1; + background: var(--bg-layer, #383838); + border: 1px solid var(--border-default, rgba(255,255,255,0.08)); + border-radius: 4px; + color: var(--text-primary, #fff); + padding: 6px 10px; + font-size: 12px; + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; + outline: none; + transition: border-color 0.15s; +} + +.ws-term-input:focus { + border-color: var(--accent, #60CDFF); +} + +.ws-term-input::placeholder { + color: var(--text-tertiary, #666); +} + +.ws-toolbar-btn { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary, #999); + cursor: pointer; + padding: 5px 7px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.ws-toolbar-btn svg { + width: 14px; + height: 14px; +} + +.ws-toolbar-btn:hover:not(:disabled) { + background: var(--bg-card-hover, #323232); + color: var(--text-primary, #fff); +} + +.ws-toolbar-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.ws-stop-btn.active { + color: #ff6b68; +} + +.ws-stop-btn.active:hover { + background: rgba(255, 107, 104, 0.15); +} + +/* ── 文件浏览器 ── */ + +.ws-file-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid var(--border-default, rgba(255,255,255,0.08)); + background: var(--bg-layer-alt, #2a2a2a); +} + +.ws-file-path { + flex: 1; + font-size: 11px; + color: var(--text-secondary, #999); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-file-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.ws-file-empty { + color: var(--text-tertiary, #666); + text-align: center; + padding: 40px 20px; + font-size: 13px; + font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif; +} + +.ws-file-item { + display: flex; + align-items: center; + padding: 5px 12px; + cursor: pointer; + transition: background 0.1s; + gap: 8px; +} + +.ws-file-item:hover { + background: var(--bg-card-hover, #323232); +} + +.ws-file-icon { + font-size: 14px; + flex-shrink: 0; + width: 20px; + text-align: center; +} + +.ws-file-name { + flex: 1; + font-size: 12px; + color: var(--text-primary, #fff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-file-size { + font-size: 11px; + color: var(--text-tertiary, #666); + flex-shrink: 0; +} + +/* ── 文件预览 ── */ + +.ws-preview { + border-top: 1px solid var(--border-default, rgba(255,255,255,0.08)); + display: flex; + flex-direction: column; + max-height: 50%; +} + +.ws-preview-header { + display: flex; + align-items: center; + padding: 6px 12px; + background: var(--bg-layer-alt, #2a2a2a); + gap: 8px; +} + +.ws-preview-title { + flex: 1; + font-size: 12px; + color: var(--text-primary, #fff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ws-preview-content { + flex: 1; + overflow-y: auto; + padding: 0; + background: var(--bg-solid, #202020); +} + +.ws-preview-pre { + margin: 0; + padding: 8px 0; + font-size: 11px; + line-height: 1.5; + font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; + color: var(--text-primary, #fff); +} + +.ws-preview-line { + display: block; + padding: 0 12px; +} + +.ws-preview-line:hover { + background: rgba(255,255,255,0.03); +} + +.ws-line-num { + display: inline-block; + width: 40px; + text-align: right; + padding-right: 12px; + color: var(--text-tertiary, #666); + user-select: none; + border-right: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); + margin-right: 8px; +} + +/* ── 滚动条 ── */ + +.ws-term-output::-webkit-scrollbar, +.ws-file-list::-webkit-scrollbar, +.ws-preview-content::-webkit-scrollbar, +.ws-term-tab-bar::-webkit-scrollbar { + width: 6px; +} + +.ws-term-output::-webkit-scrollbar-track, +.ws-file-list::-webkit-scrollbar-track, +.ws-preview-content::-webkit-scrollbar-track { + background: transparent; +} + +.ws-term-output::-webkit-scrollbar-thumb, +.ws-file-list::-webkit-scrollbar-thumb, +.ws-preview-content::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.1); + border-radius: 3px; +} + +.ws-term-output::-webkit-scrollbar-thumb:hover, +.ws-file-list::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.2); +} + +/* ── Header 按钮高亮 ── */ + +#btnWorkspace.active { + color: var(--accent, #60CDFF); + background: var(--accent-subtle, rgba(96,205,255,0.06)); +} + +/* ── 主内容区自适应 ── */ + +.main-wrap.with-workspace { + margin-right: 420px; +} diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts index 7416700..1fd7ab5 100644 --- a/src/renderer/types.d.ts +++ b/src/renderer/types.d.ts @@ -189,6 +189,19 @@ export interface DocInfo { // ── 桌面 Bridge 类型 ── +export interface WorkspaceFileEntry { + name: string; + type: 'file' | 'directory'; + size: number | null; + modified: string; +} + +export interface WorkspaceDirResult { + success: boolean; + entries?: WorkspaceFileEntry[]; + error?: string; +} + export interface MetonaDesktopAPI { isDesktop: boolean; info: () => Promise; @@ -216,6 +229,16 @@ export interface MetonaDesktopAPI { onTrayAction: (callback: (action: string) => void) => void; onAppQuit: (callback: () => void) => void; removeAllListeners: (channel: string) => void; + workspace: { + getDir: () => Promise<{ dir: string; defaultDir: string }>; + setDir: (dir: string) => Promise<{ success: boolean; dir?: string; error?: string }>; + listDir: (dirPath?: string) => Promise; + readFile: (filePath: string) => Promise<{ success: boolean; content?: string; path?: string; error?: string; truncated?: boolean; lines?: number }>; + exec: (params: { id: string; command: string; cwd?: string }) => void; + kill: (id: string) => void; + onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void; + onExit: (callback: (data: { id: string; code: number | null }) => void) => void; + }; } export interface AppInfo {