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;
}
+47
View File
@@ -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 {
+714
View File
@@ -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<string, string> = {
'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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 += '</span>';
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 += `<span style="${styles.join(';')}">`;
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 {
// 清除当前行(通过移除到最后一个 <span> 之后的内容太复杂,简化处理)
const spanStart = result.lastIndexOf('><');
if (spanStart !== -1) {
// 不处理,保留
}
}
i++;
continue;
}
result += text[i];
i++;
}
if (openSpan) result += '</span>';
return result;
}
// ── 初始化 ──
export async function initWorkspacePanel(): Promise<void> {
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<void> {
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<void> {
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 = '<div class="ws-term-placeholder">无终端会话</div>';
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
? `<button class="ws-tab-close" data-close="${s.id}" title="关闭">✕</button>`
: '';
return `<div class="ws-tab ${active ? 'active' : ''}" data-session="${s.id}">
<span class="ws-tab-title">${escapeHtml(s.title)}${running}</span>
${closeBtn}
</div>`;
}).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 = '<div class="ws-file-empty">空目录</div>';
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 `<div class="ws-file-item" data-name="${escapeHtml(entry.name)}" data-type="${entry.type}" data-path="${escapeHtml(fullPath)}">
<span class="ws-file-icon">${icon}</span>
<span class="ws-file-name">${escapeHtml(entry.name)}</span>
<span class="ws-file-size">${size}</span>
</div>`;
}).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) =>
`<span class="ws-preview-line"><span class="ws-line-num">${i + 1}</span>${line}</span>`
).join('\n');
previewContent.innerHTML = `<pre class="ws-preview-pre">${numbered}</pre>`;
}
}
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<string, string> = {
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<boolean> {
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;
}
+92
View File
@@ -65,6 +65,12 @@
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</button>
<button class="icon-btn" id="btnWorkspace" title="工作空间">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
</button>
<button class="icon-btn" id="btnSettings" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
@@ -167,6 +173,83 @@
</div>
</div><!-- /.main-wrap -->
<!-- ═══════════════ 工作空间面板(右侧)═══════════════ -->
<aside class="workspace-panel" id="workspacePanel" style="display:none;">
<div class="ws-resizer" id="wsResizer"></div>
<div class="ws-header">
<span class="ws-title">🖥️ 工作空间</span>
<div class="ws-tabs">
<button class="ws-tab active" id="wsTabTerminal">💻 命令行</button>
<button class="ws-tab" id="wsTabFiles">📁 文件</button>
</div>
<button class="icon-btn ws-toggle-btn" id="btnWorkspaceClose" title="收起面板">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
<!-- 终端 Tab -->
<div class="ws-content" id="wsTerminalContent">
<div class="ws-term-tab-bar" id="wsTermTabBar"></div>
<div class="ws-term-output" id="wsTermOutput"></div>
<div class="ws-term-toolbar">
<input class="ws-term-input" id="wsTermInput" type="text" placeholder="输入命令,Enter 执行..." spellcheck="false">
<button class="ws-toolbar-btn" id="wsExecBtn" title="执行">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="ws-toolbar-btn ws-stop-btn" id="wsStopBtn" title="停止" disabled>
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsClearBtn" title="清空">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsNewTabBtn" title="新建终端">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</div>
<!-- 文件 Tab -->
<div class="ws-content" id="wsFilesContent" style="display:none;">
<div class="ws-file-toolbar">
<button class="ws-toolbar-btn" id="wsFileUpBtn" title="上级目录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="ws-file-path" id="wsFilePath">~</span>
<button class="ws-toolbar-btn" id="wsFileRefreshBtn" title="刷新">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
</div>
<div class="ws-file-list" id="wsFileList"></div>
<div class="ws-preview" id="wsPreview" style="display:none;">
<div class="ws-preview-header">
<span class="ws-preview-title" id="wsPreviewTitle"></span>
<button class="icon-btn" id="wsPreviewClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="ws-preview-content" id="wsPreviewContent"></div>
</div>
</div>
</aside>
<!-- ═══════════════ 设置模态框 ═══════════════ -->
<div class="modal-overlay" id="settingsModal" style="display:none;">
<div class="modal">
@@ -244,6 +327,14 @@
<p class="text-muted" style="font-size:11px;margin-top:8px;">⚠️ 高风险操作(写入/删除/命令执行)会弹出确认对话框。系统关键路径(/etc, /sys, ~/.ssh 等)已自动屏蔽。</p>
</div>
</div>
<div class="setting-group">
<label class="setting-label">🖥️ 工作空间目录</label>
<div style="display:flex;gap:8px;align-items:center;">
<input class="setting-input" id="inputWorkspaceDir" type="text" placeholder="默认: 应用数据目录/workspace" style="margin-bottom:0;flex:1;" readonly>
<button class="btn btn-sm btn-outline" id="btnBrowseWorkspace">📂 浏览</button>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">工作空间用于存放 AI 执行命令时的工作文件。命令行默认在此目录执行。</p>
</div>
<div class="setting-group">
<label class="setting-label">
🧠 Agent 记忆系统
@@ -318,6 +409,7 @@
<div class="help-section"><h4>📚 知识库 (RAG)</h4><ul><li>点击顶部 📚 按钮打开知识库管理</li><li>创建集合 → 选择嵌入模型(推荐 <code>nomic-embed-text</code> 等)</li><li>上传文档,自动分块并生成向量索引</li><li>开启 RAG 检索后,聊天时自动检索相关文档注入回答</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>AI 会从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则/事件),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>后自动触发记忆提取</li><li>新对话时自动检索相关记忆并注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除记忆条目</li><li>记忆持久化到 IndexedDB,清除浏览器数据会丢失</li></ul></div>
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到 IndexedDB</li><li>点击 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 <code>.metona</code> 加密备份文件</li></ul></div>
<div class="help-section"><h4>🖥️ 工作空间</h4><ul><li>点击顶部 🖥️ 按钮打开右侧工作空间面板</li><li><strong>命令行 Tab</strong> — 终端界面,实时显示命令输出,支持长时间运行(如 <code>ollama pull</code></li><li><strong>文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容</li><li>支持多终端 Tab,每个 Tab 独立进程,可随时停止</li><li>命令在工作空间目录执行,可在设置中修改路径</li><li>AI 建议执行命令时,可在工作空间中运行并实时查看进度</li></ul></div>
<div class="help-section"><h4>⚡ 快捷键</h4><table class="help-shortcuts"><tr><td><kbd>Enter</kbd></td><td>发送消息</td></tr><tr><td><kbd>Shift + Enter</kbd></td><td>换行</td></tr><tr><td><kbd>Ctrl + K</kbd></td><td>知识库管理</td></tr><tr><td><kbd>Ctrl + M</kbd></td><td>记忆面板</td></tr><tr><td><kbd>Esc</kbd></td><td>关闭弹窗</td></tr></table></div>
</div>
</div>
+2
View File
@@ -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<void> {
initHelpModal();
initToolConfirmModal();
initMemoryPanel();
initWorkspacePanel();
setupDesktopIntegration();
bindGlobalEvents();
+416
View File
@@ -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;
}
+23
View File
@@ -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<AppInfo>;
@@ -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<WorkspaceDirResult>;
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 {