Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d5f02a76 | ||
|
|
3589e5c927 | ||
|
|
2db388c2f2 | ||
|
|
5a032b13ec | ||
|
|
4707817053 | ||
|
|
ebf454a250 | ||
|
|
35452ef235 | ||
|
|
5421af1bb0 | ||
|
|
2191ef79d3 | ||
|
|
b351057f5f | ||
|
|
e543ee6283 |
@@ -18,6 +18,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmmirror.com/
|
||||||
|
disturl=https://npmmirror.com/mirrors/node/
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# hatty 项目说明
|
||||||
|
|
||||||
|
## 打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run package:deb # 构建 deb 包,输出到 dist/hatty_<version>_amd64.deb
|
||||||
|
npm run package:all # 构建 deb + AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
- 打包基于 electron-builder,配置在 `package.json` 的 `build` 字段。
|
||||||
|
- 打包前会自动通过 @electron/rebuild 重编译 node-pty 以匹配 Electron 版本。
|
||||||
Generated
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "hatty",
|
"name": "hatty",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hatty",
|
"name": "hatty",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"license": "ISC",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
|
|||||||
+2
-1
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "hatty",
|
"name": "hatty",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
|
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
|
||||||
"main": "src/main/index.cjs",
|
"main": "src/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
|
"test": "electron test/test-runner.cjs",
|
||||||
"rebuild": "electron-rebuild -f -w node-pty",
|
"rebuild": "electron-rebuild -f -w node-pty",
|
||||||
"package:deb": "electron-builder --linux deb",
|
"package:deb": "electron-builder --linux deb",
|
||||||
"package:all": "electron-builder --linux deb AppImage",
|
"package:all": "electron-builder --linux deb AppImage",
|
||||||
|
|||||||
+145
-19
@@ -1,5 +1,107 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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() {
|
async function main() {
|
||||||
const serverUrl = process.env.GITHUB_SERVER_URL || process.env.GITEA_SERVER_URL || 'https://git.zzzjy.cloud';
|
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 [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}...`);
|
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;
|
let releaseId = null;
|
||||||
const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, {
|
const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -32,7 +154,24 @@ async function main() {
|
|||||||
if (tagCheckRes.ok) {
|
if (tagCheckRes.ok) {
|
||||||
const existing = await tagCheckRes.json();
|
const existing = await tagCheckRes.json();
|
||||||
releaseId = existing.id;
|
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 {
|
} else {
|
||||||
console.log(`Creating new release for tag ${tag}...`);
|
console.log(`Creating new release for tag ${tag}...`);
|
||||||
const createRes = await fetch(`${apiBase}/releases`, {
|
const createRes = await fetch(`${apiBase}/releases`, {
|
||||||
@@ -45,7 +184,7 @@ async function main() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
name: `hatty ${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,
|
draft: false,
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
}),
|
}),
|
||||||
@@ -62,20 +201,7 @@ async function main() {
|
|||||||
console.log(`Release created successfully with id ${releaseId}`);
|
console.log(`Release created successfully with id ${releaseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 查找 dist/ 下的 .deb 安装包
|
// 3. 检查已存在的附件,若同名则先删除以支持重试
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查已存在的附件,若同名则先删除以支持重试
|
|
||||||
try {
|
try {
|
||||||
const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, {
|
const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -99,7 +225,7 @@ async function main() {
|
|||||||
console.warn('Checking existing assets notice:', e.message);
|
console.warn('Checking existing assets notice:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 上传安装包到 Release 附件
|
// 4. 上传安装包到 Release 附件
|
||||||
for (const fileName of files) {
|
for (const fileName of files) {
|
||||||
const filePath = path.join(distDir, fileName);
|
const filePath = path.join(distDir, fileName);
|
||||||
const fileStat = fs.statSync(filePath);
|
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 path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { PtyManager } = require('./pty-manager.cjs');
|
const { PtyManager } = require('./pty-manager.cjs');
|
||||||
@@ -101,6 +101,147 @@ ipcMain.on('pty:kill', (event, { id }) => {
|
|||||||
if (ptyManager) ptyManager.kill({ 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', () => {
|
ipcMain.handle('app:get-system-info', () => {
|
||||||
return {
|
return {
|
||||||
platform: os.platform(),
|
platform: os.platform(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const pty = require('node-pty');
|
const pty = require('node-pty');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
class PtyManager {
|
class PtyManager {
|
||||||
constructor(mainWindow) {
|
constructor(mainWindow) {
|
||||||
@@ -8,17 +9,38 @@ class PtyManager {
|
|||||||
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
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)) {
|
if (this.ptys.has(id)) {
|
||||||
this.kill({ id });
|
this.kill({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
name: 'xterm-256color',
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
cwd,
|
cwd: resolvedCwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
@@ -40,7 +62,7 @@ class PtyManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.ptys.set(id, ptyProcess);
|
this.ptys.set(id, ptyProcess);
|
||||||
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell };
|
return { success: true, pid: ptyProcess.pid, shell: targetShell };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to spawn pty [${id}]:`, err);
|
console.error(`Failed to spawn pty [${id}]:`, err);
|
||||||
return { success: false, error: err.message };
|
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() {
|
killAll() {
|
||||||
for (const [id, ptyProcess] of this.ptys.entries()) {
|
for (const [id, ptyProcess] of this.ptys.entries()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -32,4 +32,15 @@ contextBridge.exposeInMainWorld('terminalAPI', {
|
|||||||
|
|
||||||
// 系统信息
|
// 系统信息
|
||||||
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
|
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);
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部无边框拖拽标题栏 */
|
/* 顶部无边框拖拽标题栏 (与标签栏合二为一) */
|
||||||
.titlebar {
|
.titlebar {
|
||||||
height: var(--titlebar-height);
|
height: var(--titlebar-height);
|
||||||
background: var(--bg-titlebar);
|
background: var(--bg-titlebar);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
-webkit-app-region: drag; /* 允许窗口拖拽 */
|
-webkit-app-region: drag; /* 允许窗口拖拽 */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
padding: 0 8px;
|
padding: 0;
|
||||||
|
padding-right: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar-left {
|
.titlebar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
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 {
|
.app-brand {
|
||||||
@@ -57,10 +78,68 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
padding: 0 8px;
|
padding: 0 4px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
cursor: grab;
|
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 {
|
.titlebar-drag-spacer {
|
||||||
@@ -68,7 +147,7 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
-webkit-app-region: drag; /* 中间大面积空白区域允许拖拽 */
|
-webkit-app-region: drag; /* 中间大面积空白区域允许拖拽 */
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
min-width: 40px;
|
min-width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-brand-icon {
|
.app-brand-icon {
|
||||||
@@ -95,10 +174,17 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
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 {
|
.btn-icon:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +198,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win-btn {
|
.win-btn {
|
||||||
@@ -127,10 +213,16 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-btn svg,
|
||||||
|
.win-btn svg * {
|
||||||
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win-btn:hover {
|
.win-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +231,10 @@ body {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主内容区 */
|
/* 主内容区终端视口 */
|
||||||
.main-viewport {
|
.main-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statusbar-item.clickable:hover {
|
.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);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
.palette-item.selected,
|
.palette-item.selected,
|
||||||
.palette-item:hover {
|
.palette-item:hover {
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
color: #0f141c;
|
color: var(--accent-text, #0f141c);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +130,10 @@
|
|||||||
|
|
||||||
.palette-shortcut {
|
.palette-shortcut {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
opacity: 0.85;
|
||||||
font-family: var(--font-mono);
|
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;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
@@ -211,6 +212,11 @@
|
|||||||
border-color: var(--border-active);
|
border-color: var(--border-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-control select option {
|
||||||
|
background: var(--bg-titlebar);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
/* 终端内搜索条 */
|
/* 终端内搜索条 */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -255,3 +261,529 @@
|
|||||||
from { transform: translateY(-10px); opacity: 0; }
|
from { transform: translateY(-10px); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
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;
|
gap: 4px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-width: calc(100vw - 320px);
|
flex: 0 1 auto;
|
||||||
-webkit-app-region: no-drag;
|
min-width: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-bar::-webkit-scrollbar {
|
.tab-bar::-webkit-scrollbar {
|
||||||
@@ -30,6 +32,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
min-width: 90px;
|
min-width: 90px;
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item svg,
|
||||||
|
.tab-item svg * {
|
||||||
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item:hover {
|
.tab-item:hover {
|
||||||
@@ -66,6 +74,8 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.12s ease;
|
transition: all 0.12s ease;
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item:hover .tab-close,
|
.tab-item:hover .tab-close,
|
||||||
@@ -91,9 +101,16 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-tab-btn svg,
|
||||||
|
.new-tab-btn svg * {
|
||||||
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-tab-btn:hover {
|
.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);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,17 @@
|
|||||||
|
|
||||||
--titlebar-height: 38px;
|
--titlebar-height: 38px;
|
||||||
--statusbar-height: 26px;
|
--statusbar-height: 26px;
|
||||||
|
--sidebar-width: 250px;
|
||||||
--border-radius-sm: 4px;
|
--border-radius-sm: 4px;
|
||||||
--border-radius-md: 8px;
|
--border-radius-md: 8px;
|
||||||
--border-radius-lg: 12px;
|
--border-radius-lg: 12px;
|
||||||
|
|
||||||
--terminal-font-size: 14px;
|
--terminal-font-size: 14px;
|
||||||
--terminal-line-height: 1.25;
|
--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 */
|
/* Tokyo Night */
|
||||||
@@ -36,8 +41,11 @@
|
|||||||
--border-active: #7aa2f7;
|
--border-active: #7aa2f7;
|
||||||
--text-main: #c0caf5;
|
--text-main: #c0caf5;
|
||||||
--text-muted: #565f89;
|
--text-muted: #565f89;
|
||||||
|
--text-bright: #ffffff;
|
||||||
--accent-color: #7aa2f7;
|
--accent-color: #7aa2f7;
|
||||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
--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 */
|
/* Catppuccin Mocha */
|
||||||
@@ -50,8 +58,11 @@
|
|||||||
--border-active: #cba6f7;
|
--border-active: #cba6f7;
|
||||||
--text-main: #cdd6f4;
|
--text-main: #cdd6f4;
|
||||||
--text-muted: #6c7086;
|
--text-muted: #6c7086;
|
||||||
|
--text-bright: #ffffff;
|
||||||
--accent-color: #cba6f7;
|
--accent-color: #cba6f7;
|
||||||
--accent-glow: rgba(203, 166, 247, 0.3);
|
--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 */
|
/* Dracula */
|
||||||
@@ -64,8 +75,11 @@
|
|||||||
--border-active: #bd93f9;
|
--border-active: #bd93f9;
|
||||||
--text-main: #f8f8f2;
|
--text-main: #f8f8f2;
|
||||||
--text-muted: #6272a4;
|
--text-muted: #6272a4;
|
||||||
|
--text-bright: #ffffff;
|
||||||
--accent-color: #ff79c6;
|
--accent-color: #ff79c6;
|
||||||
--accent-glow: rgba(255, 121, 198, 0.3);
|
--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 */
|
/* One Dark */
|
||||||
@@ -78,8 +92,11 @@
|
|||||||
--border-active: #61afef;
|
--border-active: #61afef;
|
||||||
--text-main: #abb2bf;
|
--text-main: #abb2bf;
|
||||||
--text-muted: #5c6370;
|
--text-muted: #5c6370;
|
||||||
|
--text-bright: #ffffff;
|
||||||
--accent-color: #61afef;
|
--accent-color: #61afef;
|
||||||
--accent-glow: rgba(97, 175, 239, 0.3);
|
--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 */
|
/* Cyberpunk Neon */
|
||||||
@@ -92,6 +109,107 @@
|
|||||||
--border-active: #00ffcc;
|
--border-active: #00ffcc;
|
||||||
--text-main: #e2f0fb;
|
--text-main: #e2f0fb;
|
||||||
--text-muted: #4e6a88;
|
--text-muted: #4e6a88;
|
||||||
|
--text-bright: #ffffff;
|
||||||
--accent-color: #ff007f;
|
--accent-color: #ff007f;
|
||||||
--accent-glow: rgba(255, 0, 127, 0.4);
|
--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;
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-7
@@ -13,12 +13,21 @@
|
|||||||
<link rel="stylesheet" href="css/tabs.css">
|
<link rel="stylesheet" href="css/tabs.css">
|
||||||
<link rel="stylesheet" href="css/split.css">
|
<link rel="stylesheet" href="css/split.css">
|
||||||
<link rel="stylesheet" href="css/components.css">
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/sidebar.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app" class="sidebar-collapsed">
|
||||||
<!-- 顶部现代拖拽式标题栏 -->
|
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
|
||||||
<header class="titlebar">
|
<header class="titlebar" id="titlebar">
|
||||||
<div class="titlebar-left">
|
<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">
|
<div class="app-brand">
|
||||||
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<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>
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
@@ -27,7 +36,15 @@
|
|||||||
<span>hatty</span>
|
<span>hatty</span>
|
||||||
</div>
|
</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>
|
<div class="tab-bar" id="tab-bar"></div>
|
||||||
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
|
<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">
|
<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>
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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)">
|
<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">
|
<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>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
@@ -89,8 +117,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 终端主视口 -->
|
<!-- 应用主体容器 (含左侧工作区侧边栏与终端视口) -->
|
||||||
<main class="main-viewport" id="main-viewport"></main>
|
<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">
|
<footer class="statusbar">
|
||||||
@@ -140,6 +210,21 @@
|
|||||||
<div class="modal-overlay" id="settings-overlay">
|
<div class="modal-overlay" id="settings-overlay">
|
||||||
<div class="settings-modal" id="settings-modal"></div>
|
<div class="settings-modal" id="settings-modal"></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="js/app.js"></script>
|
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
-78
@@ -1,22 +1,23 @@
|
|||||||
import { themeManager } from './theme-manager.js';
|
import { themeManager } from './theme-manager.js';
|
||||||
import { TabManager } from './tab-manager.js';
|
import { TabManager } from './tab-manager.js';
|
||||||
|
import { WorkspaceManager } from './workspace-manager.js';
|
||||||
import { CommandPalette } from './command-palette.js';
|
import { CommandPalette } from './command-palette.js';
|
||||||
import { SettingsModal } from './settings.js';
|
import { SettingsModal } from './settings.js';
|
||||||
|
import { ContextMenu } from './context-menu.js';
|
||||||
|
|
||||||
class TerminalApp {
|
class TerminalApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initElements();
|
this.initElements();
|
||||||
this.initTheme();
|
this.initTheme();
|
||||||
this.initTabManager();
|
this.initTabManager();
|
||||||
|
this.initWorkspaceManager();
|
||||||
|
this.initContextMenu();
|
||||||
this.initModals();
|
this.initModals();
|
||||||
this.initSearch();
|
this.initSearch();
|
||||||
this.initWindowControls();
|
this.initWindowControls();
|
||||||
this.initIpcListeners();
|
this.initIpcListeners();
|
||||||
this.initKeyboardShortcuts();
|
this.initKeyboardShortcuts();
|
||||||
this.initStatusBar();
|
this.initStatusBar();
|
||||||
|
|
||||||
// 创建初始标签页
|
|
||||||
this.tabManager.createTab();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initElements() {
|
initElements() {
|
||||||
@@ -55,15 +56,65 @@ class TerminalApp {
|
|||||||
this.tabManager = new TabManager({
|
this.tabManager = new TabManager({
|
||||||
tabBar: this.tabBar,
|
tabBar: this.tabBar,
|
||||||
viewport: this.viewport,
|
viewport: this.viewport,
|
||||||
|
getSpawnOptions: (wsId) => (this.workspaceManager ? this.workspaceManager.getSpawnOptions(wsId) : {}),
|
||||||
onActiveChange: (activeTerm) => {
|
onActiveChange: (activeTerm) => {
|
||||||
if (activeTerm && this.statusShell) {
|
if (activeTerm && this.statusShell) {
|
||||||
this.statusShell.textContent = activeTerm.title || 'bash';
|
this.statusShell.textContent = activeTerm.title || 'bash';
|
||||||
}
|
}
|
||||||
|
this.updateSplitButtonsState(activeTerm);
|
||||||
|
},
|
||||||
|
onContextMenu: (e, term) => {
|
||||||
|
this.contextMenu?.show(e, term);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.newTabBtn.addEventListener('click', () => {
|
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,
|
input: this.commandPaletteInput,
|
||||||
list: this.commandPaletteList,
|
list: this.commandPaletteList,
|
||||||
tabManager: this.tabManager,
|
tabManager: this.tabManager,
|
||||||
|
workspaceManager: this.workspaceManager,
|
||||||
onOpenSettings: () => this.settings.open(),
|
onOpenSettings: () => this.settings.open(),
|
||||||
onToggleSearch: () => this.toggleSearch(),
|
onToggleSearch: () => this.toggleSearch(),
|
||||||
});
|
});
|
||||||
@@ -94,12 +146,24 @@ class TerminalApp {
|
|||||||
|
|
||||||
document.getElementById('btn-split-h')?.addEventListener('click', () => {
|
document.getElementById('btn-split-h')?.addEventListener('click', () => {
|
||||||
const tab = this.tabManager.getActiveTab();
|
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', () => {
|
document.getElementById('btn-split-v')?.addEventListener('click', () => {
|
||||||
const tab = this.tabManager.getActiveTab();
|
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', () => {
|
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() {
|
initWindowControls() {
|
||||||
document.getElementById('win-min')?.addEventListener('click', () => {
|
document.getElementById('win-min')?.addEventListener('click', () => {
|
||||||
window.terminalAPI.minimize();
|
window.terminalAPI.minimize();
|
||||||
@@ -203,82 +273,149 @@ class TerminalApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initKeyboardShortcuts() {
|
initKeyboardShortcuts() {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener(
|
||||||
// Ctrl+Shift+P 或 F1: 命令面板
|
'keydown',
|
||||||
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
|
(e) => {
|
||||||
e.preventDefault();
|
// Ctrl+Shift+P 或 F1: 命令面板
|
||||||
this.commandPalette.toggle();
|
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
|
||||||
return;
|
e.preventDefault();
|
||||||
}
|
e.stopPropagation();
|
||||||
|
this.commandPalette.toggle();
|
||||||
// Ctrl+Shift+T: 新建标签
|
return;
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.tabManager.createTab();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+W: 关闭窗格
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'w') {
|
|
||||||
e.preventDefault();
|
|
||||||
const tab = this.tabManager.getActiveTab();
|
|
||||||
if (tab && tab.splitTree.activeTerminal) {
|
|
||||||
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+E: 水平分屏 (左右)
|
// Ctrl+B: 切换左侧工作区侧边栏
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const tab = this.tabManager.getActiveTab();
|
e.stopPropagation();
|
||||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
this.workspaceManager?.toggleSidebar();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+O: 垂直分屏 (上下)
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
|
|
||||||
e.preventDefault();
|
|
||||||
const tab = this.tabManager.getActiveTab();
|
|
||||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+F: 搜索
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.toggleSearch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+,: 设置
|
|
||||||
if (e.ctrlKey && e.key === ',') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.settings.open();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
|
|
||||||
if (e.ctrlKey && e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
this.tabManager.prevTab();
|
|
||||||
} else {
|
|
||||||
this.tabManager.nextTab();
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+1 .. Alt+9: 快速直达标签页
|
const isEditingText = (el) => {
|
||||||
if (e.altKey && e.key >= '1' && e.key <= '9') {
|
if (!el) return false;
|
||||||
e.preventDefault();
|
const tag = el.tagName?.toLowerCase();
|
||||||
const index = parseInt(e.key, 10) - 1;
|
return (tag === 'input' || tag === 'textarea') && !el.classList.contains('xterm-helper-textarea');
|
||||||
const ids = Array.from(this.tabManager.tabs.keys());
|
};
|
||||||
if (ids[index]) {
|
|
||||||
this.tabManager.switchTab(ids[index]);
|
// 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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
|
||||||
|
if (e.ctrlKey && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
this.tabManager.prevTab();
|
||||||
|
} else {
|
||||||
|
this.tabManager.nextTab();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
async initStatusBar() {
|
||||||
@@ -295,5 +432,5 @@ class TerminalApp {
|
|||||||
|
|
||||||
// 页面加载就绪后启动终端
|
// 页面加载就绪后启动终端
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
new TerminalApp();
|
window.terminalApp = new TerminalApp();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export class CommandPalette {
|
|||||||
this.input = options.input;
|
this.input = options.input;
|
||||||
this.list = options.list;
|
this.list = options.list;
|
||||||
this.tabManager = options.tabManager;
|
this.tabManager = options.tabManager;
|
||||||
|
this.workspaceManager = options.workspaceManager;
|
||||||
this.onOpenSettings = options.onOpenSettings || (() => {});
|
this.onOpenSettings = options.onOpenSettings || (() => {});
|
||||||
this.onToggleSearch = options.onToggleSearch || (() => {});
|
this.onToggleSearch = options.onToggleSearch || (() => {});
|
||||||
|
|
||||||
@@ -19,12 +20,36 @@ export class CommandPalette {
|
|||||||
|
|
||||||
initCommands() {
|
initCommands() {
|
||||||
this.baseCommands = [
|
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',
|
id: 'new-tab',
|
||||||
title: '新建标签页 (New Tab)',
|
title: '新建标签页 (New Tab)',
|
||||||
shortcut: 'Ctrl+Shift+T',
|
shortcut: 'Ctrl+Shift+T',
|
||||||
icon: 'plus',
|
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',
|
id: 'split-horizontal',
|
||||||
@@ -33,7 +58,9 @@ export class CommandPalette {
|
|||||||
icon: 'columns',
|
icon: 'columns',
|
||||||
action: () => {
|
action: () => {
|
||||||
const tab = this.tabManager.getActiveTab();
|
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',
|
icon: 'rows',
|
||||||
action: () => {
|
action: () => {
|
||||||
const tab = this.tabManager.getActiveTab();
|
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',
|
id: 'search',
|
||||||
title: '终端内容快速搜索 (Search Buffer)',
|
title: '终端内容快速搜索 (Search Buffer)',
|
||||||
@@ -65,6 +103,26 @@ export class CommandPalette {
|
|||||||
icon: 'search',
|
icon: 'search',
|
||||||
action: () => this.onToggleSearch(),
|
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',
|
id: 'clear',
|
||||||
title: '清空当前屏幕缓冲区 (Clear Buffer)',
|
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 { TerminalView } from './terminal-view.js';
|
||||||
|
import { AgentPanel } from './agent-panel.js';
|
||||||
|
import { FilePanel } from './file-panel.js';
|
||||||
|
|
||||||
export class TabSplitTree {
|
export class TabSplitTree {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
this.tabId = options.tabId;
|
this.tabId = options.tabId;
|
||||||
|
this.spawnOptions = options.spawnOptions || {};
|
||||||
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
||||||
this.onEmpty = options.onEmpty || (() => {});
|
this.onEmpty = options.onEmpty || (() => {});
|
||||||
this.onTitleChange = options.onTitleChange || (() => {});
|
this.onTitleChange = options.onTitleChange || (() => {});
|
||||||
|
this.onContextMenu = options.onContextMenu || null;
|
||||||
|
|
||||||
this.terminals = new Map(); // id -> { termView, paneElement, parentElement }
|
this.terminals = new Map(); // id -> { termView, paneElement, parentElement }
|
||||||
this.activeTerminal = null;
|
this.activeTerminal = null;
|
||||||
|
this.agentPanel = null; // 每个大标签页独占至多一个 AgentPanel
|
||||||
|
this.filePanel = null; // 每个大标签页独占至多一个 FilePanel
|
||||||
|
|
||||||
this.rootElement = document.createElement('div');
|
this.rootElement = document.createElement('div');
|
||||||
this.rootElement.className = 'split-container horizontal';
|
this.rootElement.className = 'split-container horizontal';
|
||||||
this.container.appendChild(this.rootElement);
|
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');
|
const paneElement = document.createElement('div');
|
||||||
paneElement.className = 'split-pane';
|
paneElement.className = 'split-pane';
|
||||||
|
|
||||||
parentElement.appendChild(paneElement);
|
parentElement.appendChild(paneElement);
|
||||||
|
|
||||||
const termView = new TerminalView({
|
const termView = new TerminalView({
|
||||||
|
...this.spawnOptions,
|
||||||
|
...paneSpawnOptions,
|
||||||
container: paneElement,
|
container: paneElement,
|
||||||
onFocus: (term) => {
|
onFocus: (term) => {
|
||||||
this.setActiveTerminal(term);
|
this.setActiveTerminal(term);
|
||||||
@@ -38,6 +46,9 @@ export class TabSplitTree {
|
|||||||
onExit: () => {
|
onExit: () => {
|
||||||
this.closeTerminal(termView.id);
|
this.closeTerminal(termView.id);
|
||||||
},
|
},
|
||||||
|
onContextMenu: (e, term) => {
|
||||||
|
this.onContextMenu?.(e, term);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.terminals.set(termView.id, {
|
this.terminals.set(termView.id, {
|
||||||
@@ -54,8 +65,8 @@ export class TabSplitTree {
|
|||||||
return termView;
|
return termView;
|
||||||
}
|
}
|
||||||
|
|
||||||
splitCurrent(direction = 'horizontal') {
|
splitCurrent(direction = 'horizontal', customSpawnOptions = null) {
|
||||||
if (!this.activeTerminal) return;
|
if (!this.activeTerminal || this.activeTerminal.isAgent) return;
|
||||||
const currentInfo = this.terminals.get(this.activeTerminal.id);
|
const currentInfo = this.terminals.get(this.activeTerminal.id);
|
||||||
if (!currentInfo) return;
|
if (!currentInfo) return;
|
||||||
|
|
||||||
@@ -79,7 +90,12 @@ export class TabSplitTree {
|
|||||||
splitWrapper.appendChild(divider);
|
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;
|
const newPane = this.terminals.get(newTerm.id).paneElement;
|
||||||
newPane.style.flex = '1 1 0%';
|
newPane.style.flex = '1 1 0%';
|
||||||
|
|
||||||
@@ -91,6 +107,259 @@ export class TabSplitTree {
|
|||||||
this.fitAll();
|
this.fitAll();
|
||||||
newTerm.focus();
|
newTerm.focus();
|
||||||
}, 50);
|
}, 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) {
|
setupDividerDrag(divider, firstElement, secondElement, direction) {
|
||||||
@@ -151,6 +420,12 @@ export class TabSplitTree {
|
|||||||
const info = this.terminals.get(termId);
|
const info = this.terminals.get(termId);
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
|
// 如果是 Agent 终端,委托给 AgentPanel 关闭该 agent 标签
|
||||||
|
if (info.termView.isAgent && this.agentPanel) {
|
||||||
|
this.agentPanel.closeAgent(info.termView.agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
info.termView.destroy();
|
info.termView.destroy();
|
||||||
const pane = info.paneElement;
|
const pane = info.paneElement;
|
||||||
const parent = pane.parentElement;
|
const parent = pane.parentElement;
|
||||||
@@ -177,6 +452,28 @@ export class TabSplitTree {
|
|||||||
const singleChild = remainingChildren[0];
|
const singleChild = remainingChildren[0];
|
||||||
singleChild.style.flex = parent.style.flex || '1 1 0%';
|
singleChild.style.flex = parent.style.flex || '1 1 0%';
|
||||||
parent.parentElement.replaceChild(singleChild, parent);
|
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() {
|
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()) {
|
for (const info of this.terminals.values()) {
|
||||||
info.termView.destroy();
|
info.termView.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-22
@@ -4,13 +4,64 @@ export class TabManager {
|
|||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.tabBar = options.tabBar;
|
this.tabBar = options.tabBar;
|
||||||
this.viewport = options.viewport;
|
this.viewport = options.viewport;
|
||||||
|
this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
|
||||||
this.onActiveChange = options.onActiveChange || (() => {});
|
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.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);
|
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
|
||||||
|
|
||||||
// 1. 创建标签页 DOM
|
// 1. 创建标签页 DOM
|
||||||
@@ -34,6 +85,7 @@ export class TabManager {
|
|||||||
const splitTree = new TabSplitTree({
|
const splitTree = new TabSplitTree({
|
||||||
container: viewElement,
|
container: viewElement,
|
||||||
tabId,
|
tabId,
|
||||||
|
spawnOptions,
|
||||||
onActiveTerminalChange: (term) => {
|
onActiveTerminalChange: (term) => {
|
||||||
if (this.activeTabId === tabId) {
|
if (this.activeTabId === tabId) {
|
||||||
this.onActiveChange(term);
|
this.onActiveChange(term);
|
||||||
@@ -45,6 +97,9 @@ export class TabManager {
|
|||||||
onEmpty: () => {
|
onEmpty: () => {
|
||||||
this.closeTab(tabId);
|
this.closeTab(tabId);
|
||||||
},
|
},
|
||||||
|
onContextMenu: (e, term) => {
|
||||||
|
this.onContextMenu?.(e, term);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 标签页点击与关闭事件
|
// 4. 标签页点击与关闭事件
|
||||||
@@ -56,22 +111,44 @@ export class TabManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 根据当前工作区控制是否在顶部标签栏可见
|
||||||
|
if (workspaceId !== this.activeWorkspaceId) {
|
||||||
|
tabElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
this.tabBar.appendChild(tabElement);
|
this.tabBar.appendChild(tabElement);
|
||||||
|
|
||||||
this.tabs.set(tabId, {
|
this.tabs.set(tabId, {
|
||||||
tabId,
|
tabId,
|
||||||
|
workspaceId,
|
||||||
tabElement,
|
tabElement,
|
||||||
viewElement,
|
viewElement,
|
||||||
splitTree,
|
splitTree,
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.switchTab(tabId);
|
if (workspaceId === this.activeWorkspaceId) {
|
||||||
|
this.switchTab(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
return tabId;
|
return tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
switchTab(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()) {
|
for (const [id, tab] of this.tabs.entries()) {
|
||||||
const isActive = id === tabId;
|
const isActive = id === tabId;
|
||||||
@@ -88,26 +165,33 @@ export class TabManager {
|
|||||||
}, 30);
|
}, 30);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeTab(tabId) {
|
closeTab(tabId) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
|
|
||||||
|
const wsId = tab.workspaceId;
|
||||||
tab.splitTree.destroy();
|
tab.splitTree.destroy();
|
||||||
tab.tabElement.remove();
|
tab.tabElement.remove();
|
||||||
tab.viewElement.remove();
|
tab.viewElement.remove();
|
||||||
this.tabs.delete(tabId);
|
this.tabs.delete(tabId);
|
||||||
|
|
||||||
// 如果还有其他标签页,切换到上一个或下一个
|
// 寻找同一工作区的其他标签
|
||||||
if (this.tabs.size > 0) {
|
const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId);
|
||||||
const remainingIds = Array.from(this.tabs.keys());
|
if (this.activeTabId === tabId) {
|
||||||
const nextId = remainingIds[remainingIds.length - 1];
|
if (remainingWsTabs.length > 0) {
|
||||||
this.switchTab(nextId);
|
const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId;
|
||||||
} else {
|
this.switchTab(nextId);
|
||||||
// 没有任何标签页时自动新建一个
|
} else {
|
||||||
this.createTab();
|
// 当前工作区没有其他标签页了,自动新建一个
|
||||||
|
this.createTab({ workspaceId: wsId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTabTitle(tabId, newTitle) {
|
updateTabTitle(tabId, newTitle) {
|
||||||
@@ -118,6 +202,7 @@ export class TabManager {
|
|||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.textContent = newTitle;
|
titleEl.textContent = newTitle;
|
||||||
}
|
}
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,19 +215,45 @@ export class TabManager {
|
|||||||
return activeTab ? activeTab.splitTree.activeTerminal : null;
|
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() {
|
nextTab() {
|
||||||
const ids = Array.from(this.tabs.keys());
|
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||||
if (ids.length <= 1) return;
|
if (wsTabs.length <= 1) return;
|
||||||
const currentIndex = ids.indexOf(this.activeTabId);
|
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||||
const nextIndex = (currentIndex + 1) % ids.length;
|
const nextIndex = (currentIndex + 1) % wsTabs.length;
|
||||||
this.switchTab(ids[nextIndex]);
|
this.switchTab(wsTabs[nextIndex].tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
prevTab() {
|
prevTab() {
|
||||||
const ids = Array.from(this.tabs.keys());
|
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||||
if (ids.length <= 1) return;
|
if (wsTabs.length <= 1) return;
|
||||||
const currentIndex = ids.indexOf(this.activeTabId);
|
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||||
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
|
||||||
this.switchTab(ids[prevIndex]);
|
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.id = 'term_' + Math.random().toString(36).substring(2, 9);
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
this.cwd = options.cwd || '';
|
this.cwd = options.cwd || '';
|
||||||
|
this.shell = options.shell || undefined;
|
||||||
|
this.args = options.args || [];
|
||||||
|
this.title = options.title || 'bash';
|
||||||
this.onTitleChange = options.onTitleChange || (() => {});
|
this.onTitleChange = options.onTitleChange || (() => {});
|
||||||
this.onFocus = options.onFocus || (() => {});
|
this.onFocus = options.onFocus || (() => {});
|
||||||
this.onExit = options.onExit || (() => {});
|
this.onExit = options.onExit || (() => {});
|
||||||
|
this.onContextMenu = options.onContextMenu || null;
|
||||||
|
|
||||||
this.title = 'bash';
|
|
||||||
this.isFocused = false;
|
this.isFocused = false;
|
||||||
|
|
||||||
this.initTerminal();
|
this.initTerminal();
|
||||||
@@ -41,7 +44,7 @@ export class TerminalView {
|
|||||||
theme: currentTheme.xterm,
|
theme: currentTheme.xterm,
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
rightClickSelectsWord: true,
|
rightClickSelectsWord: true,
|
||||||
convertEol: true,
|
convertEol: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 插件初始化
|
// 插件初始化
|
||||||
@@ -58,9 +61,24 @@ export class TerminalView {
|
|||||||
this.terminalWrapper = document.createElement('div');
|
this.terminalWrapper = document.createElement('div');
|
||||||
this.terminalWrapper.className = 'terminal-wrapper';
|
this.terminalWrapper.className = 'terminal-wrapper';
|
||||||
this.container.appendChild(this.terminalWrapper);
|
this.container.appendChild(this.terminalWrapper);
|
||||||
|
this.element = this.terminalWrapper;
|
||||||
|
|
||||||
this.xterm.open(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 渲染)
|
// 启用 WebGL 硬件加速 (GPU 渲染)
|
||||||
try {
|
try {
|
||||||
this.webglAddon = new WebglAddon();
|
this.webglAddon = new WebglAddon();
|
||||||
@@ -73,6 +91,49 @@ export class TerminalView {
|
|||||||
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
|
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) => {
|
this.xterm.onData((data) => {
|
||||||
window.terminalAPI.writePty(this.id, data);
|
window.terminalAPI.writePty(this.id, data);
|
||||||
@@ -92,6 +153,17 @@ export class TerminalView {
|
|||||||
this.onFocus(this);
|
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.unsubscribeTheme = themeManager.onThemeChange((theme) => {
|
||||||
this.xterm.options.theme = theme.xterm;
|
this.xterm.options.theme = theme.xterm;
|
||||||
@@ -113,11 +185,20 @@ export class TerminalView {
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
cwd: this.cwd,
|
cwd: this.cwd,
|
||||||
|
shell: this.shell,
|
||||||
|
args: this.args,
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
if (res && res.shell) {
|
if (res && res.error) {
|
||||||
this.title = res.shell.split('/').pop() || 'bash';
|
this.xterm.writeln(`\r\n\x1b[31m[创建终端进程失败: ${res.error}]\x1b[0m\r\n`);
|
||||||
this.onTitleChange(this.title);
|
|
||||||
}
|
}
|
||||||
|
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);
|
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) {
|
updateSettings(settings) {
|
||||||
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
|
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
|
||||||
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
|
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
|
||||||
@@ -152,6 +267,10 @@ export class TerminalView {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.unsubscribeTheme) this.unsubscribeTheme();
|
if (this.unsubscribeTheme) this.unsubscribeTheme();
|
||||||
|
if (this.unsubscribeExit) {
|
||||||
|
this.unsubscribeExit();
|
||||||
|
this.unsubscribeExit = null;
|
||||||
|
}
|
||||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||||
window.terminalAPI.killPty(this.id);
|
window.terminalAPI.killPty(this.id);
|
||||||
this.xterm.dispose();
|
this.xterm.dispose();
|
||||||
|
|||||||
@@ -140,6 +140,146 @@ export const THEMES = {
|
|||||||
brightWhite: '#ffffff',
|
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 {
|
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