Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3589e5c927 | ||
|
|
2db388c2f2 | ||
|
|
5a032b13ec | ||
|
|
4707817053 | ||
|
|
ebf454a250 | ||
|
|
35452ef235 | ||
|
|
5421af1bb0 | ||
|
|
2191ef79d3 | ||
|
|
b351057f5f |
@@ -18,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
|
||||
"main": "src/main/index.cjs",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "electron test/test-runner.cjs",
|
||||
"rebuild": "electron-rebuild -f -w node-pty",
|
||||
"package:deb": "electron-builder --linux deb",
|
||||
"package:all": "electron-builder --linux deb AppImage",
|
||||
|
||||
+145
-19
@@ -1,5 +1,107 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* 动态提取 Git Commit 日志并生成规范化 Release Notes
|
||||
*/
|
||||
function generateReleaseNotes({ tag, files, distDir, repoUrl }) {
|
||||
let prevTag = '';
|
||||
try {
|
||||
const tags = execSync('git tag --sort=-creatordate', { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const currIdx = tags.indexOf(tag);
|
||||
if (currIdx !== -1 && currIdx < tags.length - 1) {
|
||||
prevTag = tags[currIdx + 1];
|
||||
} else if (tags.length > 0 && currIdx === -1) {
|
||||
prevTag = tags[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Release] Unable to determine previous git tag:', e.message);
|
||||
}
|
||||
|
||||
let commits = [];
|
||||
try {
|
||||
const range = prevTag ? `${prevTag}..HEAD` : 'HEAD';
|
||||
const logOutput = execSync(`git log ${range} --pretty=format:"%h%x09%s"`, { encoding: 'utf8' });
|
||||
commits = logOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [hash, ...rest] = line.split('\t');
|
||||
return { hash, subject: rest.join('\t') };
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Release] Unable to retrieve git log:', e.message);
|
||||
}
|
||||
|
||||
// 按照 Conventional Commits 规范分类
|
||||
const categories = {
|
||||
features: { title: '🚀 新功能 (Features)', items: [] },
|
||||
ui: { title: '🎨 界面与主题 (UI & Themes)', items: [] },
|
||||
fixes: { title: '🐛 问题修复 (Bug Fixes)', items: [] },
|
||||
docs: { title: '📖 文档与说明 (Documentation)', items: [] },
|
||||
ci: { title: '🔧 构建与 CI (Build & CI)', items: [] },
|
||||
other: { title: '📝 其他变动 (Other Changes)', items: [] },
|
||||
};
|
||||
|
||||
for (const c of commits) {
|
||||
const s = c.subject;
|
||||
const item = repoUrl
|
||||
? `* [${c.hash}](${repoUrl}/commit/${c.hash}) - ${s}`
|
||||
: `* ${c.hash} - ${s}`;
|
||||
|
||||
if (/^(style|theme)(\(.*\))?:/i.test(s) || /theme|主题|配色/i.test(s)) {
|
||||
categories.ui.items.push(item);
|
||||
} else if (/^feat(\(.*\))?:/i.test(s)) {
|
||||
categories.features.items.push(item);
|
||||
} else if (/^fix(\(.*\))?:/i.test(s)) {
|
||||
categories.fixes.items.push(item);
|
||||
} else if (/^docs?(\(.*\))?:/i.test(s)) {
|
||||
categories.docs.items.push(item);
|
||||
} else if (/^(ci|chore|build|refactor|perf)(\(.*\))?:/i.test(s)) {
|
||||
categories.ci.items.push(item);
|
||||
} else {
|
||||
categories.other.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let changelogSection = '';
|
||||
let hasItems = false;
|
||||
for (const key of Object.keys(categories)) {
|
||||
const cat = categories[key];
|
||||
if (cat.items.length > 0) {
|
||||
hasItems = true;
|
||||
changelogSection += `#### ${cat.title}\n\n${cat.items.join('\n')}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasItems) {
|
||||
changelogSection = '暂无详细更新记录。\n\n';
|
||||
}
|
||||
|
||||
// 产物文件大小与 SHA-256 校验和表格
|
||||
let checksumSection = '';
|
||||
if (files && files.length > 0 && distDir) {
|
||||
checksumSection = '### 📦 安装包校验信息 (Checksums)\n\n| 文件名 | 大小 | SHA-256 校验和 |\n| :--- | :--- | :--- |\n';
|
||||
for (const file of files) {
|
||||
const filePath = path.join(distDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stat = fs.statSync(filePath);
|
||||
const sizeStr = (stat.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
const hash = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
checksumSection += `| \`${file}\` | ${sizeStr} | \`${hash}\` |\n`;
|
||||
}
|
||||
}
|
||||
checksumSection += '\n';
|
||||
}
|
||||
|
||||
return `## ⚡ hatty ${tag} 发布说明\n\n### 🌟 更新日志 (What's Changed)\n\n${prevTag && repoUrl ? `> 对比基准版本:[${prevTag}](${repoUrl}/src/tag/${prevTag}) → **${tag}**\n\n` : ''}${changelogSection}### 💻 安装方法 (Installation)\n\n下载下方附件中的 \`.deb\` 安装包,并在终端执行安装:\n\n\`\`\`bash\n# 安装 deb 安装包\nsudo dpkg -i hatty_*.deb\n\n# 如遇到缺少系统依赖,可执行自动修复:\nsudo apt-get install -f -y\n\`\`\`\n\n${checksumSection}---\n*由 Gitea Actions 自动化流水线构建并发布。*`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const serverUrl = process.env.GITHUB_SERVER_URL || process.env.GITEA_SERVER_URL || 'https://git.zzzjy.cloud';
|
||||
@@ -16,11 +118,31 @@ async function main() {
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const apiBase = `${serverUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repoName}`;
|
||||
const normalizedServer = serverUrl.replace(/\/+$/, '');
|
||||
const repoUrl = `${normalizedServer}/${owner}/${repoName}`;
|
||||
const apiBase = `${normalizedServer}/api/v1/repos/${owner}/${repoName}`;
|
||||
|
||||
console.log(`Connecting to Gitea at ${serverUrl} for repository ${repo}...`);
|
||||
|
||||
// 1. 检查或创建 Release
|
||||
// 1. 查找 dist/ 下的 .deb / .AppImage 安装包并生成 Release Notes
|
||||
const distDir = path.resolve('dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
console.error('dist directory does not exist.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
|
||||
if (files.length === 0) {
|
||||
console.error('No .deb or .AppImage files found in dist directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateReleaseNotes({ tag, files, distDir, repoUrl });
|
||||
console.log('--- Generated Release Notes ---');
|
||||
console.log(releaseNotes);
|
||||
console.log('-------------------------------');
|
||||
|
||||
// 2. 检查或创建 / 更新 Release
|
||||
let releaseId = null;
|
||||
const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, {
|
||||
headers: {
|
||||
@@ -32,7 +154,24 @@ async function main() {
|
||||
if (tagCheckRes.ok) {
|
||||
const existing = await tagCheckRes.json();
|
||||
releaseId = existing.id;
|
||||
console.log(`Found existing release for tag ${tag} (id: ${releaseId})`);
|
||||
console.log(`Found existing release for tag ${tag} (id: ${releaseId}), updating release notes...`);
|
||||
const updateRes = await fetch(`${apiBase}/releases/${releaseId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `hatty ${tag}`,
|
||||
body: releaseNotes,
|
||||
}),
|
||||
});
|
||||
if (!updateRes.ok) {
|
||||
console.warn(`Warning: Failed to update release body: ${updateRes.status}`);
|
||||
} else {
|
||||
console.log(`Release notes updated successfully for id ${releaseId}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Creating new release for tag ${tag}...`);
|
||||
const createRes = await fetch(`${apiBase}/releases`, {
|
||||
@@ -45,7 +184,7 @@ async function main() {
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name: `hatty ${tag}`,
|
||||
body: `### ⚡ hatty ${tag} 现代化 Linux 终端新版本发布\n\n- **架构**:Electron + xterm.js (WebGL 硬件加速) + Linux PTY\n- **特性**:无边框磨砂毛玻璃、5 套现代暗黑主题、多标签页、灵活多窗格分屏\n- **安装说明**:下载下方附件 \`.deb\` 文件,在终端运行 \`sudo dpkg -i hatty_*.deb\` 即可完成安装。`,
|
||||
body: releaseNotes,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}),
|
||||
@@ -62,20 +201,7 @@ async function main() {
|
||||
console.log(`Release created successfully with id ${releaseId}`);
|
||||
}
|
||||
|
||||
// 2. 查找 dist/ 下的 .deb 安装包
|
||||
const distDir = path.resolve('dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
console.error('dist directory does not exist.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
|
||||
if (files.length === 0) {
|
||||
console.error('No .deb or .AppImage files found in dist directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 检查已存在的附件,若同名则先删除以支持重试
|
||||
// 3. 检查已存在的附件,若同名则先删除以支持重试
|
||||
try {
|
||||
const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, {
|
||||
headers: {
|
||||
@@ -99,7 +225,7 @@ async function main() {
|
||||
console.warn('Checking existing assets notice:', e.message);
|
||||
}
|
||||
|
||||
// 3. 上传安装包到 Release 附件
|
||||
// 4. 上传安装包到 Release 附件
|
||||
for (const fileName of files) {
|
||||
const filePath = path.join(distDir, fileName);
|
||||
const fileStat = fs.statSync(filePath);
|
||||
|
||||
+142
-1
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PtyManager } = require('./pty-manager.cjs');
|
||||
@@ -101,6 +101,147 @@ ipcMain.on('pty:kill', (event, { id }) => {
|
||||
if (ptyManager) ptyManager.kill({ id });
|
||||
});
|
||||
|
||||
// 剪贴板 IPC
|
||||
ipcMain.handle('clipboard:read', () => {
|
||||
return clipboard.readText();
|
||||
});
|
||||
|
||||
ipcMain.on('clipboard:write', (event, text) => {
|
||||
clipboard.writeText(text || '');
|
||||
});
|
||||
|
||||
// 获取 PTY 实时工作目录
|
||||
ipcMain.handle('pty:get-cwd', (event, { id }) => {
|
||||
return ptyManager ? ptyManager.getCwd({ id }) : null;
|
||||
});
|
||||
|
||||
// 检查可执行命令路径
|
||||
const fs = require('fs');
|
||||
function findExecutable(command) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const exts = isWindows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
||||
const dirs = (process.env.PATH || '').split(path.delimiter);
|
||||
const extraDirs = [
|
||||
path.join(os.homedir(), '.local/bin'),
|
||||
path.join(os.homedir(), '.npm-global/bin'),
|
||||
path.join(os.homedir(), '.cargo/bin'),
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
];
|
||||
const allDirs = [...new Set([...dirs, ...extraDirs])];
|
||||
for (const dir of allDirs) {
|
||||
for (const ext of exts) {
|
||||
const fullPath = path.join(dir, command + ext);
|
||||
try {
|
||||
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
||||
return fullPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查已安装的 Agent (agy / codex / claude)
|
||||
ipcMain.handle('app:get-installed-agents', () => {
|
||||
const agentDefs = [
|
||||
{ id: 'agy', name: 'agy', cmd: 'agy', desc: 'Antigravity CLI Agent' },
|
||||
{ id: 'codex', name: 'codex', cmd: 'codex', desc: 'OpenAI CodeX CLI' },
|
||||
{ id: 'claude', name: 'claude', cmd: 'claude', desc: 'Claude Code Agent' },
|
||||
];
|
||||
return agentDefs.map((agent) => {
|
||||
const execPath = findExecutable(agent.cmd);
|
||||
return {
|
||||
...agent,
|
||||
path: execPath,
|
||||
installed: !!execPath,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 文件浏览 IPC:读取目录中的文件和文件夹
|
||||
ipcMain.handle('fs:read-dir', async (event, { dirPath, showHidden = false } = {}) => {
|
||||
try {
|
||||
let targetPath = dirPath || '~';
|
||||
if (targetPath === '~' || targetPath.startsWith('~/')) {
|
||||
targetPath = path.join(os.homedir(), targetPath.slice(targetPath === '~' ? 1 : 2));
|
||||
}
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return { success: false, error: '目录不存在: ' + targetPath, dirPath: targetPath };
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { success: false, error: '路径不是有效目录: ' + targetPath, dirPath: targetPath };
|
||||
}
|
||||
|
||||
const dirents = await fs.promises.readdir(targetPath, { withFileTypes: true });
|
||||
const entries = [];
|
||||
|
||||
for (const d of dirents) {
|
||||
const isHidden = d.name.startsWith('.');
|
||||
if (!showHidden && isHidden) continue;
|
||||
|
||||
let isDirectory = d.isDirectory();
|
||||
let isFile = d.isFile();
|
||||
let isSymbolicLink = d.isSymbolicLink();
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
|
||||
const fullItemPath = path.join(targetPath, d.name);
|
||||
if (isSymbolicLink) {
|
||||
try {
|
||||
const realStat = await fs.promises.stat(fullItemPath);
|
||||
isDirectory = realStat.isDirectory();
|
||||
isFile = realStat.isFile();
|
||||
size = realStat.size;
|
||||
mtime = realStat.mtimeMs;
|
||||
} catch (_) {}
|
||||
} else {
|
||||
try {
|
||||
if (isFile) {
|
||||
const fStat = await fs.promises.stat(fullItemPath);
|
||||
size = fStat.size;
|
||||
mtime = fStat.mtimeMs;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: d.name,
|
||||
path: fullItemPath,
|
||||
isDirectory,
|
||||
isFile,
|
||||
isSymbolicLink,
|
||||
isHidden,
|
||||
size,
|
||||
mtime,
|
||||
});
|
||||
}
|
||||
|
||||
// 排序:文件夹优先,同类按文件名升序排列
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true });
|
||||
});
|
||||
|
||||
const parentPath = path.dirname(targetPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dirPath: targetPath,
|
||||
parentPath: parentPath !== targetPath ? parentPath : null,
|
||||
entries,
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, dirPath };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:get-system-info', () => {
|
||||
return {
|
||||
platform: os.platform(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const pty = require('node-pty');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
class PtyManager {
|
||||
constructor(mainWindow) {
|
||||
@@ -8,17 +9,38 @@ class PtyManager {
|
||||
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
||||
}
|
||||
|
||||
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd() }) {
|
||||
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd(), shell, args = [] }) {
|
||||
if (this.ptys.has(id)) {
|
||||
this.kill({ id });
|
||||
}
|
||||
|
||||
try {
|
||||
const ptyProcess = pty.spawn(this.defaultShell, [], {
|
||||
let resolvedCwd = cwd || process.env.HOME || process.cwd();
|
||||
if (typeof resolvedCwd === 'string') {
|
||||
if (resolvedCwd === '~') {
|
||||
resolvedCwd = os.homedir();
|
||||
} else if (resolvedCwd.startsWith('~/') || resolvedCwd.startsWith('~\\')) {
|
||||
resolvedCwd = path.join(os.homedir(), resolvedCwd.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
try {
|
||||
if (!resolvedCwd || !fs.existsSync(resolvedCwd) || !fs.statSync(resolvedCwd).isDirectory()) {
|
||||
resolvedCwd = os.homedir() || process.cwd();
|
||||
}
|
||||
} catch (e) {
|
||||
resolvedCwd = os.homedir() || process.cwd();
|
||||
}
|
||||
|
||||
const targetShell = shell || this.defaultShell;
|
||||
const targetArgs = Array.isArray(args) ? args : [];
|
||||
|
||||
const ptyProcess = pty.spawn(targetShell, targetArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
cwd: resolvedCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -40,7 +62,7 @@ class PtyManager {
|
||||
});
|
||||
|
||||
this.ptys.set(id, ptyProcess);
|
||||
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell };
|
||||
return { success: true, pid: ptyProcess.pid, shell: targetShell };
|
||||
} catch (err) {
|
||||
console.error(`Failed to spawn pty [${id}]:`, err);
|
||||
return { success: false, error: err.message };
|
||||
@@ -77,6 +99,18 @@ class PtyManager {
|
||||
}
|
||||
}
|
||||
|
||||
getCwd({ id }) {
|
||||
const ptyProcess = this.ptys.get(id);
|
||||
if (!ptyProcess || !ptyProcess.pid) return null;
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const fs = require('fs');
|
||||
return fs.readlinkSync(`/proc/${ptyProcess.pid}/cwd`);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
killAll() {
|
||||
for (const [id, ptyProcess] of this.ptys.entries()) {
|
||||
try {
|
||||
|
||||
@@ -32,4 +32,15 @@ contextBridge.exposeInMainWorld('terminalAPI', {
|
||||
|
||||
// 系统信息
|
||||
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
|
||||
|
||||
// 剪贴板管理
|
||||
readClipboard: () => ipcRenderer.invoke('clipboard:read'),
|
||||
writeClipboard: (text) => ipcRenderer.send('clipboard:write', text),
|
||||
|
||||
// PTY 与 Agent 查询
|
||||
getPtyCwd: (id) => ipcRenderer.invoke('pty:get-cwd', { id }),
|
||||
getInstalledAgents: () => ipcRenderer.invoke('app:get-installed-agents'),
|
||||
|
||||
// 文件浏览
|
||||
readDir: (options) => ipcRenderer.invoke('fs:read-dir', options),
|
||||
});
|
||||
|
||||
+104
-11
@@ -28,26 +28,47 @@ body {
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 顶部无边框拖拽标题栏 */
|
||||
/* 顶部无边框拖拽标题栏 (与标签栏合二为一) */
|
||||
.titlebar {
|
||||
height: var(--titlebar-height);
|
||||
background: var(--bg-titlebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag; /* 允许窗口拖拽 */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
padding: 0 8px;
|
||||
padding: 0;
|
||||
padding-right: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
width: var(--sidebar-width, 250px);
|
||||
min-width: var(--sidebar-width, 250px);
|
||||
max-width: var(--sidebar-width, 250px);
|
||||
border-right: 1px solid var(--border-color);
|
||||
-webkit-app-region: no-drag;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: drag; /* 允许左侧空白和品牌区域拖拽 */
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#app.sidebar-collapsed .titlebar-left,
|
||||
.titlebar-left.sidebar-collapsed {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
border-right: none;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
@@ -57,10 +78,68 @@ body {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
padding: 0 8px;
|
||||
padding: 0 4px;
|
||||
letter-spacing: 0.5px;
|
||||
-webkit-app-region: drag;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 顶部标题栏工作区标识 */
|
||||
.titlebar-workspace-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
height: 22px;
|
||||
background: var(--bg-tab-inactive);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag !important;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar-workspace-tag:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
color: var(--text-main);
|
||||
border-color: var(--border-active);
|
||||
}
|
||||
|
||||
.ws-tag-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-tag-name {
|
||||
font-weight: 500;
|
||||
max-width: 95px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 标签页栏容器 (位于顶栏中央,工作区展开时与终端左侧像素对齐) */
|
||||
.tab-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: calc(100vw - 360px);
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.titlebar-drag-spacer {
|
||||
@@ -68,7 +147,7 @@ body {
|
||||
height: 100%;
|
||||
-webkit-app-region: drag; /* 中间大面积空白区域允许拖拽 */
|
||||
cursor: grab;
|
||||
min-width: 40px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.app-brand-icon {
|
||||
@@ -95,10 +174,17 @@ body {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
-webkit-app-region: no-drag !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon svg,
|
||||
.btn-icon svg * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
@@ -112,7 +198,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
@@ -127,10 +213,16 @@ body {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
.win-btn svg,
|
||||
.win-btn svg * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.win-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
@@ -139,9 +231,10 @@ body {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
/* 主内容区终端视口 */
|
||||
.main-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
.statusbar-item.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
.palette-item.selected,
|
||||
.palette-item:hover {
|
||||
background: var(--accent-color);
|
||||
color: #0f141c;
|
||||
color: var(--accent-text, #0f141c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -130,9 +130,10 @@
|
||||
|
||||
.palette-shortcut {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
font-family: var(--font-mono);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
|
||||
color: inherit;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -211,6 +212,11 @@
|
||||
border-color: var(--border-active);
|
||||
}
|
||||
|
||||
.setting-control select option {
|
||||
background: var(--bg-titlebar);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
/* 终端内搜索条 */
|
||||
.search-bar {
|
||||
position: absolute;
|
||||
@@ -255,3 +261,529 @@
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 终端右键上下文菜单 */
|
||||
.terminal-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-titlebar);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md, 6px);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5), 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
animation: fadeIn 0.1s ease;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.terminal-context-menu.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ctx-menu-header {
|
||||
padding: 6px 8px 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ctx-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.ctx-menu-item:hover:not(.disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.ctx-menu-item.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-menu-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctx-menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctx-menu-item:hover:not(.disabled) .ctx-menu-icon {
|
||||
color: var(--border-active, #3b82f6);
|
||||
}
|
||||
|
||||
.ctx-menu-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctx-menu-shortcut {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ctx-menu-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||
margin-left: 12px;
|
||||
letter-spacing: 0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctx-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 4px 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#ctx-close-term:hover:not(.disabled) {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
#ctx-close-term:hover:not(.disabled) .ctx-menu-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* 顶部/全局图标按钮禁用状态:变灰且不可点击 */
|
||||
.btn-icon:disabled,
|
||||
.btn-icon.disabled {
|
||||
opacity: 0.28 !important;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none !important;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* 专属 Agent 面板 (AgentPanel) */
|
||||
.agent-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-app);
|
||||
border-left: 1px solid var(--border-color);
|
||||
min-width: 240px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-panel-header {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
background: var(--bg-titlebar);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px 0 10px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-panel-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-panel-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-panel-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Agent 迷你小标签页: 紧凑小巧,主要展示对应 agent 图标与关闭按钮 */
|
||||
.agent-tab {
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-tab-inactive, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-tab:hover {
|
||||
background: var(--bg-tab-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border-hover, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.agent-tab.active {
|
||||
background: var(--bg-tab-active, rgba(59, 130, 246, 0.15));
|
||||
border-color: var(--border-active, #3b82f6);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
box-shadow: 0 0 8px var(--accent-glow, rgba(59, 130, 246, 0.25));
|
||||
}
|
||||
|
||||
.agent-tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tab-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Agent 品牌特化专属色彩与微光效果 */
|
||||
.agent-tab[data-agent-id="agy"] .agent-tab-icon,
|
||||
.ctx-menu-item#ctx-open-agy .ctx-menu-icon {
|
||||
color: #4285f4;
|
||||
}
|
||||
|
||||
.agent-tab[data-agent-id="codex"] .agent-tab-icon,
|
||||
.ctx-menu-item#ctx-open-codex .ctx-menu-icon {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
[data-theme="light"] .agent-tab[data-agent-id="codex"] .agent-tab-icon,
|
||||
[data-theme="light"] .ctx-menu-item#ctx-open-codex .ctx-menu-icon {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.agent-tab[data-agent-id="claude"] .agent-tab-icon,
|
||||
.ctx-menu-item#ctx-open-claude .ctx-menu-icon {
|
||||
color: #d97757;
|
||||
}
|
||||
|
||||
.agent-tab:not(.active) .agent-tab-icon {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.agent-tab:hover .agent-tab-icon,
|
||||
.agent-tab.active .agent-tab-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-tab.active[data-agent-id="agy"] {
|
||||
background: rgba(66, 133, 244, 0.16);
|
||||
border-color: #4285f4;
|
||||
box-shadow: 0 0 8px rgba(66, 133, 244, 0.35);
|
||||
}
|
||||
|
||||
.agent-tab.active[data-agent-id="codex"] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .agent-tab.active[data-agent-id="codex"] {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.agent-tab.active[data-agent-id="claude"] {
|
||||
background: rgba(217, 119, 87, 0.16);
|
||||
border-color: #d97757;
|
||||
box-shadow: 0 0 8px rgba(217, 119, 87, 0.3);
|
||||
}
|
||||
|
||||
/* 标签做小,只展示对应 agent 图标 (文字隐藏,hover显示title) */
|
||||
.agent-tab-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
margin-left: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.agent-tab-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.agent-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-panel-close-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.agent-panel-close-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.agent-panel-body {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: calc(100% - 28px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.agent-tab-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-tab-view.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 文件浏览面板 (FilePanel) */
|
||||
.file-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-app);
|
||||
border-right: 1px solid var(--border-color);
|
||||
width: 240px;
|
||||
min-width: 160px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-panel-header {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
background: var(--bg-titlebar);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px 0 10px;
|
||||
}
|
||||
|
||||
.file-panel-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-panel-header-icon {
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-panel-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-panel-header-actions .btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.file-toggle-hidden {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.file-toggle-hidden.active {
|
||||
background: rgba(59, 130, 246, 0.2) !important;
|
||||
color: var(--accent-color, #7aa2f7) !important;
|
||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.file-panel-pathbar {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-muted);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.file-item.selected {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.file-item.is-hidden {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.file-item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-item.is-directory .file-item-icon {
|
||||
color: #e0af68;
|
||||
}
|
||||
|
||||
.file-item.is-file .file-item-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-item-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-item-size {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-panel-empty,
|
||||
.file-panel-error {
|
||||
padding: 20px 10px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-panel-error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.file-panel-footer {
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
background: var(--bg-titlebar);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/* 工作区侧边栏样式 */
|
||||
.app-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - var(--titlebar-height) - var(--statusbar-height));
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
background: var(--bg-titlebar);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
z-index: 40;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workspace-sidebar.collapsed {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-title-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidebar-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-workspaces {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工作区卡片 */
|
||||
.workspace-card {
|
||||
background: var(--bg-tab-inactive);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-card:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.workspace-card.active {
|
||||
background: var(--bg-tab-active);
|
||||
border-color: var(--border-active);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.ws-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ws-card-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
|
||||
color: var(--accent-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ws-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ws-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ws-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
|
||||
color: var(--text-muted);
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ws-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workspace-card:hover .ws-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ws-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
padding-left: 36px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 工作区展开的标签页列表 */
|
||||
.ws-tabs-container {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ws-tabs-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.ws-tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ws-tab-item:hover {
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.ws-tab-item.active {
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.12));
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ws-tab-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws-tab-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.ws-tab-close {
|
||||
opacity: 0.6;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ws-tab-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ff79c6;
|
||||
}
|
||||
|
||||
.ws-add-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ws-add-tab-btn:hover {
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.06));
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 侧边栏底部添加按钮 */
|
||||
.sidebar-footer {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-new-workspace {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-new-workspace:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
/* 工作区配置模态框细节 */
|
||||
.workspace-modal {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.type-selector-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.type-option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tab-inactive);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.type-option-btn:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.type-option-btn.selected {
|
||||
border-color: var(--border-active);
|
||||
background: var(--bg-tab-active);
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--bg-titlebar);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: var(--accent-text, #ffffff);
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tab-inactive);
|
||||
color: var(--text-bright);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
}
|
||||
|
||||
/* 顶部栏切换按钮高亮 */
|
||||
.btn-toggle-sidebar {
|
||||
-webkit-app-region: no-drag !important;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btn-toggle-sidebar.active {
|
||||
color: var(--accent-color);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: calc(100vw - 320px);
|
||||
-webkit-app-region: no-drag;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar {
|
||||
@@ -30,6 +32,12 @@
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
min-width: 90px;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
.tab-item svg,
|
||||
.tab-item svg * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
@@ -66,6 +74,8 @@
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: all 0.12s ease;
|
||||
-webkit-app-region: no-drag !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
@@ -92,10 +102,15 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
.new-tab-btn svg,
|
||||
.new-tab-btn svg * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.new-tab-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@
|
||||
|
||||
--titlebar-height: 38px;
|
||||
--statusbar-height: 26px;
|
||||
--sidebar-width: 250px;
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
|
||||
--terminal-font-size: 14px;
|
||||
--terminal-line-height: 1.25;
|
||||
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
--accent-text: #0f141c;
|
||||
}
|
||||
|
||||
/* Tokyo Night */
|
||||
@@ -36,8 +41,11 @@
|
||||
--border-active: #7aa2f7;
|
||||
--text-main: #c0caf5;
|
||||
--text-muted: #565f89;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #7aa2f7;
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha */
|
||||
@@ -50,8 +58,11 @@
|
||||
--border-active: #cba6f7;
|
||||
--text-main: #cdd6f4;
|
||||
--text-muted: #6c7086;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #cba6f7;
|
||||
--accent-glow: rgba(203, 166, 247, 0.3);
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Dracula */
|
||||
@@ -64,8 +75,11 @@
|
||||
--border-active: #bd93f9;
|
||||
--text-main: #f8f8f2;
|
||||
--text-muted: #6272a4;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #ff79c6;
|
||||
--accent-glow: rgba(255, 121, 198, 0.3);
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* One Dark */
|
||||
@@ -78,8 +92,11 @@
|
||||
--border-active: #61afef;
|
||||
--text-main: #abb2bf;
|
||||
--text-muted: #5c6370;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #61afef;
|
||||
--accent-glow: rgba(97, 175, 239, 0.3);
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Cyberpunk Neon */
|
||||
@@ -92,6 +109,107 @@
|
||||
--border-active: #00ffcc;
|
||||
--text-main: #e2f0fb;
|
||||
--text-muted: #4e6a88;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #ff007f;
|
||||
--accent-glow: rgba(255, 0, 127, 0.4);
|
||||
--btn-hover: rgba(255, 255, 255, 0.08);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.2);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* ==================== 高品质浅色主题系列 ==================== */
|
||||
|
||||
/* Catppuccin Latte */
|
||||
[data-theme="catppuccin-latte"] {
|
||||
--bg-app: rgba(239, 241, 245, 0.94);
|
||||
--bg-titlebar: rgba(230, 233, 239, 0.96);
|
||||
--bg-tab-active: rgba(220, 224, 232, 0.98);
|
||||
--bg-tab-inactive: rgba(239, 241, 245, 0.65);
|
||||
--bg-tab-hover: rgba(230, 233, 239, 0.9);
|
||||
--border-color: rgba(76, 79, 105, 0.15);
|
||||
--border-active: #1e66f5;
|
||||
--text-main: #4c4f69;
|
||||
--text-muted: #7c7f93;
|
||||
--text-bright: #1e1e2e;
|
||||
--accent-color: #1e66f5;
|
||||
--accent-glow: rgba(30, 102, 245, 0.25);
|
||||
--btn-hover: rgba(0, 0, 0, 0.06);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.06);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* GitHub Light */
|
||||
[data-theme="github-light"] {
|
||||
--bg-app: rgba(255, 255, 255, 0.96);
|
||||
--bg-titlebar: rgba(246, 248, 250, 0.98);
|
||||
--bg-tab-active: rgba(255, 255, 255, 0.98);
|
||||
--bg-tab-inactive: rgba(246, 248, 250, 0.7);
|
||||
--bg-tab-hover: rgba(240, 242, 245, 0.9);
|
||||
--border-color: rgba(31, 35, 40, 0.15);
|
||||
--border-active: #0969da;
|
||||
--text-main: #24292f;
|
||||
--text-muted: #6e7781;
|
||||
--text-bright: #1f2328;
|
||||
--accent-color: #0969da;
|
||||
--accent-glow: rgba(9, 105, 218, 0.25);
|
||||
--btn-hover: rgba(0, 0, 0, 0.06);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.06);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* Tokyo Night Day */
|
||||
[data-theme="tokyo-night-day"] {
|
||||
--bg-app: rgba(225, 226, 231, 0.94);
|
||||
--bg-titlebar: rgba(213, 214, 219, 0.96);
|
||||
--bg-tab-active: rgba(235, 236, 240, 0.98);
|
||||
--bg-tab-inactive: rgba(225, 226, 231, 0.65);
|
||||
--bg-tab-hover: rgba(213, 214, 219, 0.9);
|
||||
--border-color: rgba(55, 96, 191, 0.15);
|
||||
--border-active: #2e7de9;
|
||||
--text-main: #3760bf;
|
||||
--text-muted: #6172b0;
|
||||
--text-bright: #182848;
|
||||
--accent-color: #2e7de9;
|
||||
--accent-glow: rgba(46, 125, 233, 0.25);
|
||||
--btn-hover: rgba(0, 0, 0, 0.06);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.06);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* Solarized Light */
|
||||
[data-theme="solarized-light"] {
|
||||
--bg-app: rgba(253, 246, 227, 0.94);
|
||||
--bg-titlebar: rgba(238, 232, 213, 0.96);
|
||||
--bg-tab-active: rgba(245, 239, 223, 0.98);
|
||||
--bg-tab-inactive: rgba(253, 246, 227, 0.65);
|
||||
--bg-tab-hover: rgba(238, 232, 213, 0.9);
|
||||
--border-color: rgba(101, 123, 131, 0.18);
|
||||
--border-active: #268bd2;
|
||||
--text-main: #657b83;
|
||||
--text-muted: #93a1a1;
|
||||
--text-bright: #073642;
|
||||
--accent-color: #268bd2;
|
||||
--accent-glow: rgba(38, 139, 210, 0.25);
|
||||
--btn-hover: rgba(0, 0, 0, 0.06);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.06);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
/* One Light */
|
||||
[data-theme="one-light"] {
|
||||
--bg-app: rgba(250, 250, 250, 0.95);
|
||||
--bg-titlebar: rgba(240, 240, 240, 0.98);
|
||||
--bg-tab-active: rgba(255, 255, 255, 0.98);
|
||||
--bg-tab-inactive: rgba(240, 240, 240, 0.7);
|
||||
--bg-tab-hover: rgba(230, 230, 230, 0.9);
|
||||
--border-color: rgba(56, 58, 66, 0.15);
|
||||
--border-active: #4078f2;
|
||||
--text-main: #383a42;
|
||||
--text-muted: #6f7179;
|
||||
--text-bright: #202227;
|
||||
--accent-color: #4078f2;
|
||||
--accent-glow: rgba(64, 120, 242, 0.25);
|
||||
--btn-hover: rgba(0, 0, 0, 0.06);
|
||||
--kbd-bg: rgba(0, 0, 0, 0.06);
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
+90
-5
@@ -13,12 +13,21 @@
|
||||
<link rel="stylesheet" href="css/tabs.css">
|
||||
<link rel="stylesheet" href="css/split.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/sidebar.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 顶部现代拖拽式标题栏 -->
|
||||
<header class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<div id="app" class="sidebar-collapsed">
|
||||
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
|
||||
<header class="titlebar" id="titlebar">
|
||||
<div class="titlebar-left sidebar-collapsed" id="titlebar-left">
|
||||
<!-- 侧边栏折叠/展开按钮 -->
|
||||
<button class="btn-icon btn-toggle-sidebar" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="app-brand">
|
||||
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
@@ -27,7 +36,15 @@
|
||||
<span>hatty</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签页栏 -->
|
||||
<!-- 当前活跃工作区标识 (上层结构指示) -->
|
||||
<div class="titlebar-workspace-tag" id="titlebar-workspace-tag" title="当前激活工作区">
|
||||
<span class="ws-tag-dot"></span>
|
||||
<span class="ws-tag-name" id="titlebar-ws-name">本机电脑 (Local PC)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页栏与新建按钮 -->
|
||||
<div class="tab-bar-container" id="tab-bar-container">
|
||||
<div class="tab-bar" id="tab-bar"></div>
|
||||
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -54,6 +71,17 @@
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-file-browser" title="文件浏览面板 (Ctrl+Shift+U)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-palette" title="快速命令面板 (Ctrl+Shift+P / F1)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@@ -89,8 +117,50 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 应用主体容器 (含左侧工作区侧边栏与终端视口) -->
|
||||
<div class="app-body sidebar-collapsed">
|
||||
<!-- 左侧工作区侧边栏 -->
|
||||
<aside class="workspace-sidebar collapsed" id="workspace-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
<svg class="sidebar-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
<span>工作区</span>
|
||||
</div>
|
||||
<div class="sidebar-header-actions">
|
||||
<button class="btn-icon" id="btn-add-workspace" title="新建工作区 (PC / SSH / 开发板)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-collapse-sidebar" title="折叠侧边栏 (Ctrl+B)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-workspaces" id="sidebar-workspace-list"></div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-new-workspace" id="btn-footer-add-workspace">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span>添加工作区 (PC/SSH/开发板)...</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 终端主视口 -->
|
||||
<main class="main-viewport" id="main-viewport"></main>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<footer class="statusbar">
|
||||
@@ -140,6 +210,21 @@
|
||||
<div class="modal-overlay" id="settings-overlay">
|
||||
<div class="settings-modal" id="settings-modal"></div>
|
||||
</div>
|
||||
|
||||
<!-- 工作区创建/编辑模态框 -->
|
||||
<div class="modal-overlay" id="workspace-modal-overlay">
|
||||
<div class="settings-modal workspace-modal">
|
||||
<div class="settings-header">
|
||||
<h3 id="workspace-modal-title">新建工作区</h3>
|
||||
<button class="btn-icon" id="btn-close-workspace-modal">×</button>
|
||||
</div>
|
||||
<div class="settings-body" id="workspace-modal-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="btn-cancel-workspace">取消</button>
|
||||
<button class="btn-primary" id="btn-save-workspace">保存并进入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { TerminalView } from './terminal-view.js';
|
||||
|
||||
export const AGENT_ICONS = {
|
||||
// Google Antigravity (agy) 官方标志:Google 经典四色渐变 (蓝 #4285F4、红 #EA4335、黄 #FBBC05、绿 #34A853)
|
||||
agy: `<svg width="14" height="14" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="agy-google-gradient" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#34A853"/>
|
||||
<stop offset="32%" stop-color="#FBBC05"/>
|
||||
<stop offset="65%" stop-color="#EA4335"/>
|
||||
<stop offset="100%" stop-color="#4285F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M19.08 19.44C20.05 20.17 21.5 19.68 20.17 18.35C16.18 14.48 17.02 3.83 12.06 3.83C7.1 3.83 7.95 14.48 3.95 18.35C2.5 19.81 4.07 20.17 5.04 19.44C8.79 16.9 8.55 12.42 12.06 12.42C15.57 12.42 15.33 16.9 19.08 19.44Z" fill="url(#agy-google-gradient)"></path>
|
||||
</svg>`,
|
||||
// OpenAI ChatGPT / Codex (codex) 标志:从 iconfont.cn (q=chatgpt) 抓取的经典黑色 ChatGPT 标志
|
||||
codex: `<svg width="14" height="14" viewBox="0 0 1024 1024" fill="currentColor">
|
||||
<path d="M939.7888 421.4144a248.832 248.832 0 0 0-21.376-204.3904 251.6608 251.6608 0 0 0-271.0656-120.7424A248.832 248.832 0 0 0 459.648 12.608a251.6864 251.6864 0 0 0-240.0896 174.2592 248.9216 248.9216 0 0 0-166.4 120.704 251.6992 251.6992 0 0 0 30.9504 295.1168 248.832 248.832 0 0 0 21.3888 204.3904 251.6864 251.6864 0 0 0 271.0656 120.7424 248.832 248.832 0 0 0 187.6864 83.6736 251.6736 251.6736 0 0 0 240.1664-174.3488 248.9216 248.9216 0 0 0 166.4-120.7168 251.6992 251.6992 0 0 0-31.04-295.0144zM564.352 946.2016a186.6624 186.6624 0 0 1-119.8208-43.328c1.5104-0.832 4.1728-2.2784 5.9008-3.3536L649.3184 784.64a32.3328 32.3328 0 0 0 16.3328-28.288V475.9296l84.0704 48.5376a3.008 3.008 0 0 1 1.6384 2.304v232.2176a187.4304 187.4304 0 0 1-187.008 187.2z m-402.1888-171.776a186.5472 186.5472 0 0 1-22.336-125.44c1.472 0.8832 4.0576 2.4576 5.9008 3.52l198.8992 114.8928a32.3712 32.3712 0 0 0 32.6656 0l242.8288-140.2112v97.0752a3.008 3.008 0 0 1-1.2032 2.5856l-201.0496 116.0832a187.392 187.392 0 0 1-255.7056-68.5056zM109.824 340.224a186.5088 186.5088 0 0 1 97.4336-82.0736c0 1.7152-0.1024 4.736-0.1024 6.848v229.7728a32.32 32.32 0 0 0 16.3328 28.288l242.816 140.1856-84.0576 48.5376a3.008 3.008 0 0 1-2.8416 0.256L178.3296 595.84A187.392 187.392 0 0 1 109.824 340.224z m690.688 160.7296L557.696 360.7424l84.0576-48.512a3.008 3.008 0 0 1 2.8288-0.256l201.088 116.0832a187.2256 187.2256 0 0 1-28.928 337.8176V529.2416a32.3072 32.3072 0 0 0-16.2304-28.288z m83.6736-125.9264c-1.4848-0.9088-4.0576-2.4704-5.9136-3.5328l-198.8864-114.88a32.4096 32.4096 0 0 0-32.6656 0L403.8912 396.8256v-97.088a3.008 3.008 0 0 1 1.2032-2.5856l201.0496-115.9808a187.2128 187.2128 0 0 1 278.0416 193.856zM358.1824 548.0576l-84.096-48.5376a3.008 3.008 0 0 1-1.6256-2.304V264.9984a187.2128 187.2128 0 0 1 307.008-143.744 172.8 172.8 0 0 0-5.9136 3.3408l-198.8864 114.88a32.32 32.32 0 0 0-16.3456 28.288l-0.1408 280.2944z m45.6704-98.4576L512 387.136l108.16 62.4256v124.8896L512 636.8768l-108.16-62.4256v-124.8512z"></path>
|
||||
</svg>`,
|
||||
// Anthropic Claude (claude) 官方标志:经典 14 瓣温暖射线火花
|
||||
claude: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path>
|
||||
</svg>`,
|
||||
// 通用/默认 AI Agent 标志
|
||||
default: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM7.5 13a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"></path>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 专属 Agent 面板 (AgentPanel)
|
||||
* 每个大标签页独占一个此类面板,用于在右侧全高分屏展示 AI Agent。
|
||||
* 支持在单一面板内以小标签页 (多 Agent) 形式展示。
|
||||
*/
|
||||
export class AgentPanel {
|
||||
constructor(options = {}) {
|
||||
this.tabSplitTree = options.tabSplitTree;
|
||||
this.onClose = options.onClose || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.paneElement = document.createElement('div');
|
||||
this.paneElement.className = 'split-pane agent-panel';
|
||||
this.paneElement.dataset.agentPanel = 'true';
|
||||
|
||||
this.tabs = new Map(); // agentId -> { tabEl, viewEl, termView, agent }
|
||||
this.activeAgentId = null;
|
||||
|
||||
this.initDOM();
|
||||
}
|
||||
|
||||
initDOM() {
|
||||
this.paneElement.innerHTML = `
|
||||
<div class="agent-panel-header">
|
||||
<div class="agent-panel-header-left">
|
||||
<span class="agent-panel-title">AGENT</span>
|
||||
<div class="agent-panel-tabs" role="tablist"></div>
|
||||
</div>
|
||||
<div class="agent-panel-header-actions">
|
||||
<button class="btn-icon agent-panel-close-btn" title="关闭 Agent 面板 (Ctrl+Shift+W)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-panel-body"></div>
|
||||
`;
|
||||
|
||||
this.tabsContainer = this.paneElement.querySelector('.agent-panel-tabs');
|
||||
this.bodyContainer = this.paneElement.querySelector('.agent-panel-body');
|
||||
this.closeBtn = this.paneElement.querySelector('.agent-panel-close-btn');
|
||||
|
||||
this.closeBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
this.paneElement.addEventListener('click', () => {
|
||||
const activeInfo = this.tabs.get(this.activeAgentId);
|
||||
if (activeInfo?.termView) {
|
||||
activeInfo.termView.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAgent(agent, spawnOptions = {}) {
|
||||
const agentId = agent.id || agent.cmd;
|
||||
|
||||
// 1. 如果该 agent 已经在此面板中打开,直接激活并聚焦
|
||||
if (this.tabs.has(agentId)) {
|
||||
this.switchAgent(agentId);
|
||||
return this.tabs.get(agentId).termView;
|
||||
}
|
||||
|
||||
// 2. 创建 Agent 视图容器并设为激活状态,确保 TerminalView 初始化时能测量真实 DOM 宽高
|
||||
for (const entry of this.tabs.values()) {
|
||||
entry.tabEl.classList.remove('active');
|
||||
entry.viewEl.style.display = 'none';
|
||||
entry.viewEl.classList.remove('active');
|
||||
}
|
||||
const viewEl = document.createElement('div');
|
||||
viewEl.className = 'agent-tab-view active';
|
||||
viewEl.style.display = 'flex';
|
||||
this.bodyContainer.appendChild(viewEl);
|
||||
|
||||
// 3. 创建 TerminalView (直接 exec Agent 进程,Agent 退出后 PTY 即刻关闭并联动关闭该 Agent 面板)
|
||||
const agentCmd = agent.path ? `"${agent.path}"` : agent.cmd;
|
||||
const termView = new TerminalView({
|
||||
cwd: spawnOptions.cwd || '~',
|
||||
shell: spawnOptions.shell || '/bin/bash',
|
||||
args: spawnOptions.args || ['-c', `exec ${agentCmd}`],
|
||||
title: agent.name || agentId,
|
||||
container: viewEl,
|
||||
onFocus: (term) => {
|
||||
this.tabSplitTree.setActiveTerminal(term);
|
||||
},
|
||||
onTitleChange: (title) => {
|
||||
if (this.tabSplitTree.activeTerminal === termView) {
|
||||
this.tabSplitTree.onTitleChange(title);
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
this.closeAgent(agentId);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
// 标记该终端为特化的 Agent 终端
|
||||
termView.isAgent = true;
|
||||
termView.agentId = agentId;
|
||||
termView.agentName = agent.name || agentId;
|
||||
|
||||
// 4. 创建紧凑型小标签页 (仅展示对应 agent 图标与小关闭按钮)
|
||||
const tabEl = document.createElement('div');
|
||||
tabEl.className = 'agent-tab';
|
||||
tabEl.dataset.agentId = agentId;
|
||||
tabEl.title = `${agent.name || agentId} (AI Agent)`;
|
||||
|
||||
const iconSvg = AGENT_ICONS[agentId] || AGENT_ICONS.default;
|
||||
tabEl.innerHTML = `
|
||||
<span class="agent-tab-icon">${iconSvg}</span>
|
||||
<span class="agent-tab-name">${agent.name || agentId}</span>
|
||||
<button class="agent-tab-close" title="关闭 ${agent.name || agentId}">×</button>
|
||||
`;
|
||||
|
||||
// 标签页点击切换
|
||||
tabEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.switchAgent(agentId);
|
||||
});
|
||||
|
||||
// 标签页内关闭按钮
|
||||
const tabCloseBtn = tabEl.querySelector('.agent-tab-close');
|
||||
tabCloseBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeAgent(agentId);
|
||||
});
|
||||
|
||||
this.tabsContainer.appendChild(tabEl);
|
||||
|
||||
// 5. 存储并注册进 tabSplitTree 的 terminals 集合中
|
||||
const agentEntry = {
|
||||
agentId,
|
||||
agent,
|
||||
tabEl,
|
||||
viewEl,
|
||||
termView,
|
||||
};
|
||||
this.tabs.set(agentId, agentEntry);
|
||||
|
||||
this.tabSplitTree.terminals.set(termView.id, {
|
||||
termView,
|
||||
paneElement: this.paneElement,
|
||||
parentElement: this.paneElement.parentElement,
|
||||
});
|
||||
|
||||
// 6. 切换到新建的 agent
|
||||
this.switchAgent(agentId);
|
||||
|
||||
return termView;
|
||||
}
|
||||
|
||||
switchAgent(agentId) {
|
||||
if (!this.tabs.has(agentId)) return;
|
||||
this.activeAgentId = agentId;
|
||||
|
||||
for (const [id, entry] of this.tabs.entries()) {
|
||||
const isActive = id === agentId;
|
||||
entry.tabEl.classList.toggle('active', isActive);
|
||||
entry.viewEl.style.display = isActive ? 'flex' : 'none';
|
||||
entry.viewEl.classList.toggle('active', isActive);
|
||||
}
|
||||
|
||||
const currentEntry = this.tabs.get(agentId);
|
||||
if (currentEntry?.termView) {
|
||||
this.tabSplitTree.setActiveTerminal(currentEntry.termView);
|
||||
currentEntry.termView.fit();
|
||||
currentEntry.termView.focus();
|
||||
setTimeout(() => {
|
||||
currentEntry.termView.fit();
|
||||
currentEntry.termView.focus();
|
||||
}, 40);
|
||||
}
|
||||
}
|
||||
|
||||
closeAgent(agentId) {
|
||||
const entry = this.tabs.get(agentId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.termView.destroy();
|
||||
this.tabSplitTree.terminals.delete(entry.termView.id);
|
||||
|
||||
entry.tabEl.remove();
|
||||
entry.viewEl.remove();
|
||||
this.tabs.delete(agentId);
|
||||
|
||||
if (this.tabs.size === 0) {
|
||||
// 当 Agent 面板中无任何 agent 时,销毁整个 Agent 面板
|
||||
this.destroy();
|
||||
} else {
|
||||
// 切换至剩余的其他 agent
|
||||
if (this.activeAgentId === agentId) {
|
||||
const nextId = this.tabs.keys().next().value;
|
||||
this.switchAgent(nextId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeCurrentAgent() {
|
||||
if (this.activeAgentId) {
|
||||
this.closeAgent(this.activeAgentId);
|
||||
} else if (this.tabs.size > 0) {
|
||||
const firstId = this.tabs.keys().next().value;
|
||||
this.closeAgent(firstId);
|
||||
} else {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTerminal() {
|
||||
if (!this.activeAgentId) return null;
|
||||
return this.tabs.get(this.activeAgentId)?.termView || null;
|
||||
}
|
||||
|
||||
fit() {
|
||||
const active = this.getActiveTerminal();
|
||||
if (active) active.fit();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// 销毁面板内所有运行中的终端
|
||||
for (const entry of this.tabs.values()) {
|
||||
entry.termView.destroy();
|
||||
this.tabSplitTree.terminals.delete(entry.termView.id);
|
||||
}
|
||||
this.tabs.clear();
|
||||
this.activeAgentId = null;
|
||||
|
||||
// 通知外部从 splitTree 移除 Agent 面板窗格
|
||||
this.onClose(this);
|
||||
}
|
||||
}
|
||||
+150
-13
@@ -1,22 +1,23 @@
|
||||
import { themeManager } from './theme-manager.js';
|
||||
import { TabManager } from './tab-manager.js';
|
||||
import { WorkspaceManager } from './workspace-manager.js';
|
||||
import { CommandPalette } from './command-palette.js';
|
||||
import { SettingsModal } from './settings.js';
|
||||
import { ContextMenu } from './context-menu.js';
|
||||
|
||||
class TerminalApp {
|
||||
constructor() {
|
||||
this.initElements();
|
||||
this.initTheme();
|
||||
this.initTabManager();
|
||||
this.initWorkspaceManager();
|
||||
this.initContextMenu();
|
||||
this.initModals();
|
||||
this.initSearch();
|
||||
this.initWindowControls();
|
||||
this.initIpcListeners();
|
||||
this.initKeyboardShortcuts();
|
||||
this.initStatusBar();
|
||||
|
||||
// 创建初始标签页
|
||||
this.tabManager.createTab();
|
||||
}
|
||||
|
||||
initElements() {
|
||||
@@ -55,15 +56,65 @@ class TerminalApp {
|
||||
this.tabManager = new TabManager({
|
||||
tabBar: this.tabBar,
|
||||
viewport: this.viewport,
|
||||
getSpawnOptions: (wsId) => (this.workspaceManager ? this.workspaceManager.getSpawnOptions(wsId) : {}),
|
||||
onActiveChange: (activeTerm) => {
|
||||
if (activeTerm && this.statusShell) {
|
||||
this.statusShell.textContent = activeTerm.title || 'bash';
|
||||
}
|
||||
this.updateSplitButtonsState(activeTerm);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.contextMenu?.show(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
this.newTabBtn.addEventListener('click', () => {
|
||||
this.tabManager.createTab();
|
||||
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
|
||||
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
|
||||
this.tabManager.createTab({
|
||||
workspaceId: activeWs?.id,
|
||||
title: spawnOpts.title,
|
||||
cwd: spawnOpts.cwd,
|
||||
shell: spawnOpts.shell,
|
||||
args: spawnOpts.args,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateSplitButtonsState(activeTerm) {
|
||||
const isAgent = !!(
|
||||
activeTerm?.isAgent ||
|
||||
activeTerm?.element?.closest?.('.agent-panel') ||
|
||||
activeTerm?.terminalWrapper?.closest?.('.agent-panel')
|
||||
);
|
||||
const btnH = document.getElementById('btn-split-h');
|
||||
const btnV = document.getElementById('btn-split-v');
|
||||
if (btnH) {
|
||||
btnH.disabled = isAgent;
|
||||
btnH.classList.toggle('disabled', isAgent);
|
||||
btnH.title = isAgent ? 'Agent 面板不可分屏' : '水平分屏 (Ctrl+Shift+E)';
|
||||
}
|
||||
if (btnV) {
|
||||
btnV.disabled = isAgent;
|
||||
btnV.classList.toggle('disabled', isAgent);
|
||||
btnV.title = isAgent ? 'Agent 面板不可分屏' : '垂直分屏 (Ctrl+Shift+O)';
|
||||
}
|
||||
}
|
||||
|
||||
initWorkspaceManager() {
|
||||
this.workspaceManager = new WorkspaceManager({
|
||||
tabManager: this.tabManager,
|
||||
});
|
||||
}
|
||||
|
||||
initContextMenu() {
|
||||
this.contextMenu = new ContextMenu({
|
||||
tabManager: this.tabManager,
|
||||
workspaceManager: this.workspaceManager,
|
||||
});
|
||||
|
||||
window.addEventListener('terminal-contextmenu', (e) => {
|
||||
this.contextMenu?.show(e.detail.event, e.detail.termView);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +124,7 @@ class TerminalApp {
|
||||
input: this.commandPaletteInput,
|
||||
list: this.commandPaletteList,
|
||||
tabManager: this.tabManager,
|
||||
workspaceManager: this.workspaceManager,
|
||||
onOpenSettings: () => this.settings.open(),
|
||||
onToggleSearch: () => this.toggleSearch(),
|
||||
});
|
||||
@@ -94,12 +146,24 @@ class TerminalApp {
|
||||
|
||||
document.getElementById('btn-split-h')?.addEventListener('click', () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('horizontal');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-split-v')?.addEventListener('click', () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('vertical');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-file-browser')?.addEventListener('click', () => {
|
||||
this.toggleFileBrowser();
|
||||
});
|
||||
|
||||
document.getElementById('btn-search')?.addEventListener('click', () => {
|
||||
this.toggleSearch();
|
||||
});
|
||||
|
||||
document.getElementById('status-theme')?.addEventListener('click', () => {
|
||||
@@ -157,6 +221,12 @@ class TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
toggleFileBrowser(initialDir = null) {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (!tab || !tab.splitTree) return null;
|
||||
return tab.splitTree.toggleFilePanel(initialDir);
|
||||
}
|
||||
|
||||
initWindowControls() {
|
||||
document.getElementById('win-min')?.addEventListener('click', () => {
|
||||
window.terminalAPI.minimize();
|
||||
@@ -203,24 +273,71 @@ class TerminalApp {
|
||||
}
|
||||
|
||||
initKeyboardShortcuts() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
// Ctrl+Shift+P 或 F1: 命令面板
|
||||
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.commandPalette.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+T: 新建标签
|
||||
// Ctrl+B: 切换左侧工作区侧边栏
|
||||
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.workspaceManager?.toggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditingText = (el) => {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toLowerCase();
|
||||
return (tag === 'input' || tag === 'textarea') && !el.classList.contains('xterm-helper-textarea');
|
||||
};
|
||||
|
||||
// Ctrl+Shift+C: 复制当前选中终端文本
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') {
|
||||
if (isEditingText(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeTerm = this.tabManager.getActiveTerminal();
|
||||
if (activeTerm) activeTerm.copySelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+V: 粘贴剪贴板文本到当前终端
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'v') {
|
||||
if (isEditingText(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeTerm = this.tabManager.getActiveTerminal();
|
||||
if (activeTerm) activeTerm.pasteFromClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+T: 新建当前工作区标签页
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
this.tabManager.createTab();
|
||||
e.stopPropagation();
|
||||
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
|
||||
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
|
||||
this.tabManager.createTab({
|
||||
workspaceId: activeWs?.id,
|
||||
title: spawnOpts.title,
|
||||
cwd: spawnOpts.cwd,
|
||||
shell: spawnOpts.shell,
|
||||
args: spawnOpts.args,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+W: 关闭窗格
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'w') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab && tab.splitTree.activeTerminal) {
|
||||
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
|
||||
@@ -231,29 +348,45 @@ class TerminalApp {
|
||||
// Ctrl+Shift+E: 水平分屏 (左右)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('horizontal');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+O: 垂直分屏 (上下)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('vertical');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+F: 搜索
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+U 或 Alt+F: 切换文件浏览面板
|
||||
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'u') || (e.altKey && !e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === 'f')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleFileBrowser();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+,: 设置
|
||||
if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.settings.open();
|
||||
return;
|
||||
}
|
||||
@@ -261,6 +394,7 @@ class TerminalApp {
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
this.tabManager.prevTab();
|
||||
} else {
|
||||
@@ -272,13 +406,16 @@ class TerminalApp {
|
||||
// Alt+1 .. Alt+9: 快速直达标签页
|
||||
if (e.altKey && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
const ids = Array.from(this.tabManager.tabs.keys());
|
||||
if (ids[index]) {
|
||||
this.tabManager.switchTab(ids[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
true // 启用捕获阶段,确保终端即使在聚焦/输入状态下应用级快捷键也能被优先拦截响应
|
||||
);
|
||||
}
|
||||
|
||||
async initStatusBar() {
|
||||
@@ -295,5 +432,5 @@ class TerminalApp {
|
||||
|
||||
// 页面加载就绪后启动终端
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new TerminalApp();
|
||||
window.terminalApp = new TerminalApp();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export class CommandPalette {
|
||||
this.input = options.input;
|
||||
this.list = options.list;
|
||||
this.tabManager = options.tabManager;
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
this.onOpenSettings = options.onOpenSettings || (() => {});
|
||||
this.onToggleSearch = options.onToggleSearch || (() => {});
|
||||
|
||||
@@ -19,12 +20,36 @@ export class CommandPalette {
|
||||
|
||||
initCommands() {
|
||||
this.baseCommands = [
|
||||
{
|
||||
id: 'toggle-sidebar',
|
||||
title: '切换工作区侧边栏 (Toggle Workspace Sidebar)',
|
||||
shortcut: 'Ctrl+B',
|
||||
icon: 'layout',
|
||||
action: () => this.workspaceManager?.toggleSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'new-workspace',
|
||||
title: '新建工作区: 本地PC / SSH远程 / 开发板 (New Workspace)',
|
||||
shortcut: '',
|
||||
icon: 'plus',
|
||||
action: () => this.workspaceManager?.openCreateModal(),
|
||||
},
|
||||
{
|
||||
id: 'new-tab',
|
||||
title: '新建标签页 (New Tab)',
|
||||
shortcut: 'Ctrl+Shift+T',
|
||||
icon: 'plus',
|
||||
action: () => this.tabManager.createTab(),
|
||||
action: () => {
|
||||
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
|
||||
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
|
||||
this.tabManager.createTab({
|
||||
workspaceId: activeWs?.id,
|
||||
title: spawnOpts.title,
|
||||
cwd: spawnOpts.cwd,
|
||||
shell: spawnOpts.shell,
|
||||
args: spawnOpts.args,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'split-horizontal',
|
||||
@@ -33,7 +58,9 @@ export class CommandPalette {
|
||||
icon: 'columns',
|
||||
action: () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('horizontal');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,7 +70,9 @@ export class CommandPalette {
|
||||
icon: 'rows',
|
||||
action: () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('vertical');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,6 +87,15 @@ export class CommandPalette {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'toggle-file-browser',
|
||||
title: '切换文件浏览面板 (Toggle File Explorer)',
|
||||
shortcut: 'Ctrl+Shift+U',
|
||||
icon: 'folder',
|
||||
action: () => {
|
||||
this.tabManager?.toggleFileBrowser();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: '终端内容快速搜索 (Search Buffer)',
|
||||
@@ -65,6 +103,26 @@ export class CommandPalette {
|
||||
icon: 'search',
|
||||
action: () => this.onToggleSearch(),
|
||||
},
|
||||
{
|
||||
id: 'copy-selection',
|
||||
title: '复制终端选中内容 (Copy Selection)',
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
icon: 'copy',
|
||||
action: () => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.copySelection();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'paste-clipboard',
|
||||
title: '粘贴剪贴板内容到终端 (Paste Clipboard)',
|
||||
shortcut: 'Ctrl+Shift+V',
|
||||
icon: 'clipboard',
|
||||
action: () => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.pasteFromClipboard();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
title: '清空当前屏幕缓冲区 (Clear Buffer)',
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 终端右键上下文菜单组件 (ContextMenu)
|
||||
* 提供终端基础操作(复制、粘贴、分屏、清屏)
|
||||
*/
|
||||
import { AGENT_ICONS } from './agent-panel.js';
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(options = {}) {
|
||||
this.tabManager = options.tabManager;
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
this.menuElement = null;
|
||||
this.currentTerminal = null;
|
||||
this.isOpen = false;
|
||||
|
||||
this.initElement();
|
||||
this.bindGlobalEvents();
|
||||
}
|
||||
|
||||
initElement() {
|
||||
this.menuElement = document.createElement('div');
|
||||
this.menuElement.className = 'terminal-context-menu';
|
||||
this.menuElement.id = 'terminal-context-menu';
|
||||
document.body.appendChild(this.menuElement);
|
||||
|
||||
// 阻止菜单内部右键默认行为
|
||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
bindGlobalEvents() {
|
||||
// 点击外部关闭
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (this.isOpen && !this.menuElement.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Esc 关闭
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this.isOpen && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 窗口尺寸变化或失焦关闭
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.isOpen) this.close();
|
||||
});
|
||||
window.addEventListener('blur', () => {
|
||||
if (this.isOpen) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
async show(e, termView) {
|
||||
if (!termView) return;
|
||||
this.currentTerminal = termView;
|
||||
|
||||
// 检查是否有选中文本
|
||||
const hasSelection = termView.xterm ? termView.xterm.hasSelection() : false;
|
||||
|
||||
// 检查当前终端是否是 Agent 终端 (在 Agent 面板内)
|
||||
const isAgent = !!(
|
||||
termView.isAgent ||
|
||||
termView.element?.closest('.agent-panel') ||
|
||||
termView.terminalWrapper?.closest('.agent-panel')
|
||||
);
|
||||
|
||||
// 检查当前终端是否处于分屏状态(所属标签页中是否有多于 1 个终端)
|
||||
let activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab || !activeTab.splitTree?.terminals.has(termView.id)) {
|
||||
if (this.tabManager?.tabs) {
|
||||
for (const tab of this.tabManager.tabs.values()) {
|
||||
if (tab.splitTree?.terminals.has(termView.id)) {
|
||||
activeTab = tab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const isSplit = isAgent || !!(activeTab && activeTab.splitTree && activeTab.splitTree.terminals.size > 1);
|
||||
|
||||
// 获取本机已安装的 Agent (agy, codex, claude) - 仅在非 Agent 面板中右键时检索
|
||||
let installedAgents = [];
|
||||
if (!isAgent) {
|
||||
try {
|
||||
if (window.terminalAPI?.getInstalledAgents) {
|
||||
const agents = await window.terminalAPI.getInstalledAgents();
|
||||
installedAgents = (agents || []).filter((a) => a.installed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to query installed agents:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染菜单项
|
||||
this.renderMenu(hasSelection, installedAgents, isSplit, isAgent);
|
||||
|
||||
// 计算定位与防止越界
|
||||
this.menuElement.style.display = 'flex';
|
||||
this.menuElement.classList.add('active');
|
||||
this.isOpen = true;
|
||||
|
||||
const menuRect = this.menuElement.getBoundingClientRect();
|
||||
let x = e.clientX || 0;
|
||||
let y = e.clientY || 0;
|
||||
|
||||
if (x + menuRect.width > window.innerWidth - 8) {
|
||||
x = window.innerWidth - menuRect.width - 8;
|
||||
}
|
||||
if (y + menuRect.height > window.innerHeight - 8) {
|
||||
y = window.innerHeight - menuRect.height - 8;
|
||||
}
|
||||
if (x < 8) x = 8;
|
||||
if (y < 8) y = 8;
|
||||
|
||||
this.menuElement.style.left = `${Math.round(x)}px`;
|
||||
this.menuElement.style.top = `${Math.round(y)}px`;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.menuElement.classList.remove('active');
|
||||
this.menuElement.style.display = 'none';
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
renderMenu(hasSelection, installedAgents, isSplit = false, isAgent = false) {
|
||||
this.menuElement.innerHTML = '';
|
||||
|
||||
// 1. 复制
|
||||
const copyItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>`,
|
||||
label: '复制 (Copy)',
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
this.currentTerminal?.copySelection();
|
||||
},
|
||||
});
|
||||
copyItem.id = 'ctx-copy';
|
||||
this.menuElement.appendChild(copyItem);
|
||||
|
||||
// 2. 粘贴
|
||||
const pasteItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>`,
|
||||
label: '粘贴 (Paste)',
|
||||
shortcut: 'Ctrl+Shift+V',
|
||||
action: () => {
|
||||
this.currentTerminal?.pasteFromClipboard();
|
||||
},
|
||||
});
|
||||
pasteItem.id = 'ctx-paste';
|
||||
this.menuElement.appendChild(pasteItem);
|
||||
|
||||
// 分隔线
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
// Agent 面板特化菜单:只有关闭窗口功能,没有分屏和打开终端功能
|
||||
if (isAgent) {
|
||||
const closeItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
label: '关闭窗口',
|
||||
shortcut: 'Ctrl+Shift+W',
|
||||
disabled: false,
|
||||
title: '关闭当前窗口 (Ctrl+Shift+W)',
|
||||
action: () => {
|
||||
this.closeCurrentSplitTerminal();
|
||||
},
|
||||
});
|
||||
closeItem.id = 'ctx-close-term';
|
||||
closeItem.classList.add('ctx-close-pane');
|
||||
this.menuElement.appendChild(closeItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 常规终端菜单
|
||||
// 3. 浏览当前目录文件
|
||||
const fileItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>`,
|
||||
label: '浏览当前目录文件',
|
||||
shortcut: 'Ctrl+Shift+U',
|
||||
action: async () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (!tab || !tab.splitTree) return;
|
||||
let cwd = this.currentTerminal?.cwd || '~';
|
||||
try {
|
||||
if (this.currentTerminal && window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(this.currentTerminal.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
tab.splitTree.toggleFilePanel(cwd);
|
||||
},
|
||||
});
|
||||
fileItem.id = 'ctx-file-browser';
|
||||
this.menuElement.appendChild(fileItem);
|
||||
|
||||
// 4. 向右分屏
|
||||
const splitHItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||
</svg>`,
|
||||
label: '向右水平分屏',
|
||||
shortcut: 'Ctrl+Shift+E',
|
||||
action: () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
},
|
||||
});
|
||||
splitHItem.id = 'ctx-split-h';
|
||||
this.menuElement.appendChild(splitHItem);
|
||||
|
||||
// 4. 向下分屏
|
||||
const splitVItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>`,
|
||||
label: '向下垂直分屏',
|
||||
shortcut: 'Ctrl+Shift+O',
|
||||
action: () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
},
|
||||
});
|
||||
splitVItem.id = 'ctx-split-v';
|
||||
this.menuElement.appendChild(splitVItem);
|
||||
|
||||
// 5. 关闭当前终端 (只关闭分屏的终端)
|
||||
const closeItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
label: '关闭当前终端',
|
||||
shortcut: 'Ctrl+Shift+W',
|
||||
disabled: !isSplit,
|
||||
title: !isSplit ? '仅在分屏时可关闭终端' : '关闭当前终端 (Ctrl+Shift+W)',
|
||||
action: () => {
|
||||
this.closeCurrentSplitTerminal();
|
||||
},
|
||||
});
|
||||
closeItem.id = 'ctx-close-term';
|
||||
closeItem.classList.add('ctx-close-pane');
|
||||
this.menuElement.appendChild(closeItem);
|
||||
|
||||
// 分隔线
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
// 6. 清屏
|
||||
const clearItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<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"></path>
|
||||
</svg>`,
|
||||
label: '清空当前屏幕',
|
||||
shortcut: 'Ctrl+L',
|
||||
action: () => {
|
||||
this.currentTerminal?.xterm?.clear();
|
||||
},
|
||||
});
|
||||
clearItem.id = 'ctx-clear';
|
||||
this.menuElement.appendChild(clearItem);
|
||||
|
||||
// 7. 已安装的 Agent (如果有安装)
|
||||
if (installedAgents && installedAgents.length > 0) {
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ctx-menu-header';
|
||||
header.textContent = 'AI AGENTS (右侧分屏)';
|
||||
this.menuElement.appendChild(header);
|
||||
|
||||
for (const agent of installedAgents) {
|
||||
const agentItem = this.createMenuItem({
|
||||
icon: AGENT_ICONS[agent.id] || AGENT_ICONS.default,
|
||||
label: `打开 ${agent.name}`,
|
||||
badge: '右侧分屏',
|
||||
action: () => {
|
||||
this.launchAgentInSplit(agent);
|
||||
},
|
||||
});
|
||||
agentItem.id = `ctx-open-${agent.id}`;
|
||||
this.menuElement.appendChild(agentItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createMenuItem({ icon, label, shortcut, badge, disabled, title, action }) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ctx-menu-item' + (disabled ? ' disabled' : '');
|
||||
if (title) item.title = title;
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'ctx-menu-item-left';
|
||||
left.innerHTML = `<span class="ctx-menu-icon">${icon || ''}</span><span class="ctx-menu-label">${label}</span>`;
|
||||
item.appendChild(left);
|
||||
|
||||
if (badge) {
|
||||
const badgeEl = document.createElement('span');
|
||||
badgeEl.className = 'ctx-menu-badge';
|
||||
badgeEl.textContent = badge;
|
||||
item.appendChild(badgeEl);
|
||||
} else if (shortcut) {
|
||||
const shortcutEl = document.createElement('span');
|
||||
shortcutEl.className = 'ctx-menu-shortcut';
|
||||
shortcutEl.textContent = shortcut;
|
||||
item.appendChild(shortcutEl);
|
||||
}
|
||||
|
||||
if (!disabled && action) {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
createDivider() {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'ctx-menu-divider';
|
||||
return divider;
|
||||
}
|
||||
|
||||
async launchAgentInSplit(agent) {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab || !activeTab.splitTree) return;
|
||||
|
||||
// 获取当前右键终端的真实工作目录
|
||||
let cwd = this.currentTerminal?.cwd || '~';
|
||||
try {
|
||||
if (this.currentTerminal && window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(this.currentTerminal.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 新打开的 agent 在特化的 Agent 面板中展示 (每个大标签页独占一个,内部多小标签页)
|
||||
const agentCmd = agent.path ? `"${agent.path}"` : (agent.cmd || 'bash');
|
||||
const spawnOpts = {
|
||||
cwd,
|
||||
shell: '/bin/bash',
|
||||
args: ['-c', `exec ${agentCmd}`],
|
||||
title: agent.name || agent.cmd,
|
||||
};
|
||||
|
||||
if (typeof activeTab.splitTree.openAgent === 'function') {
|
||||
activeTab.splitTree.openAgent(agent, spawnOpts);
|
||||
} else if (typeof activeTab.splitTree.splitRoot === 'function') {
|
||||
activeTab.splitTree.splitRoot('horizontal', spawnOpts);
|
||||
} else {
|
||||
activeTab.splitTree.splitCurrent('horizontal', spawnOpts);
|
||||
}
|
||||
}
|
||||
|
||||
closeCurrentSplitTerminal() {
|
||||
if (!this.currentTerminal) return;
|
||||
|
||||
let tab = this.tabManager?.getActiveTab();
|
||||
if (!tab || !tab.splitTree?.terminals.has(this.currentTerminal.id)) {
|
||||
if (this.tabManager?.tabs) {
|
||||
for (const t of this.tabManager.tabs.values()) {
|
||||
if (t.splitTree?.terminals.has(this.currentTerminal.id)) {
|
||||
tab = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tab || !tab.splitTree) return;
|
||||
|
||||
// 如果是 Agent 终端,直接通过 splitTree.closeTerminal 关闭当前 agent 标签页/窗口
|
||||
if (
|
||||
this.currentTerminal.isAgent ||
|
||||
this.currentTerminal.element?.closest('.agent-panel') ||
|
||||
this.currentTerminal.terminalWrapper?.closest('.agent-panel')
|
||||
) {
|
||||
tab.splitTree.closeTerminal(this.currentTerminal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 严格限制:只关闭分屏的终端(即窗格数量大于 1),单个终端绝不关闭
|
||||
if (tab.splitTree.terminals.size <= 1) return;
|
||||
|
||||
tab.splitTree.closeTerminal(this.currentTerminal.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 文件浏览面板组件 (FilePanel)
|
||||
* 每个大标签页独占至多一个,默认展示终端同目录的文件与文件夹。
|
||||
* 支持进入子目录、返回上层、刷新,以及显隐隐藏文件 (dotfiles) 的开关切换。
|
||||
*/
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0 || !bytes) return '';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
if (i <= 0) return bytes + ' B';
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export class FilePanel {
|
||||
constructor(options = {}) {
|
||||
this.tabSplitTree = options.tabSplitTree;
|
||||
this.initialDir = options.initialDir || null;
|
||||
this.onClose = options.onClose || (() => {});
|
||||
|
||||
this.showHidden = false; // 是否显示隐藏文件
|
||||
this.currentPath = null;
|
||||
this.parentPath = null;
|
||||
this.entries = [];
|
||||
this.selectedPath = null;
|
||||
|
||||
this.paneElement = document.createElement('div');
|
||||
this.paneElement.className = 'split-pane file-panel';
|
||||
this.paneElement.dataset.filePanel = 'true';
|
||||
|
||||
this.initDOM();
|
||||
this.initPath();
|
||||
}
|
||||
|
||||
initDOM() {
|
||||
this.paneElement.innerHTML = `
|
||||
<div class="file-panel-header">
|
||||
<div class="file-panel-header-left">
|
||||
<svg class="file-panel-header-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span class="file-panel-title">文件浏览</span>
|
||||
</div>
|
||||
<div class="file-panel-header-actions">
|
||||
<button class="btn-icon file-toggle-hidden" id="file-toggle-hidden" title="显示隐藏文件 (当前已隐藏)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-up-btn" title="返回上一级目录">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-refresh-btn" title="刷新目录 (Refresh)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-panel-close-btn" title="关闭文件浏览面板">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-panel-pathbar">
|
||||
<span class="file-panel-path" title="">/</span>
|
||||
</div>
|
||||
<div class="file-panel-body">
|
||||
<div class="file-list" role="list"></div>
|
||||
<div class="file-panel-empty" style="display: none;">目录为空</div>
|
||||
<div class="file-panel-error" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="file-panel-footer">
|
||||
<span class="file-count-info">0 项</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.toggleHiddenBtn = this.paneElement.querySelector('.file-toggle-hidden');
|
||||
this.upBtn = this.paneElement.querySelector('.file-up-btn');
|
||||
this.refreshBtn = this.paneElement.querySelector('.file-refresh-btn');
|
||||
this.closeBtn = this.paneElement.querySelector('.file-panel-close-btn');
|
||||
this.pathDisplay = this.paneElement.querySelector('.file-panel-path');
|
||||
this.fileListEl = this.paneElement.querySelector('.file-list');
|
||||
this.emptyEl = this.paneElement.querySelector('.file-panel-empty');
|
||||
this.errorEl = this.paneElement.querySelector('.file-panel-error');
|
||||
this.footerInfo = this.paneElement.querySelector('.file-count-info');
|
||||
|
||||
// 绑定显隐隐藏文件开关
|
||||
this.toggleHiddenBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleHidden();
|
||||
});
|
||||
|
||||
// 返回上级
|
||||
this.upBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.parentPath) {
|
||||
this.loadDirectory(this.parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新
|
||||
this.refreshBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.currentPath) {
|
||||
this.loadDirectory(this.currentPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭文件面板
|
||||
this.closeBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
async initPath() {
|
||||
if (this.initialDir) {
|
||||
await this.loadDirectory(this.initialDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认展示终端同目录
|
||||
let cwd = '~';
|
||||
const term = this.tabSplitTree?.activeTerminal;
|
||||
if (term) {
|
||||
if (term.cwd) cwd = term.cwd;
|
||||
try {
|
||||
if (window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(term.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (this.tabSplitTree?.spawnOptions?.cwd) {
|
||||
cwd = this.tabSplitTree.spawnOptions.cwd;
|
||||
}
|
||||
|
||||
await this.loadDirectory(cwd);
|
||||
}
|
||||
|
||||
async loadDirectory(dirPath) {
|
||||
if (!dirPath) return;
|
||||
|
||||
try {
|
||||
this.errorEl.style.display = 'none';
|
||||
const result = await window.terminalAPI.readDir({
|
||||
dirPath,
|
||||
showHidden: this.showHidden,
|
||||
});
|
||||
|
||||
if (!result || !result.success) {
|
||||
this.errorEl.textContent = result?.error || '无法读取目录';
|
||||
this.errorEl.style.display = 'block';
|
||||
this.emptyEl.style.display = 'none';
|
||||
this.fileListEl.innerHTML = '';
|
||||
this.footerInfo.textContent = '读取失败';
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentPath = result.dirPath;
|
||||
this.parentPath = result.parentPath;
|
||||
this.entries = result.entries || [];
|
||||
|
||||
// 更新顶部路径指示
|
||||
this.pathDisplay.textContent = this.currentPath;
|
||||
this.pathDisplay.title = this.currentPath;
|
||||
|
||||
// 更新返回上级按钮状态
|
||||
if (this.upBtn) {
|
||||
this.upBtn.disabled = !this.parentPath;
|
||||
this.upBtn.classList.toggle('disabled', !this.parentPath);
|
||||
}
|
||||
|
||||
// 渲染列表
|
||||
this.renderEntries();
|
||||
} catch (err) {
|
||||
console.error('Failed to load directory in FilePanel:', err);
|
||||
this.errorEl.textContent = err.message || '加载目录出错';
|
||||
this.errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
renderEntries() {
|
||||
this.fileListEl.innerHTML = '';
|
||||
|
||||
if (this.entries.length === 0) {
|
||||
this.emptyEl.style.display = 'block';
|
||||
this.footerInfo.textContent = '空目录';
|
||||
return;
|
||||
}
|
||||
|
||||
this.emptyEl.style.display = 'none';
|
||||
|
||||
let dirCount = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const item of this.entries) {
|
||||
if (item.isDirectory) dirCount++;
|
||||
else fileCount++;
|
||||
|
||||
const itemEl = document.createElement('div');
|
||||
itemEl.className = 'file-item' + (item.isDirectory ? ' is-directory' : ' is-file');
|
||||
if (item.isHidden) itemEl.classList.add('is-hidden');
|
||||
if (this.selectedPath === item.path) itemEl.classList.add('selected');
|
||||
itemEl.dataset.path = item.path;
|
||||
itemEl.title = `${item.name}${item.size ? ' (' + formatFileSize(item.size) + ')' : ''}`;
|
||||
|
||||
const iconSvg = item.isDirectory
|
||||
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"></path>
|
||||
</svg>`
|
||||
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>`;
|
||||
|
||||
const sizeStr = item.isFile && item.size !== undefined ? formatFileSize(item.size) : '';
|
||||
|
||||
itemEl.innerHTML = `
|
||||
<span class="file-item-icon">${iconSvg}</span>
|
||||
<span class="file-item-name">${item.name}</span>
|
||||
${sizeStr ? `<span class="file-item-size">${sizeStr}</span>` : ''}
|
||||
`;
|
||||
|
||||
// 单击选择
|
||||
itemEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectItem(item.path, itemEl);
|
||||
if (item.isDirectory) {
|
||||
// 文件夹单击进入
|
||||
this.loadDirectory(item.path);
|
||||
}
|
||||
});
|
||||
|
||||
// 双击进入或操作
|
||||
itemEl.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.isDirectory) {
|
||||
this.loadDirectory(item.path);
|
||||
} else {
|
||||
// 双击文件复制路径到剪贴板
|
||||
if (window.terminalAPI?.writeClipboard) {
|
||||
window.terminalAPI.writeClipboard(item.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fileListEl.appendChild(itemEl);
|
||||
}
|
||||
|
||||
this.footerInfo.textContent = `${dirCount} 个文件夹, ${fileCount} 个文件`;
|
||||
}
|
||||
|
||||
selectItem(itemPath, itemEl) {
|
||||
this.selectedPath = itemPath;
|
||||
const prev = this.fileListEl.querySelector('.file-item.selected');
|
||||
if (prev) prev.classList.remove('selected');
|
||||
itemEl?.classList.add('selected');
|
||||
}
|
||||
|
||||
toggleHidden() {
|
||||
this.showHidden = !this.showHidden;
|
||||
this.updateHiddenButton();
|
||||
if (this.currentPath) {
|
||||
this.loadDirectory(this.currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
updateHiddenButton() {
|
||||
if (!this.toggleHiddenBtn) return;
|
||||
this.toggleHiddenBtn.classList.toggle('active', this.showHidden);
|
||||
this.toggleHiddenBtn.title = this.showHidden ? '隐藏隐藏文件 (当前已显示)' : '显示隐藏文件 (当前已隐藏)';
|
||||
|
||||
// 图标切换 (睁眼 / 闭眼)
|
||||
this.toggleHiddenBtn.innerHTML = this.showHidden
|
||||
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</svg>`
|
||||
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose(this);
|
||||
this.paneElement.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,39 @@
|
||||
import { TerminalView } from './terminal-view.js';
|
||||
import { AgentPanel } from './agent-panel.js';
|
||||
import { FilePanel } from './file-panel.js';
|
||||
|
||||
export class TabSplitTree {
|
||||
constructor(options = {}) {
|
||||
this.container = options.container;
|
||||
this.tabId = options.tabId;
|
||||
this.spawnOptions = options.spawnOptions || {};
|
||||
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
||||
this.onEmpty = options.onEmpty || (() => {});
|
||||
this.onTitleChange = options.onTitleChange || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.terminals = new Map(); // id -> { termView, paneElement, parentElement }
|
||||
this.activeTerminal = null;
|
||||
this.agentPanel = null; // 每个大标签页独占至多一个 AgentPanel
|
||||
this.filePanel = null; // 每个大标签页独占至多一个 FilePanel
|
||||
|
||||
this.rootElement = document.createElement('div');
|
||||
this.rootElement.className = 'split-container horizontal';
|
||||
this.container.appendChild(this.rootElement);
|
||||
|
||||
// 创建初始窗格
|
||||
this.addPane(this.rootElement);
|
||||
this.addPane(this.rootElement, 'horizontal', this.spawnOptions);
|
||||
}
|
||||
|
||||
addPane(parentElement, orientation = 'horizontal') {
|
||||
addPane(parentElement, orientation = 'horizontal', paneSpawnOptions = {}) {
|
||||
const paneElement = document.createElement('div');
|
||||
paneElement.className = 'split-pane';
|
||||
|
||||
parentElement.appendChild(paneElement);
|
||||
|
||||
const termView = new TerminalView({
|
||||
...this.spawnOptions,
|
||||
...paneSpawnOptions,
|
||||
container: paneElement,
|
||||
onFocus: (term) => {
|
||||
this.setActiveTerminal(term);
|
||||
@@ -38,6 +46,9 @@ export class TabSplitTree {
|
||||
onExit: () => {
|
||||
this.closeTerminal(termView.id);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
this.terminals.set(termView.id, {
|
||||
@@ -54,8 +65,8 @@ export class TabSplitTree {
|
||||
return termView;
|
||||
}
|
||||
|
||||
splitCurrent(direction = 'horizontal') {
|
||||
if (!this.activeTerminal) return;
|
||||
splitCurrent(direction = 'horizontal', customSpawnOptions = null) {
|
||||
if (!this.activeTerminal || this.activeTerminal.isAgent) return;
|
||||
const currentInfo = this.terminals.get(this.activeTerminal.id);
|
||||
if (!currentInfo) return;
|
||||
|
||||
@@ -79,7 +90,12 @@ export class TabSplitTree {
|
||||
splitWrapper.appendChild(divider);
|
||||
|
||||
// 添加新窗格
|
||||
const newTerm = this.addPane(splitWrapper, direction);
|
||||
const currentSpawn = currentInfo ? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args } : this.spawnOptions;
|
||||
const finalSpawn = {
|
||||
...currentSpawn,
|
||||
...(customSpawnOptions || {}),
|
||||
};
|
||||
const newTerm = this.addPane(splitWrapper, direction, finalSpawn);
|
||||
const newPane = this.terminals.get(newTerm.id).paneElement;
|
||||
newPane.style.flex = '1 1 0%';
|
||||
|
||||
@@ -91,6 +107,259 @@ export class TabSplitTree {
|
||||
this.fitAll();
|
||||
newTerm.focus();
|
||||
}, 50);
|
||||
|
||||
return newTerm;
|
||||
}
|
||||
|
||||
splitRoot(direction = 'horizontal', customSpawnOptions = null) {
|
||||
if (this.terminals.size === 0) return null;
|
||||
|
||||
// 创建新的顶级分屏容器,使新窗口竖向占满整个容器高度,对当前标签页整体左右分屏
|
||||
const newRoot = document.createElement('div');
|
||||
newRoot.className = `split-container ${direction}`;
|
||||
newRoot.style.flex = '1 1 0%';
|
||||
newRoot.style.width = '100%';
|
||||
newRoot.style.height = '100%';
|
||||
|
||||
const oldRoot = this.rootElement;
|
||||
oldRoot.style.flex = '1 1 0%';
|
||||
|
||||
this.container.replaceChild(newRoot, oldRoot);
|
||||
newRoot.appendChild(oldRoot);
|
||||
|
||||
// 添加顶级分割线
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
newRoot.appendChild(divider);
|
||||
|
||||
// 添加新窗格(Agent 终端)
|
||||
const currentInfo = this.activeTerminal ? this.terminals.get(this.activeTerminal.id) : null;
|
||||
const currentSpawn = currentInfo
|
||||
? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args }
|
||||
: this.spawnOptions;
|
||||
const finalSpawn = {
|
||||
...currentSpawn,
|
||||
...(customSpawnOptions || {}),
|
||||
};
|
||||
|
||||
const newTerm = this.addPane(newRoot, direction, finalSpawn);
|
||||
const newPane = this.terminals.get(newTerm.id).paneElement;
|
||||
newPane.style.flex = '1 1 0%';
|
||||
|
||||
// 更新 rootElement 为新容器
|
||||
this.rootElement = newRoot;
|
||||
|
||||
// 为分割线绑定拖拽调节事件
|
||||
this.setupDividerDrag(divider, oldRoot, newPane, direction);
|
||||
|
||||
// 刷新自适应尺寸与焦点
|
||||
setTimeout(() => {
|
||||
this.fitAll();
|
||||
newTerm.focus();
|
||||
}, 50);
|
||||
|
||||
return newTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在特化的 Agent 面板中打开 Agent。
|
||||
* 每个大标签页独占一个此类面板,多个 agent 在该面板内部以多标签页形式展示。
|
||||
*/
|
||||
openAgent(agent, customSpawnOptions = {}) {
|
||||
// 1. 如果该大标签页已经拥有 Agent 面板且仍然挂载在 DOM 中,直接在已有面板中新增/切换标签页
|
||||
if (this.agentPanel && this.agentPanel.paneElement.isConnected) {
|
||||
return this.agentPanel.openAgent(agent, customSpawnOptions);
|
||||
}
|
||||
|
||||
// 2. 如果尚未创建 Agent 面板,创建并在右侧分屏(竖向占满全高)
|
||||
this.agentPanel = new AgentPanel({
|
||||
tabSplitTree: this,
|
||||
onContextMenu: this.onContextMenu,
|
||||
onClose: () => {
|
||||
this.removeAgentPanel();
|
||||
},
|
||||
});
|
||||
|
||||
this.splitRootForAgentPanel(this.agentPanel);
|
||||
return this.agentPanel.openAgent(agent, customSpawnOptions);
|
||||
}
|
||||
|
||||
splitRootForAgentPanel(agentPanel) {
|
||||
if (this.terminals.size === 0) {
|
||||
this.rootElement.appendChild(agentPanel.paneElement);
|
||||
agentPanel.paneElement.style.flex = '1 1 0%';
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoot = document.createElement('div');
|
||||
newRoot.className = 'split-container horizontal';
|
||||
newRoot.style.flex = '1 1 0%';
|
||||
newRoot.style.width = '100%';
|
||||
newRoot.style.height = '100%';
|
||||
|
||||
const oldRoot = this.rootElement;
|
||||
oldRoot.style.flex = '1 1 0%';
|
||||
|
||||
this.container.replaceChild(newRoot, oldRoot);
|
||||
newRoot.appendChild(oldRoot);
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
newRoot.appendChild(divider);
|
||||
|
||||
newRoot.appendChild(agentPanel.paneElement);
|
||||
agentPanel.paneElement.style.flex = '1 1 0%';
|
||||
|
||||
this.rootElement = newRoot;
|
||||
this.setupDividerDrag(divider, oldRoot, agentPanel.paneElement, 'horizontal');
|
||||
|
||||
setTimeout(() => {
|
||||
this.fitAll();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
removeAgentPanel() {
|
||||
if (!this.agentPanel) return;
|
||||
const pane = this.agentPanel.paneElement;
|
||||
const parent = pane.parentElement;
|
||||
|
||||
// 移除相邻分割线
|
||||
const prev = pane.previousElementSibling;
|
||||
const next = pane.nextElementSibling;
|
||||
if (prev && prev.classList.contains('split-divider')) {
|
||||
prev.remove();
|
||||
} else if (next && next.classList.contains('split-divider')) {
|
||||
next.remove();
|
||||
}
|
||||
pane.remove();
|
||||
|
||||
if (parent === this.rootElement) {
|
||||
const remainingChildren = Array.from(this.rootElement.children).filter(
|
||||
(el) => !el.classList.contains('split-divider')
|
||||
);
|
||||
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
|
||||
const singleChild = remainingChildren[0];
|
||||
singleChild.style.flex = '1 1 0%';
|
||||
this.container.replaceChild(singleChild, this.rootElement);
|
||||
this.rootElement = singleChild;
|
||||
}
|
||||
}
|
||||
|
||||
this.agentPanel = null;
|
||||
|
||||
// 如果当前活跃终端在 Agent 面板上,焦点转移给剩余的常规终端
|
||||
if (!this.activeTerminal || !this.terminals.has(this.activeTerminal.id)) {
|
||||
if (this.terminals.size > 0) {
|
||||
const nextTerm = this.terminals.values().next().value.termView;
|
||||
this.setActiveTerminal(nextTerm);
|
||||
nextTerm.focus();
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.fitAll(), 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件浏览面板 (每个大标签页独占至多一个)
|
||||
*/
|
||||
openFilePanel(initialDir = null) {
|
||||
if (this.filePanel && this.filePanel.paneElement.isConnected) {
|
||||
if (initialDir) {
|
||||
this.filePanel.loadDirectory(initialDir);
|
||||
}
|
||||
return this.filePanel;
|
||||
}
|
||||
|
||||
this.filePanel = new FilePanel({
|
||||
tabSplitTree: this,
|
||||
initialDir,
|
||||
onClose: () => {
|
||||
this.removeFilePanel();
|
||||
},
|
||||
});
|
||||
|
||||
this.splitRootForFilePanel(this.filePanel);
|
||||
return this.filePanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文件浏览面板显示/隐藏
|
||||
*/
|
||||
toggleFilePanel(initialDir = null) {
|
||||
if (this.filePanel && this.filePanel.paneElement.isConnected) {
|
||||
this.filePanel.destroy();
|
||||
return null;
|
||||
}
|
||||
return this.openFilePanel(initialDir);
|
||||
}
|
||||
|
||||
splitRootForFilePanel(filePanel) {
|
||||
if (this.terminals.size === 0) {
|
||||
this.rootElement.insertBefore(filePanel.paneElement, this.rootElement.firstChild);
|
||||
filePanel.paneElement.style.width = '240px';
|
||||
filePanel.paneElement.style.flex = '0 0 240px';
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoot = document.createElement('div');
|
||||
newRoot.className = 'split-container horizontal';
|
||||
newRoot.style.flex = '1 1 0%';
|
||||
newRoot.style.width = '100%';
|
||||
newRoot.style.height = '100%';
|
||||
|
||||
const oldRoot = this.rootElement;
|
||||
oldRoot.style.flex = '1 1 0%';
|
||||
|
||||
this.container.replaceChild(newRoot, oldRoot);
|
||||
|
||||
// 文件浏览面板放置于当前终端区域最左侧
|
||||
newRoot.appendChild(filePanel.paneElement);
|
||||
filePanel.paneElement.style.width = '240px';
|
||||
filePanel.paneElement.style.flex = '0 0 240px';
|
||||
filePanel.paneElement.style.minWidth = '160px';
|
||||
filePanel.paneElement.style.maxWidth = '500px';
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
newRoot.appendChild(divider);
|
||||
|
||||
newRoot.appendChild(oldRoot);
|
||||
|
||||
this.rootElement = newRoot;
|
||||
this.setupDividerDrag(divider, filePanel.paneElement, oldRoot, 'horizontal');
|
||||
|
||||
setTimeout(() => {
|
||||
this.fitAll();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
removeFilePanel() {
|
||||
if (!this.filePanel) return;
|
||||
const pane = this.filePanel.paneElement;
|
||||
const parent = pane.parentElement;
|
||||
|
||||
const next = pane.nextElementSibling;
|
||||
const prev = pane.previousElementSibling;
|
||||
if (next && next.classList.contains('split-divider')) {
|
||||
next.remove();
|
||||
} else if (prev && prev.classList.contains('split-divider')) {
|
||||
prev.remove();
|
||||
}
|
||||
pane.remove();
|
||||
|
||||
if (parent === this.rootElement) {
|
||||
const remainingChildren = Array.from(this.rootElement.children).filter(
|
||||
(el) => !el.classList.contains('split-divider')
|
||||
);
|
||||
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
|
||||
const singleChild = remainingChildren[0];
|
||||
singleChild.style.flex = '1 1 0%';
|
||||
this.container.replaceChild(singleChild, this.rootElement);
|
||||
this.rootElement = singleChild;
|
||||
}
|
||||
}
|
||||
|
||||
this.filePanel = null;
|
||||
setTimeout(() => this.fitAll(), 40);
|
||||
}
|
||||
|
||||
setupDividerDrag(divider, firstElement, secondElement, direction) {
|
||||
@@ -151,6 +420,12 @@ export class TabSplitTree {
|
||||
const info = this.terminals.get(termId);
|
||||
if (!info) return;
|
||||
|
||||
// 如果是 Agent 终端,委托给 AgentPanel 关闭该 agent 标签
|
||||
if (info.termView.isAgent && this.agentPanel) {
|
||||
this.agentPanel.closeAgent(info.termView.agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
info.termView.destroy();
|
||||
const pane = info.paneElement;
|
||||
const parent = pane.parentElement;
|
||||
@@ -177,6 +452,28 @@ export class TabSplitTree {
|
||||
const singleChild = remainingChildren[0];
|
||||
singleChild.style.flex = parent.style.flex || '1 1 0%';
|
||||
parent.parentElement.replaceChild(singleChild, parent);
|
||||
} else if (remainingChildren.length === 0) {
|
||||
const pPrev = parent.previousElementSibling;
|
||||
const pNext = parent.nextElementSibling;
|
||||
if (pPrev && pPrev.classList.contains('split-divider')) {
|
||||
pPrev.remove();
|
||||
} else if (pNext && pNext.classList.contains('split-divider')) {
|
||||
pNext.remove();
|
||||
}
|
||||
parent.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果根容器中仅剩 1 个子元素且该子元素也是 split-container,消除顶层冗余包裹
|
||||
if (this.rootElement) {
|
||||
const remainingChildren = Array.from(this.rootElement.children).filter(
|
||||
(el) => !el.classList.contains('split-divider')
|
||||
);
|
||||
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
|
||||
const singleChild = remainingChildren[0];
|
||||
singleChild.style.flex = '1 1 0%';
|
||||
this.container.replaceChild(singleChild, this.rootElement);
|
||||
this.rootElement = singleChild;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +496,14 @@ export class TabSplitTree {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.agentPanel) {
|
||||
this.agentPanel.destroy();
|
||||
this.agentPanel = null;
|
||||
}
|
||||
if (this.filePanel) {
|
||||
this.filePanel.destroy();
|
||||
this.filePanel = null;
|
||||
}
|
||||
for (const info of this.terminals.values()) {
|
||||
info.termView.destroy();
|
||||
}
|
||||
|
||||
+130
-19
@@ -4,13 +4,64 @@ export class TabManager {
|
||||
constructor(options = {}) {
|
||||
this.tabBar = options.tabBar;
|
||||
this.viewport = options.viewport;
|
||||
this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
|
||||
this.onActiveChange = options.onActiveChange || (() => {});
|
||||
this.onTabListChange = options.onTabListChange || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.tabs = new Map(); // tabId -> { tabElement, viewElement, splitTree, title }
|
||||
this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title }
|
||||
this.activeTabId = null;
|
||||
this.activeWorkspaceId = 'default';
|
||||
this.workspaceLastTab = new Map(); // workspaceId -> lastActiveTabId
|
||||
}
|
||||
|
||||
createTab(title = 'bash') {
|
||||
setActiveWorkspace(workspaceId, defaultSpawnOptions = {}) {
|
||||
this.activeWorkspaceId = workspaceId;
|
||||
|
||||
// 更新各标签在顶部标签栏中的可见性
|
||||
for (const [id, tab] of this.tabs.entries()) {
|
||||
const match = tab.workspaceId === workspaceId;
|
||||
tab.tabElement.style.display = match ? '' : 'none';
|
||||
if (!match) {
|
||||
tab.tabElement.classList.remove('active');
|
||||
tab.viewElement.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// 确定此工作区要激活的标签页
|
||||
const workspaceTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === workspaceId);
|
||||
if (workspaceTabs.length === 0) {
|
||||
// 当前工作区无标签页时自动创建
|
||||
this.createTab({
|
||||
workspaceId,
|
||||
title: defaultSpawnOptions.title || 'bash',
|
||||
cwd: defaultSpawnOptions.cwd,
|
||||
shell: defaultSpawnOptions.shell,
|
||||
args: defaultSpawnOptions.args,
|
||||
});
|
||||
} else {
|
||||
let targetId = this.workspaceLastTab.get(workspaceId);
|
||||
if (!targetId || !this.tabs.has(targetId)) {
|
||||
targetId = workspaceTabs[workspaceTabs.length - 1].tabId;
|
||||
}
|
||||
this.switchTab(targetId);
|
||||
}
|
||||
|
||||
this.onTabListChange();
|
||||
}
|
||||
|
||||
createTab(options = {}) {
|
||||
const workspaceId = typeof options === 'object' && options.workspaceId ? options.workspaceId : this.activeWorkspaceId;
|
||||
const defaultSpawn = this.getSpawnOptions(workspaceId) || {};
|
||||
const title = typeof options === 'string' ? options : (options.title || defaultSpawn.title || 'bash');
|
||||
const spawnOptions = typeof options === 'object' && options.spawnOptions ? options.spawnOptions : (
|
||||
typeof options === 'object' ? {
|
||||
cwd: options.cwd !== undefined ? options.cwd : defaultSpawn.cwd,
|
||||
shell: options.shell !== undefined ? options.shell : defaultSpawn.shell,
|
||||
args: options.args !== undefined ? options.args : defaultSpawn.args,
|
||||
} : defaultSpawn
|
||||
);
|
||||
|
||||
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
|
||||
|
||||
// 1. 创建标签页 DOM
|
||||
@@ -34,6 +85,7 @@ export class TabManager {
|
||||
const splitTree = new TabSplitTree({
|
||||
container: viewElement,
|
||||
tabId,
|
||||
spawnOptions,
|
||||
onActiveTerminalChange: (term) => {
|
||||
if (this.activeTabId === tabId) {
|
||||
this.onActiveChange(term);
|
||||
@@ -45,6 +97,9 @@ export class TabManager {
|
||||
onEmpty: () => {
|
||||
this.closeTab(tabId);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 标签页点击与关闭事件
|
||||
@@ -56,22 +111,44 @@ export class TabManager {
|
||||
}
|
||||
});
|
||||
|
||||
// 根据当前工作区控制是否在顶部标签栏可见
|
||||
if (workspaceId !== this.activeWorkspaceId) {
|
||||
tabElement.style.display = 'none';
|
||||
}
|
||||
|
||||
this.tabBar.appendChild(tabElement);
|
||||
|
||||
this.tabs.set(tabId, {
|
||||
tabId,
|
||||
workspaceId,
|
||||
tabElement,
|
||||
viewElement,
|
||||
splitTree,
|
||||
title,
|
||||
});
|
||||
|
||||
if (workspaceId === this.activeWorkspaceId) {
|
||||
this.switchTab(tabId);
|
||||
}
|
||||
|
||||
this.onTabListChange();
|
||||
return tabId;
|
||||
}
|
||||
|
||||
switchTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
const targetTab = this.tabs.get(tabId);
|
||||
if (!targetTab) return;
|
||||
|
||||
// 如果该标签属于不同工作区,同步更新 activeWorkspaceId
|
||||
if (targetTab.workspaceId !== this.activeWorkspaceId) {
|
||||
this.activeWorkspaceId = targetTab.workspaceId;
|
||||
for (const [id, tab] of this.tabs.entries()) {
|
||||
const match = tab.workspaceId === targetTab.workspaceId;
|
||||
tab.tabElement.style.display = match ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceLastTab.set(targetTab.workspaceId, tabId);
|
||||
|
||||
for (const [id, tab] of this.tabs.entries()) {
|
||||
const isActive = id === tabId;
|
||||
@@ -88,28 +165,35 @@ export class TabManager {
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
|
||||
this.onTabListChange();
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const wsId = tab.workspaceId;
|
||||
tab.splitTree.destroy();
|
||||
tab.tabElement.remove();
|
||||
tab.viewElement.remove();
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// 如果还有其他标签页,切换到上一个或下一个
|
||||
if (this.tabs.size > 0) {
|
||||
const remainingIds = Array.from(this.tabs.keys());
|
||||
const nextId = remainingIds[remainingIds.length - 1];
|
||||
// 寻找同一工作区的其他标签
|
||||
const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId);
|
||||
if (this.activeTabId === tabId) {
|
||||
if (remainingWsTabs.length > 0) {
|
||||
const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId;
|
||||
this.switchTab(nextId);
|
||||
} else {
|
||||
// 没有任何标签页时自动新建一个
|
||||
this.createTab();
|
||||
// 当前工作区没有其他标签页了,自动新建一个
|
||||
this.createTab({ workspaceId: wsId });
|
||||
}
|
||||
}
|
||||
|
||||
this.onTabListChange();
|
||||
}
|
||||
|
||||
updateTabTitle(tabId, newTitle) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab && newTitle) {
|
||||
@@ -118,6 +202,7 @@ export class TabManager {
|
||||
if (titleEl) {
|
||||
titleEl.textContent = newTitle;
|
||||
}
|
||||
this.onTabListChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,19 +215,45 @@ export class TabManager {
|
||||
return activeTab ? activeTab.splitTree.activeTerminal : null;
|
||||
}
|
||||
|
||||
getTabsForWorkspace(workspaceId) {
|
||||
return Array.from(this.tabs.values())
|
||||
.filter((t) => t.workspaceId === workspaceId)
|
||||
.map((t) => ({
|
||||
tabId: t.tabId,
|
||||
title: t.title,
|
||||
isActive: t.tabId === this.activeTabId,
|
||||
}));
|
||||
}
|
||||
|
||||
nextTab() {
|
||||
const ids = Array.from(this.tabs.keys());
|
||||
if (ids.length <= 1) return;
|
||||
const currentIndex = ids.indexOf(this.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % ids.length;
|
||||
this.switchTab(ids[nextIndex]);
|
||||
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||
if (wsTabs.length <= 1) return;
|
||||
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % wsTabs.length;
|
||||
this.switchTab(wsTabs[nextIndex].tabId);
|
||||
}
|
||||
|
||||
prevTab() {
|
||||
const ids = Array.from(this.tabs.keys());
|
||||
if (ids.length <= 1) return;
|
||||
const currentIndex = ids.indexOf(this.activeTabId);
|
||||
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
||||
this.switchTab(ids[prevIndex]);
|
||||
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||
if (wsTabs.length <= 1) return;
|
||||
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||
const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
|
||||
this.switchTab(wsTabs[prevIndex].tabId);
|
||||
}
|
||||
|
||||
toggleFileBrowser(tabId = null) {
|
||||
const tab = tabId ? this.tabs.get(tabId) : this.getActiveTab();
|
||||
if (tab && tab.splitTree) {
|
||||
return tab.splitTree.toggleFilePanel();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
openFileBrowser(tabId = null, dir = null) {
|
||||
const tab = tabId ? this.tabs.get(tabId) : this.getActiveTab();
|
||||
if (tab && tab.splitTree) {
|
||||
return tab.splitTree.openFilePanel(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ export class TerminalView {
|
||||
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
|
||||
this.container = options.container;
|
||||
this.cwd = options.cwd || '';
|
||||
this.shell = options.shell || undefined;
|
||||
this.args = options.args || [];
|
||||
this.title = options.title || 'bash';
|
||||
this.onTitleChange = options.onTitleChange || (() => {});
|
||||
this.onFocus = options.onFocus || (() => {});
|
||||
this.onExit = options.onExit || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.title = 'bash';
|
||||
this.isFocused = false;
|
||||
|
||||
this.initTerminal();
|
||||
@@ -41,7 +44,7 @@ export class TerminalView {
|
||||
theme: currentTheme.xterm,
|
||||
macOptionIsMeta: true,
|
||||
rightClickSelectsWord: true,
|
||||
convertEol: true,
|
||||
convertEol: false,
|
||||
});
|
||||
|
||||
// 插件初始化
|
||||
@@ -58,9 +61,24 @@ export class TerminalView {
|
||||
this.terminalWrapper = document.createElement('div');
|
||||
this.terminalWrapper.className = 'terminal-wrapper';
|
||||
this.container.appendChild(this.terminalWrapper);
|
||||
this.element = this.terminalWrapper;
|
||||
|
||||
this.xterm.open(this.terminalWrapper);
|
||||
|
||||
// 右键上下文菜单
|
||||
this.terminalWrapper.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.focus();
|
||||
if (typeof this.onContextMenu === 'function') {
|
||||
this.onContextMenu(e, this);
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('terminal-contextmenu', {
|
||||
detail: { event: e, termView: this }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 启用 WebGL 硬件加速 (GPU 渲染)
|
||||
try {
|
||||
this.webglAddon = new WebglAddon();
|
||||
@@ -73,6 +91,49 @@ export class TerminalView {
|
||||
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
|
||||
}
|
||||
|
||||
// 自定义按键过滤:允许应用级全局快捷键穿透终端聚焦拦截
|
||||
this.xterm.attachCustomKeyEventHandler((event) => {
|
||||
// Ctrl+B: 侧边栏
|
||||
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+C: 复制选中内容
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'c') {
|
||||
if (event.type === 'keydown') {
|
||||
this.copySelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+V: 粘贴剪贴板内容
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'v') {
|
||||
if (event.type === 'keydown') {
|
||||
this.pasteFromClipboard();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Shift+T / W / E / O / F / P
|
||||
if (event.ctrlKey && event.shiftKey && ['t', 'w', 'e', 'o', 'f', 'p'].includes(event.key.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
// F1: 命令面板
|
||||
if (event.key === 'F1') {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+,: 设置
|
||||
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key === ',') {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return false;
|
||||
}
|
||||
// Alt+1 .. Alt+9
|
||||
if (event.altKey && event.key >= '1' && event.key <= '9') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 监听输入
|
||||
this.xterm.onData((data) => {
|
||||
window.terminalAPI.writePty(this.id, data);
|
||||
@@ -92,6 +153,17 @@ export class TerminalView {
|
||||
this.onFocus(this);
|
||||
});
|
||||
|
||||
// 监听 PTY 退出并触发 onExit 回调
|
||||
if (window.terminalAPI?.onPtyExit) {
|
||||
this.unsubscribeExit = window.terminalAPI.onPtyExit(({ id, exitCode }) => {
|
||||
if (id === this.id) {
|
||||
if (typeof this.onExit === 'function') {
|
||||
this.onExit(exitCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 主题监听
|
||||
this.unsubscribeTheme = themeManager.onThemeChange((theme) => {
|
||||
this.xterm.options.theme = theme.xterm;
|
||||
@@ -113,11 +185,20 @@ export class TerminalView {
|
||||
cols,
|
||||
rows,
|
||||
cwd: this.cwd,
|
||||
shell: this.shell,
|
||||
args: this.args,
|
||||
}).then((res) => {
|
||||
if (res && res.error) {
|
||||
this.xterm.writeln(`\r\n\x1b[31m[创建终端进程失败: ${res.error}]\x1b[0m\r\n`);
|
||||
}
|
||||
if (res && res.shell) {
|
||||
if (!this.title || this.title === 'bash') {
|
||||
this.title = res.shell.split('/').pop() || 'bash';
|
||||
this.onTitleChange(this.title);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
this.xterm.writeln(`\r\n\x1b[31m[启动终端异常: ${err.message}]\x1b[0m\r\n`);
|
||||
});
|
||||
|
||||
// 聚焦
|
||||
@@ -142,6 +223,40 @@ export class TerminalView {
|
||||
this.onFocus(this);
|
||||
}
|
||||
|
||||
async copySelection() {
|
||||
const selection = this.xterm.getSelection();
|
||||
if (!selection) return false;
|
||||
try {
|
||||
if (window.terminalAPI?.writeClipboard) {
|
||||
window.terminalAPI.writeClipboard(selection);
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to copy selection to clipboard:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async pasteFromClipboard() {
|
||||
try {
|
||||
let text = '';
|
||||
if (window.terminalAPI?.readClipboard) {
|
||||
text = await window.terminalAPI.readClipboard();
|
||||
} else if (navigator.clipboard?.readText) {
|
||||
text = await navigator.clipboard.readText();
|
||||
}
|
||||
if (text) {
|
||||
this.xterm.paste(text);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to paste from clipboard:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
updateSettings(settings) {
|
||||
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
|
||||
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
|
||||
@@ -152,6 +267,10 @@ export class TerminalView {
|
||||
|
||||
destroy() {
|
||||
if (this.unsubscribeTheme) this.unsubscribeTheme();
|
||||
if (this.unsubscribeExit) {
|
||||
this.unsubscribeExit();
|
||||
this.unsubscribeExit = null;
|
||||
}
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
window.terminalAPI.killPty(this.id);
|
||||
this.xterm.dispose();
|
||||
|
||||
@@ -140,6 +140,146 @@ export const THEMES = {
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
'catppuccin-latte': {
|
||||
name: 'Catppuccin Latte (浅色)',
|
||||
themeName: 'catppuccin-latte',
|
||||
xterm: {
|
||||
background: '#eff1f5',
|
||||
foreground: '#4c4f69',
|
||||
cursor: '#dc8a78',
|
||||
cursorAccent: '#eff1f5',
|
||||
selectionBackground: '#ccd0da',
|
||||
selectionForeground: '#4c4f69',
|
||||
black: '#5c5f77',
|
||||
red: '#d20f39',
|
||||
green: '#40a02b',
|
||||
yellow: '#df8e1d',
|
||||
blue: '#1e66f5',
|
||||
magenta: '#ea76cb',
|
||||
cyan: '#179299',
|
||||
white: '#acb0be',
|
||||
brightBlack: '#6c6f85',
|
||||
brightRed: '#d20f39',
|
||||
brightGreen: '#40a02b',
|
||||
brightYellow: '#df8e1d',
|
||||
brightBlue: '#1e66f5',
|
||||
brightMagenta: '#ea76cb',
|
||||
brightCyan: '#179299',
|
||||
brightWhite: '#bcc0cc',
|
||||
},
|
||||
},
|
||||
'github-light': {
|
||||
name: 'GitHub Light (浅色)',
|
||||
themeName: 'github-light',
|
||||
xterm: {
|
||||
background: '#ffffff',
|
||||
foreground: '#24292f',
|
||||
cursor: '#0969da',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#b6e3ff',
|
||||
selectionForeground: '#24292f',
|
||||
black: '#24292f',
|
||||
red: '#cf222e',
|
||||
green: '#1a7f37',
|
||||
yellow: '#9a6700',
|
||||
blue: '#0969da',
|
||||
magenta: '#8250df',
|
||||
cyan: '#1b7c83',
|
||||
white: '#6e7781',
|
||||
brightBlack: '#57606a',
|
||||
brightRed: '#a40e26',
|
||||
brightGreen: '#116329',
|
||||
brightYellow: '#633c01',
|
||||
brightBlue: '#0550ae',
|
||||
brightMagenta: '#6639ba',
|
||||
brightCyan: '#114c53',
|
||||
brightWhite: '#8c959f',
|
||||
},
|
||||
},
|
||||
'tokyo-night-day': {
|
||||
name: 'Tokyo Night Day (浅色)',
|
||||
themeName: 'tokyo-night-day',
|
||||
xterm: {
|
||||
background: '#e1e2e7',
|
||||
foreground: '#3760bf',
|
||||
cursor: '#3760bf',
|
||||
cursorAccent: '#e1e2e7',
|
||||
selectionBackground: '#b7c1e3',
|
||||
selectionForeground: '#3760bf',
|
||||
black: '#e9e9ed',
|
||||
red: '#f52a65',
|
||||
green: '#587539',
|
||||
yellow: '#8c6c3e',
|
||||
blue: '#2e7de9',
|
||||
magenta: '#9854f1',
|
||||
cyan: '#007197',
|
||||
white: '#6172b0',
|
||||
brightBlack: '#a1a6c5',
|
||||
brightRed: '#f52a65',
|
||||
brightGreen: '#587539',
|
||||
brightYellow: '#8c6c3e',
|
||||
brightBlue: '#2e7de9',
|
||||
brightMagenta: '#9854f1',
|
||||
brightCyan: '#007197',
|
||||
brightWhite: '#3760bf',
|
||||
},
|
||||
},
|
||||
'solarized-light': {
|
||||
name: 'Solarized Light (浅色)',
|
||||
themeName: 'solarized-light',
|
||||
xterm: {
|
||||
background: '#fdf6e3',
|
||||
foreground: '#657b83',
|
||||
cursor: '#586e75',
|
||||
cursorAccent: '#fdf6e3',
|
||||
selectionBackground: '#eee8d5',
|
||||
selectionForeground: '#586e75',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#002b36',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3',
|
||||
},
|
||||
},
|
||||
'one-light': {
|
||||
name: 'One Light (浅色)',
|
||||
themeName: 'one-light',
|
||||
xterm: {
|
||||
background: '#fafafa',
|
||||
foreground: '#383a42',
|
||||
cursor: '#526fff',
|
||||
cursorAccent: '#fafafa',
|
||||
selectionBackground: '#e5e5e6',
|
||||
selectionForeground: '#383a42',
|
||||
black: '#383a42',
|
||||
red: '#e45649',
|
||||
green: '#50a14f',
|
||||
yellow: '#c18401',
|
||||
blue: '#4078f2',
|
||||
magenta: '#a626a4',
|
||||
cyan: '#0184bc',
|
||||
white: '#a0a1a7',
|
||||
brightBlack: '#4f525d',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* 工作区管理器 (WorkspaceManager)
|
||||
* 支持管理多个工作环境(本地 PC、SSH 远程开发机、串口/开发板)
|
||||
* 每个工作区拥有独立的标签页会话集合,并提供可展开/收起的高性能左侧栏
|
||||
*/
|
||||
|
||||
export const WORKSPACE_TYPES = {
|
||||
LOCAL: 'local',
|
||||
SSH: 'ssh',
|
||||
SERIAL: 'serial',
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
local: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>`,
|
||||
ssh: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>`,
|
||||
serial: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
|
||||
<rect x="9" y="9" width="6" height="6"></rect>
|
||||
<line x1="9" y1="1" x2="9" y2="4"></line>
|
||||
<line x1="15" y1="1" x2="15" y2="4"></line>
|
||||
<line x1="9" y1="20" x2="9" y2="23"></line>
|
||||
<line x1="15" y1="20" x2="15" y2="23"></line>
|
||||
<line x1="20" y1="9" x2="23" y2="9"></line>
|
||||
<line x1="20" y1="14" x2="23" y2="14"></line>
|
||||
<line x1="1" y1="9" x2="4" y2="9"></line>
|
||||
<line x1="1" y1="14" x2="4" y2="14"></line>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACES = [
|
||||
{
|
||||
id: 'ws_local',
|
||||
name: '本机电脑 (Local PC)',
|
||||
type: WORKSPACE_TYPES.LOCAL,
|
||||
desc: '本地 Linux 环境与项目开发',
|
||||
cwd: '~',
|
||||
shell: '',
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
id: 'ws_ssh',
|
||||
name: '开发服务器 (SSH)',
|
||||
type: WORKSPACE_TYPES.SSH,
|
||||
desc: '远程 Linux 开发机',
|
||||
host: '192.168.1.100',
|
||||
user: 'dev',
|
||||
port: 22,
|
||||
extraArgs: '',
|
||||
},
|
||||
{
|
||||
id: 'ws_serial',
|
||||
name: '嵌入式开发板 (Serial)',
|
||||
type: WORKSPACE_TYPES.SERIAL,
|
||||
desc: '串口终端 /dev/ttyUSB0 (115200)',
|
||||
device: '/dev/ttyUSB0',
|
||||
baudRate: 115200,
|
||||
tool: 'picocom',
|
||||
},
|
||||
];
|
||||
|
||||
export class WorkspaceManager {
|
||||
constructor(options = {}) {
|
||||
this.tabManager = options.tabManager;
|
||||
this.sidebar = document.getElementById('workspace-sidebar');
|
||||
this.listContainer = document.getElementById('sidebar-workspace-list');
|
||||
this.modalOverlay = document.getElementById('workspace-modal-overlay');
|
||||
|
||||
this.workspaces = [];
|
||||
this.activeWorkspaceId = 'ws_local';
|
||||
this.isCollapsed = true;
|
||||
this.editingWorkspaceId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadState();
|
||||
this.initEventListeners();
|
||||
this.renderSidebar();
|
||||
|
||||
const toggleBtn = document.getElementById('btn-toggle-sidebar');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('active', !this.isCollapsed);
|
||||
}
|
||||
const appBody = document.querySelector('.app-body');
|
||||
if (appBody && this.isCollapsed) {
|
||||
appBody.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// 订阅标签页状态变更,以便实时刷新侧边栏标签列表
|
||||
this.tabManager.onTabListChange = () => {
|
||||
this.renderSidebar();
|
||||
};
|
||||
|
||||
// 激活初始工作区
|
||||
this.switchWorkspace(this.activeWorkspaceId, true);
|
||||
}
|
||||
|
||||
loadState() {
|
||||
try {
|
||||
const saved = localStorage.getItem('hatty:workspaces');
|
||||
if (saved) {
|
||||
this.workspaces = JSON.parse(saved);
|
||||
} else {
|
||||
this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES));
|
||||
this.saveWorkspaces();
|
||||
}
|
||||
|
||||
const savedActive = localStorage.getItem('hatty:active-workspace');
|
||||
if (savedActive && this.workspaces.some((w) => w.id === savedActive)) {
|
||||
this.activeWorkspaceId = savedActive;
|
||||
} else if (this.workspaces.length > 0) {
|
||||
this.activeWorkspaceId = this.workspaces[0].id;
|
||||
}
|
||||
|
||||
// 打开程序时,工作区默认折叠起来
|
||||
this.isCollapsed = true;
|
||||
if (this.sidebar) {
|
||||
this.sidebar.classList.add('collapsed');
|
||||
}
|
||||
document.querySelector('.app-body')?.classList.add('sidebar-collapsed');
|
||||
document.getElementById('app')?.classList.add('sidebar-collapsed');
|
||||
document.getElementById('titlebar-left')?.classList.add('sidebar-collapsed');
|
||||
} catch (e) {
|
||||
console.warn('Failed to load workspace state:', e);
|
||||
this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES));
|
||||
}
|
||||
}
|
||||
|
||||
saveWorkspaces() {
|
||||
try {
|
||||
localStorage.setItem('hatty:workspaces', JSON.stringify(this.workspaces));
|
||||
localStorage.setItem('hatty:active-workspace', this.activeWorkspaceId);
|
||||
localStorage.setItem('hatty:sidebar-collapsed', String(this.isCollapsed));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save workspaces:', e);
|
||||
}
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// 侧边栏折叠/展开按钮
|
||||
document.getElementById('btn-toggle-sidebar')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleSidebar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-collapse-sidebar')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.setCollapsed(true);
|
||||
});
|
||||
|
||||
// 新建工作区按钮
|
||||
document.getElementById('btn-add-workspace')?.addEventListener('click', () => {
|
||||
this.openCreateModal();
|
||||
});
|
||||
|
||||
document.getElementById('btn-footer-add-workspace')?.addEventListener('click', () => {
|
||||
this.openCreateModal();
|
||||
});
|
||||
|
||||
// 模态框按钮
|
||||
document.getElementById('btn-close-workspace-modal')?.addEventListener('click', () => {
|
||||
this.closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('btn-cancel-workspace')?.addEventListener('click', () => {
|
||||
this.closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('btn-save-workspace')?.addEventListener('click', () => {
|
||||
this.saveModalWorkspace();
|
||||
});
|
||||
|
||||
this.modalOverlay?.addEventListener('click', (e) => {
|
||||
if (e.target === this.modalOverlay) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
this.setCollapsed(!this.isCollapsed);
|
||||
}
|
||||
|
||||
setCollapsed(collapsed) {
|
||||
this.isCollapsed = collapsed;
|
||||
if (this.sidebar) {
|
||||
this.sidebar.classList.toggle('collapsed', collapsed);
|
||||
}
|
||||
const appBody = document.querySelector('.app-body');
|
||||
if (appBody) {
|
||||
appBody.classList.toggle('sidebar-collapsed', collapsed);
|
||||
}
|
||||
const app = document.getElementById('app');
|
||||
if (app) {
|
||||
app.classList.toggle('sidebar-collapsed', collapsed);
|
||||
}
|
||||
const titlebarLeft = document.getElementById('titlebar-left');
|
||||
if (titlebarLeft) {
|
||||
titlebarLeft.classList.toggle('sidebar-collapsed', collapsed);
|
||||
}
|
||||
const toggleBtn = document.getElementById('btn-toggle-sidebar');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('active', !collapsed);
|
||||
}
|
||||
this.saveWorkspaces();
|
||||
|
||||
// 触发当前终端自适应尺寸
|
||||
setTimeout(() => {
|
||||
const activeTerm = this.tabManager.getActiveTerminal();
|
||||
if (activeTerm) {
|
||||
activeTerm.fit();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
getActiveWorkspace() {
|
||||
return this.workspaces.find((w) => w.id === this.activeWorkspaceId) || this.workspaces[0];
|
||||
}
|
||||
|
||||
getSpawnOptions(workspaceId) {
|
||||
const ws = this.workspaces.find((w) => w.id === workspaceId) || this.getActiveWorkspace();
|
||||
if (!ws) return {};
|
||||
|
||||
if (ws.type === WORKSPACE_TYPES.SSH) {
|
||||
const targetHost = ws.user ? `${ws.user}@${ws.host}` : ws.host;
|
||||
const portArgs = ws.port && Number(ws.port) !== 22 ? ['-p', String(ws.port)] : [];
|
||||
const extra = ws.extraArgs ? ws.extraArgs.split(' ').filter(Boolean) : [];
|
||||
const sshCmd = `ssh ${[...portArgs, ...extra, targetHost].join(' ')}`;
|
||||
return {
|
||||
shell: '/bin/bash',
|
||||
args: ['-c', `${sshCmd} || (echo -e "\\n\\x1b[33m[SSH 会话断开或连接失败,已保留本地交互终端]\\x1b[0m" && exec bash)`],
|
||||
title: `ssh: ${ws.name || targetHost}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ws.type === WORKSPACE_TYPES.SERIAL) {
|
||||
const baud = String(ws.baudRate || 115200);
|
||||
const dev = ws.device || '/dev/ttyUSB0';
|
||||
const tool = ws.tool || 'picocom';
|
||||
return {
|
||||
shell: '/bin/bash',
|
||||
args: ['-c', `which ${tool} >/dev/null 2>&1 && ${tool} -b ${baud} ${dev} || (echo -e "\\n\\x1b[33m[串口工具 ${tool} 未安装或 ${dev} 无法连接,已保留本地交互终端]\\x1b[0m" && exec bash)`],
|
||||
title: `serial: ${dev}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 本地主机
|
||||
return {
|
||||
cwd: ws.cwd || '~',
|
||||
shell: ws.shell || undefined,
|
||||
args: ws.args || [],
|
||||
title: ws.name || 'bash',
|
||||
};
|
||||
}
|
||||
|
||||
switchWorkspace(workspaceId, isInit = false) {
|
||||
const ws = this.workspaces.find((w) => w.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
this.activeWorkspaceId = workspaceId;
|
||||
this.saveWorkspaces();
|
||||
|
||||
// 切换 TabManager 的活跃工作区
|
||||
const spawnOpts = this.getSpawnOptions(workspaceId);
|
||||
this.tabManager.setActiveWorkspace(workspaceId, spawnOpts);
|
||||
|
||||
this.renderSidebar();
|
||||
|
||||
// 更新顶部标题栏与底部状态栏信息
|
||||
const titlebarWsName = document.getElementById('titlebar-ws-name');
|
||||
if (titlebarWsName) {
|
||||
titlebarWsName.textContent = ws.name;
|
||||
}
|
||||
|
||||
const statusHost = document.getElementById('status-host');
|
||||
if (statusHost) {
|
||||
if (ws.type === WORKSPACE_TYPES.SSH) {
|
||||
statusHost.textContent = `SSH: ${ws.user ? ws.user + '@' : ''}${ws.host}`;
|
||||
} else if (ws.type === WORKSPACE_TYPES.SERIAL) {
|
||||
statusHost.textContent = `Serial: ${ws.device} (${ws.baudRate})`;
|
||||
} else {
|
||||
statusHost.textContent = `Local: ${ws.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderSidebar() {
|
||||
if (!this.listContainer) return;
|
||||
this.listContainer.innerHTML = '';
|
||||
|
||||
for (const ws of this.workspaces) {
|
||||
const isActive = ws.id === this.activeWorkspaceId;
|
||||
const card = document.createElement('div');
|
||||
card.className = `workspace-card ${isActive ? 'active' : ''}`;
|
||||
|
||||
const iconSvg = ICONS[ws.type] || ICONS.local;
|
||||
const tabs = this.tabManager.getTabsForWorkspace(ws.id);
|
||||
const tabCountText = `${tabs.length} 标签`;
|
||||
|
||||
let subText = '';
|
||||
if (ws.type === WORKSPACE_TYPES.SSH) {
|
||||
subText = `${ws.user ? ws.user + '@' : ''}${ws.host}:${ws.port || 22}`;
|
||||
} else if (ws.type === WORKSPACE_TYPES.SERIAL) {
|
||||
subText = `${ws.device} @ ${ws.baudRate}`;
|
||||
} else {
|
||||
subText = ws.cwd || '~';
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="ws-card-header">
|
||||
<div class="ws-card-info">
|
||||
<div class="ws-icon">${iconSvg}</div>
|
||||
<div class="ws-meta">
|
||||
<span class="ws-name" title="${ws.name}">${ws.name}</span>
|
||||
<span class="ws-badge">${tabCountText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-actions">
|
||||
<button class="btn-icon ws-btn-edit" title="编辑工作区">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
${
|
||||
this.workspaces.length > 1
|
||||
? `<button class="btn-icon ws-btn-del" title="删除工作区">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<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"></path>
|
||||
</svg>
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-desc" title="${subText}">${subText}</div>
|
||||
`;
|
||||
|
||||
// 绑定编辑和删除按钮
|
||||
card.querySelector('.ws-btn-edit')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.openEditModal(ws.id);
|
||||
});
|
||||
|
||||
card.querySelector('.ws-btn-del')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.deleteWorkspace(ws.id);
|
||||
});
|
||||
|
||||
// 点击工作区卡片切换工作区
|
||||
card.addEventListener('click', () => {
|
||||
if (ws.id !== this.activeWorkspaceId) {
|
||||
this.switchWorkspace(ws.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果是当前选中的活跃工作区,展开展示其中的标签页列表
|
||||
if (isActive) {
|
||||
const tabsContainer = document.createElement('div');
|
||||
tabsContainer.className = 'ws-tabs-container';
|
||||
tabsContainer.innerHTML = `<div class="ws-tabs-title">已开启会话 (${tabs.length})</div>`;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const tabItem = document.createElement('div');
|
||||
tabItem.className = `ws-tab-item ${tab.isActive ? 'active' : ''}`;
|
||||
tabItem.innerHTML = `
|
||||
<div class="ws-tab-left">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span class="ws-tab-title" title="${tab.title}">${tab.title}</span>
|
||||
</div>
|
||||
<span class="ws-tab-close" title="关闭">×</span>
|
||||
`;
|
||||
|
||||
tabItem.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('ws-tab-close')) {
|
||||
e.stopPropagation();
|
||||
this.tabManager.closeTab(tab.tabId);
|
||||
} else {
|
||||
this.tabManager.switchTab(tab.tabId);
|
||||
}
|
||||
});
|
||||
|
||||
tabsContainer.appendChild(tabItem);
|
||||
}
|
||||
|
||||
const addTabBtn = document.createElement('button');
|
||||
addTabBtn.className = 'ws-add-tab-btn';
|
||||
addTabBtn.innerHTML = `
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span>新建 ${ws.name} 标签页</span>
|
||||
`;
|
||||
addTabBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const spawnOpts = this.getSpawnOptions(ws.id);
|
||||
this.tabManager.createTab({
|
||||
workspaceId: ws.id,
|
||||
title: spawnOpts.title,
|
||||
cwd: spawnOpts.cwd,
|
||||
shell: spawnOpts.shell,
|
||||
args: spawnOpts.args,
|
||||
});
|
||||
});
|
||||
|
||||
tabsContainer.appendChild(addTabBtn);
|
||||
card.appendChild(tabsContainer);
|
||||
}
|
||||
|
||||
this.listContainer.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
openCreateModal() {
|
||||
this.editingWorkspaceId = null;
|
||||
const titleEl = document.getElementById('workspace-modal-title');
|
||||
if (titleEl) titleEl.textContent = '新建工作区 (PC / SSH / 开发板)';
|
||||
|
||||
this.renderModalForm({
|
||||
name: '',
|
||||
type: WORKSPACE_TYPES.LOCAL,
|
||||
cwd: '~',
|
||||
host: '',
|
||||
user: '',
|
||||
port: 22,
|
||||
device: '/dev/ttyUSB0',
|
||||
baudRate: 115200,
|
||||
});
|
||||
|
||||
this.modalOverlay?.classList.add('active');
|
||||
}
|
||||
|
||||
openEditModal(workspaceId) {
|
||||
const ws = this.workspaces.find((w) => w.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
this.editingWorkspaceId = workspaceId;
|
||||
const titleEl = document.getElementById('workspace-modal-title');
|
||||
if (titleEl) titleEl.textContent = `编辑工作区 - ${ws.name}`;
|
||||
|
||||
this.renderModalForm(ws);
|
||||
this.modalOverlay?.classList.add('active');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.modalOverlay?.classList.remove('active');
|
||||
this.editingWorkspaceId = null;
|
||||
}
|
||||
|
||||
renderModalForm(data) {
|
||||
const body = document.getElementById('workspace-modal-body');
|
||||
if (!body) return;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">工作区类型</span>
|
||||
<span class="setting-desc">选择此工作区的连接目标与运行环境</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-selector-group" id="ws-type-group">
|
||||
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.LOCAL ? 'selected' : ''}" data-type="local">
|
||||
${ICONS.local}
|
||||
<span>本机电脑 (Local)</span>
|
||||
</div>
|
||||
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.SSH ? 'selected' : ''}" data-type="ssh">
|
||||
${ICONS.ssh}
|
||||
<span>SSH 远程机</span>
|
||||
</div>
|
||||
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.SERIAL ? 'selected' : ''}" data-type="serial">
|
||||
${ICONS.serial}
|
||||
<span>串口 / 开发板</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">工作区名称</span>
|
||||
<span class="setting-desc">自定义显示名称</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="ws-input-name" value="${data.name || ''}" placeholder="例如:Ubuntu-Server 或 ESP32" style="width: 220px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地特定配置 -->
|
||||
<div id="ws-config-local" style="display: ${data.type === WORKSPACE_TYPES.LOCAL ? 'block' : 'none'};">
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">工作目录 (CWD)</span>
|
||||
<span class="setting-desc">启动终端时的起始目录</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="ws-input-cwd" value="${data.cwd || '~'}" style="width: 220px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH 特定配置 -->
|
||||
<div id="ws-config-ssh" style="display: ${data.type === WORKSPACE_TYPES.SSH ? 'block' : 'none'};">
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">主机地址 (Host)</span>
|
||||
<span class="setting-desc">IP 或域名</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="ws-input-host" value="${data.host || ''}" placeholder="192.168.1.100" style="width: 220px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">用户名 (Username)</span>
|
||||
<span class="setting-desc">SSH 登录账号</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="ws-input-user" value="${data.user || ''}" placeholder="root" style="width: 220px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">端口号 (Port)</span>
|
||||
<span class="setting-desc">默认 22</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" id="ws-input-port" value="${data.port || 22}" style="width: 100px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 串口开发板特定配置 -->
|
||||
<div id="ws-config-serial" style="display: ${data.type === WORKSPACE_TYPES.SERIAL ? 'block' : 'none'};">
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">串口设备路径 (Device)</span>
|
||||
<span class="setting-desc">Linux 串口设备路径</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="text" id="ws-input-device" value="${data.device || '/dev/ttyUSB0'}" placeholder="/dev/ttyUSB0" style="width: 220px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="margin-top: 12px;">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">波特率 (Baud Rate)</span>
|
||||
<span class="setting-desc">串口通信波特率</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="ws-input-baud" style="width: 130px;">
|
||||
${[9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1500000]
|
||||
.map((b) => `<option value="${b}" ${Number(data.baudRate) === b ? 'selected' : ''}>${b}</option>`)
|
||||
.join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 绑定类型切换点击事件
|
||||
let currentType = data.type || WORKSPACE_TYPES.LOCAL;
|
||||
const typeBtns = body.querySelectorAll('.type-option-btn');
|
||||
typeBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
typeBtns.forEach((b) => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
currentType = btn.dataset.type;
|
||||
|
||||
document.getElementById('ws-config-local').style.display = currentType === WORKSPACE_TYPES.LOCAL ? 'block' : 'none';
|
||||
document.getElementById('ws-config-ssh').style.display = currentType === WORKSPACE_TYPES.SSH ? 'block' : 'none';
|
||||
document.getElementById('ws-config-serial').style.display = currentType === WORKSPACE_TYPES.SERIAL ? 'block' : 'none';
|
||||
|
||||
// 默认名字补全
|
||||
const nameInput = document.getElementById('ws-input-name');
|
||||
if (!nameInput.value) {
|
||||
if (currentType === WORKSPACE_TYPES.SSH) nameInput.value = '远程服务器';
|
||||
else if (currentType === WORKSPACE_TYPES.SERIAL) nameInput.value = '开发板终端';
|
||||
else nameInput.value = '本机电脑';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
saveModalWorkspace() {
|
||||
const nameInput = document.getElementById('ws-input-name');
|
||||
const name = nameInput ? nameInput.value.trim() : '';
|
||||
if (!name) {
|
||||
alert('请输入工作区名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTypeBtn = document.querySelector('.type-option-btn.selected');
|
||||
const type = selectedTypeBtn ? selectedTypeBtn.dataset.type : WORKSPACE_TYPES.LOCAL;
|
||||
|
||||
const wsData = {
|
||||
name,
|
||||
type,
|
||||
};
|
||||
|
||||
if (type === WORKSPACE_TYPES.LOCAL) {
|
||||
wsData.cwd = document.getElementById('ws-input-cwd')?.value.trim() || '~';
|
||||
} else if (type === WORKSPACE_TYPES.SSH) {
|
||||
wsData.host = document.getElementById('ws-input-host')?.value.trim() || 'localhost';
|
||||
wsData.user = document.getElementById('ws-input-user')?.value.trim() || '';
|
||||
wsData.port = parseInt(document.getElementById('ws-input-port')?.value, 10) || 22;
|
||||
} else if (type === WORKSPACE_TYPES.SERIAL) {
|
||||
wsData.device = document.getElementById('ws-input-device')?.value.trim() || '/dev/ttyUSB0';
|
||||
wsData.baudRate = parseInt(document.getElementById('ws-input-baud')?.value, 10) || 115200;
|
||||
}
|
||||
|
||||
if (this.editingWorkspaceId) {
|
||||
const idx = this.workspaces.findIndex((w) => w.id === this.editingWorkspaceId);
|
||||
if (idx !== -1) {
|
||||
this.workspaces[idx] = { ...this.workspaces[idx], ...wsData };
|
||||
}
|
||||
} else {
|
||||
const id = 'ws_' + Math.random().toString(36).substring(2, 9);
|
||||
const newWs = { id, ...wsData };
|
||||
this.workspaces.push(newWs);
|
||||
this.activeWorkspaceId = id;
|
||||
}
|
||||
|
||||
this.saveWorkspaces();
|
||||
this.closeModal();
|
||||
this.switchWorkspace(this.activeWorkspaceId);
|
||||
}
|
||||
|
||||
deleteWorkspace(workspaceId) {
|
||||
if (this.workspaces.length <= 1) {
|
||||
alert('至少需要保留一个工作区。');
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = this.workspaces.find((w) => w.id === workspaceId);
|
||||
if (!confirm(`确定要删除工作区「${ws ? ws.name : workspaceId}」吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭属于该工作区的所有标签页
|
||||
const tabs = this.tabManager.getTabsForWorkspace(workspaceId);
|
||||
for (const t of tabs) {
|
||||
this.tabManager.closeTab(t.tabId);
|
||||
}
|
||||
|
||||
this.workspaces = this.workspaces.filter((w) => w.id !== workspaceId);
|
||||
if (this.activeWorkspaceId === workspaceId) {
|
||||
this.activeWorkspaceId = this.workspaces[0].id;
|
||||
}
|
||||
|
||||
this.saveWorkspaces();
|
||||
this.switchWorkspace(this.activeWorkspaceId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user