9 Commits
Author SHA1 Message Date
zzzjy 3589e5c927 feat: add agent panel, file panel, context menu, fix agy cursor, and bump to v1.1.0
Build and Release / Build & Release DEB Package (push) Successful in 3m27s
- Agent panel: dedicated panel per tab with compact icon-only agent tabs (agy with Google colors, codex with ChatGPT icon, claude)
- Agent lifecycle: auto-close tab and panel when agent process exits without leaving a shell
- Fix cursor jumping: disable convertEol and ensure precise viewport size calculation
- File browser panel: single instance per tab, directory navigation, hidden files toggle
- Context menu: terminal right-click menu with split options, agent launcher, and close pane
- Terminal shortcuts: Ctrl+Shift+C / Ctrl+Shift+V copy and paste support
- Test suite: 35 automated end-to-end tests covering all new features
2026-09-25 01:45:51 +08:00
zzzjy 2db388c2f2 feat: merge titlebar and tab bar into single header with dynamic workspace alignment
- Merge titlebar and tab bar into a single unified top bar (.titlebar)
- Remove redundant floating workspace button that caused overlap on collapsed sidebar
- Ensure tab bar left aligns with terminal window when workspace sidebar is expanded
- Allow tab bar to sit naturally at leftmost position when workspace sidebar is collapsed
- Update test runner suite to cover merged titlebar and alignment behavior
2026-09-24 22:57:47 +08:00
zzzjy 5a032b13ec feat: align tab bar with terminal window under workspace hierarchy & add automated test suite
- Restructure UI layout: tab bar and stage actions are now housed inside .workspace-stage (.workspace-header), aligning 100% with the terminal window (#main-viewport) below it
- Establish clear workspace hierarchy: Global Titlebar -> Left Workspace Sidebar -> Active Workspace Content Stage (Tab Bar + Terminal Viewport)
- Add active workspace indicator badge to titlebar header
- Introduce comprehensive automated test suite (test/test-runner.cjs) covering PTY process lifecycle, workspace hierarchy, stage alignment, tab creation, split panes, shortcuts, and themes
- Add 'npm test' script to package.json
2026-09-24 01:54:02 +08:00
zzzjy 4707817053 fix: resolve new tab creation failure and shortcut passthrough
- Fix pty.spawn cwd tilde expansion: ptyManager now passes resolvedCwd (expanding ~ and verifying directory existence) so child processes do not exit immediately on launch
- Add pointer-events: none to SVGs inside buttons and tabs to prevent Electron window-dragging interception
- Extend xterm attachCustomKeyEventHandler to allow Ctrl+Shift+T, Ctrl+Shift+W, etc. through while terminal is focused
- Wrap remote SSH and serial spawns with fallback to local bash to prevent tab death loops on connection failures
2026-09-24 01:48:43 +08:00
zzzjy ebf454a250 fix: intercept Ctrl+B and app shortcuts in capture phase when terminal is focused 2026-09-24 01:38:35 +08:00
zzzjy 35452ef235 fix: make titlebar workspace toggle clickable and add floating expand button 2026-09-24 01:31:50 +08:00
zzzjy 5421af1bb0 feat: add collapsible workspace sidebar supporting Local PC, SSH, and Serial Dev Boards 2026-09-24 01:24:43 +08:00
zzzjy 2191ef79d3 feat(ci): add automated dynamic release notes generation with git changelog and checksums 2026-09-24 00:38:09 +08:00
zzzjy b351057f5f feat: add 5 modern light themes (Catppuccin Latte, GitHub Light, Tokyo Night Day, Solarized Light, One Light) 2026-09-24 00:27:14 +08:00
25 changed files with 5639 additions and 166 deletions
+2
View File
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
+2
View File
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hatty",
"version": "1.0.1",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hatty",
"version": "1.0.1",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
+2 -1
View File
@@ -1,10 +1,11 @@
{
"name": "hatty",
"version": "1.0.1",
"version": "1.1.0",
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
"main": "src/main/index.cjs",
"scripts": {
"start": "electron .",
"test": "electron test/test-runner.cjs",
"rebuild": "electron-rebuild -f -w node-pty",
"package:deb": "electron-builder --linux deb",
"package:all": "electron-builder --linux deb AppImage",
+145 -19
View File
@@ -1,5 +1,107 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { execSync } from 'child_process';
/**
* 动态提取 Git Commit 日志并生成规范化 Release Notes
*/
function generateReleaseNotes({ tag, files, distDir, repoUrl }) {
let prevTag = '';
try {
const tags = execSync('git tag --sort=-creatordate', { encoding: 'utf8' })
.split('\n')
.map((t) => t.trim())
.filter(Boolean);
const currIdx = tags.indexOf(tag);
if (currIdx !== -1 && currIdx < tags.length - 1) {
prevTag = tags[currIdx + 1];
} else if (tags.length > 0 && currIdx === -1) {
prevTag = tags[0];
}
} catch (e) {
console.warn('[Release] Unable to determine previous git tag:', e.message);
}
let commits = [];
try {
const range = prevTag ? `${prevTag}..HEAD` : 'HEAD';
const logOutput = execSync(`git log ${range} --pretty=format:"%h%x09%s"`, { encoding: 'utf8' });
commits = logOutput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [hash, ...rest] = line.split('\t');
return { hash, subject: rest.join('\t') };
});
} catch (e) {
console.warn('[Release] Unable to retrieve git log:', e.message);
}
// 按照 Conventional Commits 规范分类
const categories = {
features: { title: '🚀 新功能 (Features)', items: [] },
ui: { title: '🎨 界面与主题 (UI & Themes)', items: [] },
fixes: { title: '🐛 问题修复 (Bug Fixes)', items: [] },
docs: { title: '📖 文档与说明 (Documentation)', items: [] },
ci: { title: '🔧 构建与 CI (Build & CI)', items: [] },
other: { title: '📝 其他变动 (Other Changes)', items: [] },
};
for (const c of commits) {
const s = c.subject;
const item = repoUrl
? `* [${c.hash}](${repoUrl}/commit/${c.hash}) - ${s}`
: `* ${c.hash} - ${s}`;
if (/^(style|theme)(\(.*\))?:/i.test(s) || /theme|主题|配色/i.test(s)) {
categories.ui.items.push(item);
} else if (/^feat(\(.*\))?:/i.test(s)) {
categories.features.items.push(item);
} else if (/^fix(\(.*\))?:/i.test(s)) {
categories.fixes.items.push(item);
} else if (/^docs?(\(.*\))?:/i.test(s)) {
categories.docs.items.push(item);
} else if (/^(ci|chore|build|refactor|perf)(\(.*\))?:/i.test(s)) {
categories.ci.items.push(item);
} else {
categories.other.items.push(item);
}
}
let changelogSection = '';
let hasItems = false;
for (const key of Object.keys(categories)) {
const cat = categories[key];
if (cat.items.length > 0) {
hasItems = true;
changelogSection += `#### ${cat.title}\n\n${cat.items.join('\n')}\n\n`;
}
}
if (!hasItems) {
changelogSection = '暂无详细更新记录。\n\n';
}
// 产物文件大小与 SHA-256 校验和表格
let checksumSection = '';
if (files && files.length > 0 && distDir) {
checksumSection = '### 📦 安装包校验信息 (Checksums)\n\n| 文件名 | 大小 | SHA-256 校验和 |\n| :--- | :--- | :--- |\n';
for (const file of files) {
const filePath = path.join(distDir, file);
if (fs.existsSync(filePath)) {
const stat = fs.statSync(filePath);
const sizeStr = (stat.size / (1024 * 1024)).toFixed(2) + ' MB';
const hash = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
checksumSection += `| \`${file}\` | ${sizeStr} | \`${hash}\` |\n`;
}
}
checksumSection += '\n';
}
return `## ⚡ hatty ${tag} 发布说明\n\n### 🌟 更新日志 (What's Changed)\n\n${prevTag && repoUrl ? `> 对比基准版本:[${prevTag}](${repoUrl}/src/tag/${prevTag}) → **${tag}**\n\n` : ''}${changelogSection}### 💻 安装方法 (Installation)\n\n下载下方附件中的 \`.deb\` 安装包,并在终端执行安装:\n\n\`\`\`bash\n# 安装 deb 安装包\nsudo dpkg -i hatty_*.deb\n\n# 如遇到缺少系统依赖,可执行自动修复:\nsudo apt-get install -f -y\n\`\`\`\n\n${checksumSection}---\n*由 Gitea Actions 自动化流水线构建并发布。*`;
}
async function main() {
const serverUrl = process.env.GITHUB_SERVER_URL || process.env.GITEA_SERVER_URL || 'https://git.zzzjy.cloud';
@@ -16,11 +118,31 @@ async function main() {
}
const [owner, repoName] = repo.split('/');
const apiBase = `${serverUrl.replace(/\/+$/, '')}/api/v1/repos/${owner}/${repoName}`;
const normalizedServer = serverUrl.replace(/\/+$/, '');
const repoUrl = `${normalizedServer}/${owner}/${repoName}`;
const apiBase = `${normalizedServer}/api/v1/repos/${owner}/${repoName}`;
console.log(`Connecting to Gitea at ${serverUrl} for repository ${repo}...`);
// 1. 检查或创建 Release
// 1. 查找 dist/ 下的 .deb / .AppImage 安装包并生成 Release Notes
const distDir = path.resolve('dist');
if (!fs.existsSync(distDir)) {
console.error('dist directory does not exist.');
process.exit(1);
}
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
if (files.length === 0) {
console.error('No .deb or .AppImage files found in dist directory.');
process.exit(1);
}
const releaseNotes = generateReleaseNotes({ tag, files, distDir, repoUrl });
console.log('--- Generated Release Notes ---');
console.log(releaseNotes);
console.log('-------------------------------');
// 2. 检查或创建 / 更新 Release
let releaseId = null;
const tagCheckRes = await fetch(`${apiBase}/releases/tags/${encodeURIComponent(tag)}`, {
headers: {
@@ -32,7 +154,24 @@ async function main() {
if (tagCheckRes.ok) {
const existing = await tagCheckRes.json();
releaseId = existing.id;
console.log(`Found existing release for tag ${tag} (id: ${releaseId})`);
console.log(`Found existing release for tag ${tag} (id: ${releaseId}), updating release notes...`);
const updateRes = await fetch(`${apiBase}/releases/${releaseId}`, {
method: 'PATCH',
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
name: `hatty ${tag}`,
body: releaseNotes,
}),
});
if (!updateRes.ok) {
console.warn(`Warning: Failed to update release body: ${updateRes.status}`);
} else {
console.log(`Release notes updated successfully for id ${releaseId}`);
}
} else {
console.log(`Creating new release for tag ${tag}...`);
const createRes = await fetch(`${apiBase}/releases`, {
@@ -45,7 +184,7 @@ async function main() {
body: JSON.stringify({
tag_name: tag,
name: `hatty ${tag}`,
body: `### ⚡ hatty ${tag} 现代化 Linux 终端新版本发布\n\n- **架构**:Electron + xterm.js (WebGL 硬件加速) + Linux PTY\n- **特性**:无边框磨砂毛玻璃、5 套现代暗黑主题、多标签页、灵活多窗格分屏\n- **安装说明**:下载下方附件 \`.deb\` 文件,在终端运行 \`sudo dpkg -i hatty_*.deb\` 即可完成安装。`,
body: releaseNotes,
draft: false,
prerelease: false,
}),
@@ -62,20 +201,7 @@ async function main() {
console.log(`Release created successfully with id ${releaseId}`);
}
// 2. 查找 dist/ 下的 .deb 安装包
const distDir = path.resolve('dist');
if (!fs.existsSync(distDir)) {
console.error('dist directory does not exist.');
process.exit(1);
}
const files = fs.readdirSync(distDir).filter((f) => f.endsWith('.deb') || f.endsWith('.AppImage'));
if (files.length === 0) {
console.error('No .deb or .AppImage files found in dist directory.');
process.exit(1);
}
// 检查已存在的附件,若同名则先删除以支持重试
// 3. 检查已存在的附件,若同名则先删除以支持重试
try {
const assetsRes = await fetch(`${apiBase}/releases/${releaseId}/assets`, {
headers: {
@@ -99,7 +225,7 @@ async function main() {
console.warn('Checking existing assets notice:', e.message);
}
// 3. 上传安装包到 Release 附件
// 4. 上传安装包到 Release 附件
for (const fileName of files) {
const filePath = path.join(distDir, fileName);
const fileStat = fs.statSync(filePath);
+142 -1
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
const path = require('path');
const os = require('os');
const { PtyManager } = require('./pty-manager.cjs');
@@ -101,6 +101,147 @@ ipcMain.on('pty:kill', (event, { id }) => {
if (ptyManager) ptyManager.kill({ id });
});
// 剪贴板 IPC
ipcMain.handle('clipboard:read', () => {
return clipboard.readText();
});
ipcMain.on('clipboard:write', (event, text) => {
clipboard.writeText(text || '');
});
// 获取 PTY 实时工作目录
ipcMain.handle('pty:get-cwd', (event, { id }) => {
return ptyManager ? ptyManager.getCwd({ id }) : null;
});
// 检查可执行命令路径
const fs = require('fs');
function findExecutable(command) {
const isWindows = process.platform === 'win32';
const exts = isWindows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
const dirs = (process.env.PATH || '').split(path.delimiter);
const extraDirs = [
path.join(os.homedir(), '.local/bin'),
path.join(os.homedir(), '.npm-global/bin'),
path.join(os.homedir(), '.cargo/bin'),
'/usr/local/bin',
'/usr/bin',
'/bin',
];
const allDirs = [...new Set([...dirs, ...extraDirs])];
for (const dir of allDirs) {
for (const ext of exts) {
const fullPath = path.join(dir, command + ext);
try {
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
} catch (_) {}
}
}
return null;
}
// 检查已安装的 Agent (agy / codex / claude)
ipcMain.handle('app:get-installed-agents', () => {
const agentDefs = [
{ id: 'agy', name: 'agy', cmd: 'agy', desc: 'Antigravity CLI Agent' },
{ id: 'codex', name: 'codex', cmd: 'codex', desc: 'OpenAI CodeX CLI' },
{ id: 'claude', name: 'claude', cmd: 'claude', desc: 'Claude Code Agent' },
];
return agentDefs.map((agent) => {
const execPath = findExecutable(agent.cmd);
return {
...agent,
path: execPath,
installed: !!execPath,
};
});
});
// 文件浏览 IPC:读取目录中的文件和文件夹
ipcMain.handle('fs:read-dir', async (event, { dirPath, showHidden = false } = {}) => {
try {
let targetPath = dirPath || '~';
if (targetPath === '~' || targetPath.startsWith('~/')) {
targetPath = path.join(os.homedir(), targetPath.slice(targetPath === '~' ? 1 : 2));
}
targetPath = path.resolve(targetPath);
if (!fs.existsSync(targetPath)) {
return { success: false, error: '目录不存在: ' + targetPath, dirPath: targetPath };
}
const stat = await fs.promises.stat(targetPath);
if (!stat.isDirectory()) {
return { success: false, error: '路径不是有效目录: ' + targetPath, dirPath: targetPath };
}
const dirents = await fs.promises.readdir(targetPath, { withFileTypes: true });
const entries = [];
for (const d of dirents) {
const isHidden = d.name.startsWith('.');
if (!showHidden && isHidden) continue;
let isDirectory = d.isDirectory();
let isFile = d.isFile();
let isSymbolicLink = d.isSymbolicLink();
let size = 0;
let mtime = 0;
const fullItemPath = path.join(targetPath, d.name);
if (isSymbolicLink) {
try {
const realStat = await fs.promises.stat(fullItemPath);
isDirectory = realStat.isDirectory();
isFile = realStat.isFile();
size = realStat.size;
mtime = realStat.mtimeMs;
} catch (_) {}
} else {
try {
if (isFile) {
const fStat = await fs.promises.stat(fullItemPath);
size = fStat.size;
mtime = fStat.mtimeMs;
}
} catch (_) {}
}
entries.push({
name: d.name,
path: fullItemPath,
isDirectory,
isFile,
isSymbolicLink,
isHidden,
size,
mtime,
});
}
// 排序:文件夹优先,同类按文件名升序排列
entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true });
});
const parentPath = path.dirname(targetPath);
return {
success: true,
dirPath: targetPath,
parentPath: parentPath !== targetPath ? parentPath : null,
entries,
};
} catch (err) {
return { success: false, error: err.message, dirPath };
}
});
ipcMain.handle('app:get-system-info', () => {
return {
platform: os.platform(),
+38 -4
View File
@@ -1,5 +1,6 @@
const pty = require('node-pty');
const os = require('os');
const path = require('path');
class PtyManager {
constructor(mainWindow) {
@@ -8,17 +9,38 @@ class PtyManager {
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
}
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd() }) {
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd(), shell, args = [] }) {
if (this.ptys.has(id)) {
this.kill({ id });
}
try {
const ptyProcess = pty.spawn(this.defaultShell, [], {
let resolvedCwd = cwd || process.env.HOME || process.cwd();
if (typeof resolvedCwd === 'string') {
if (resolvedCwd === '~') {
resolvedCwd = os.homedir();
} else if (resolvedCwd.startsWith('~/') || resolvedCwd.startsWith('~\\')) {
resolvedCwd = path.join(os.homedir(), resolvedCwd.slice(2));
}
}
const fs = require('fs');
try {
if (!resolvedCwd || !fs.existsSync(resolvedCwd) || !fs.statSync(resolvedCwd).isDirectory()) {
resolvedCwd = os.homedir() || process.cwd();
}
} catch (e) {
resolvedCwd = os.homedir() || process.cwd();
}
const targetShell = shell || this.defaultShell;
const targetArgs = Array.isArray(args) ? args : [];
const ptyProcess = pty.spawn(targetShell, targetArgs, {
name: 'xterm-256color',
cols,
rows,
cwd,
cwd: resolvedCwd,
env: {
...process.env,
TERM: 'xterm-256color',
@@ -40,7 +62,7 @@ class PtyManager {
});
this.ptys.set(id, ptyProcess);
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell };
return { success: true, pid: ptyProcess.pid, shell: targetShell };
} catch (err) {
console.error(`Failed to spawn pty [${id}]:`, err);
return { success: false, error: err.message };
@@ -77,6 +99,18 @@ class PtyManager {
}
}
getCwd({ id }) {
const ptyProcess = this.ptys.get(id);
if (!ptyProcess || !ptyProcess.pid) return null;
try {
if (process.platform === 'linux') {
const fs = require('fs');
return fs.readlinkSync(`/proc/${ptyProcess.pid}/cwd`);
}
} catch (_) {}
return null;
}
killAll() {
for (const [id, ptyProcess] of this.ptys.entries()) {
try {
+11
View File
@@ -32,4 +32,15 @@ contextBridge.exposeInMainWorld('terminalAPI', {
// 系统信息
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
// 剪贴板管理
readClipboard: () => ipcRenderer.invoke('clipboard:read'),
writeClipboard: (text) => ipcRenderer.send('clipboard:write', text),
// PTY 与 Agent 查询
getPtyCwd: (id) => ipcRenderer.invoke('pty:get-cwd', { id }),
getInstalledAgents: () => ipcRenderer.invoke('app:get-installed-agents'),
// 文件浏览
readDir: (options) => ipcRenderer.invoke('fs:read-dir', options),
});
+104 -11
View File
@@ -28,26 +28,47 @@ body {
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
/* 顶部无边框拖拽标题栏 */
/* 顶部无边框拖拽标题栏 (与标签栏合二为一) */
.titlebar {
height: var(--titlebar-height);
background: var(--bg-titlebar);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag; /* 允许窗口拖拽 */
position: relative;
z-index: 100;
padding: 0 8px;
padding: 0;
padding-right: 8px;
box-sizing: border-box;
}
.titlebar-left {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
height: 100%;
padding: 0 8px;
width: var(--sidebar-width, 250px);
min-width: var(--sidebar-width, 250px);
max-width: var(--sidebar-width, 250px);
border-right: 1px solid var(--border-color);
-webkit-app-region: no-drag;
overflow: hidden;
-webkit-app-region: drag; /* 允许左侧空白和品牌区域拖拽 */
box-sizing: border-box;
flex-shrink: 0;
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
#app.sidebar-collapsed .titlebar-left,
.titlebar-left.sidebar-collapsed {
width: auto;
min-width: 0;
max-width: none;
border-right: none;
padding: 0 8px;
}
.app-brand {
@@ -57,10 +78,68 @@ body {
font-size: 13px;
font-weight: 700;
color: var(--accent-color);
padding: 0 8px;
padding: 0 4px;
letter-spacing: 0.5px;
-webkit-app-region: drag;
cursor: grab;
flex-shrink: 0;
}
/* 顶部标题栏工作区标识 */
.titlebar-workspace-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
height: 22px;
background: var(--bg-tab-inactive);
border: 1px solid var(--border-color);
border-radius: 12px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
user-select: none;
-webkit-app-region: no-drag !important;
transition: all 0.15s ease;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
}
.titlebar-workspace-tag:hover {
background: var(--bg-tab-hover);
color: var(--text-main);
border-color: var(--border-active);
}
.ws-tag-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-color);
box-shadow: 0 0 6px var(--accent-glow);
flex-shrink: 0;
}
.ws-tag-name {
font-weight: 500;
max-width: 95px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 标签页栏容器 (位于顶栏中央,工作区展开时与终端左侧像素对齐) */
.tab-bar-container {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: calc(100vw - 360px);
padding: 0;
height: 100%;
-webkit-app-region: no-drag !important;
box-sizing: border-box;
}
.titlebar-drag-spacer {
@@ -68,7 +147,7 @@ body {
height: 100%;
-webkit-app-region: drag; /* 中间大面积空白区域允许拖拽 */
cursor: grab;
min-width: 40px;
min-width: 20px;
}
.app-brand-icon {
@@ -95,10 +174,17 @@ body {
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag !important;
flex-shrink: 0;
}
.btn-icon svg,
.btn-icon svg * {
pointer-events: none !important;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
}
@@ -112,7 +198,7 @@ body {
align-items: center;
gap: 2px;
margin-left: 6px;
-webkit-app-region: no-drag;
-webkit-app-region: no-drag !important;
}
.win-btn {
@@ -127,10 +213,16 @@ body {
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
-webkit-app-region: no-drag !important;
}
.win-btn svg,
.win-btn svg * {
pointer-events: none !important;
}
.win-btn:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
}
@@ -139,9 +231,10 @@ body {
color: #ffffff;
}
/* 主内容区 */
/* 主内容区终端视口 */
.main-viewport {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
background: transparent;
+536 -4
View File
@@ -33,7 +33,7 @@
}
.statusbar-item.clickable:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
}
@@ -118,7 +118,7 @@
.palette-item.selected,
.palette-item:hover {
background: var(--accent-color);
color: #0f141c;
color: var(--accent-text, #0f141c);
font-weight: 600;
}
@@ -130,9 +130,10 @@
.palette-shortcut {
font-size: 11px;
opacity: 0.7;
opacity: 0.85;
font-family: var(--font-mono);
background: rgba(0, 0, 0, 0.2);
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
color: inherit;
padding: 2px 6px;
border-radius: 3px;
}
@@ -211,6 +212,11 @@
border-color: var(--border-active);
}
.setting-control select option {
background: var(--bg-titlebar);
color: var(--text-bright);
}
/* 终端内搜索条 */
.search-bar {
position: absolute;
@@ -255,3 +261,529 @@
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 终端右键上下文菜单 */
.terminal-context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-titlebar);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md, 6px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5), 0 0 1px rgba(255, 255, 255, 0.1);
min-width: 200px;
padding: 4px;
display: none;
flex-direction: column;
gap: 2px;
user-select: none;
font-size: 12px;
color: var(--text-color);
animation: fadeIn 0.1s ease;
backdrop-filter: blur(12px);
}
.terminal-context-menu.active {
display: flex;
}
.ctx-menu-header {
padding: 6px 8px 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.ctx-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: var(--border-radius-sm, 4px);
cursor: pointer;
transition: background 0.12s, color 0.12s;
color: var(--text-color);
}
.ctx-menu-item:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-bright);
}
.ctx-menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ctx-menu-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.ctx-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--text-muted);
flex-shrink: 0;
}
.ctx-menu-item:hover:not(.disabled) .ctx-menu-icon {
color: var(--border-active, #3b82f6);
}
.ctx-menu-label {
font-size: 12px;
}
.ctx-menu-shortcut {
font-size: 10px;
color: var(--text-muted);
margin-left: 12px;
opacity: 0.8;
}
.ctx-menu-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
margin-left: 12px;
letter-spacing: 0.2px;
white-space: nowrap;
}
.ctx-menu-divider {
height: 1px;
background: var(--border-color);
margin: 4px 2px;
opacity: 0.6;
}
#ctx-close-term:hover:not(.disabled) {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
#ctx-close-term:hover:not(.disabled) .ctx-menu-icon {
color: #ef4444;
}
/* 顶部/全局图标按钮禁用状态:变灰且不可点击 */
.btn-icon:disabled,
.btn-icon.disabled {
opacity: 0.28 !important;
cursor: not-allowed !important;
pointer-events: none !important;
filter: grayscale(1);
}
/* 专属 Agent 面板 (AgentPanel) */
.agent-panel {
display: flex;
flex-direction: column;
background: var(--bg-app);
border-left: 1px solid var(--border-color);
min-width: 240px;
position: relative;
overflow: hidden;
}
.agent-panel-header {
height: 28px;
min-height: 28px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px 0 10px;
user-select: none;
}
.agent-panel-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.agent-panel-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--accent-color, #7aa2f7);
text-transform: uppercase;
flex-shrink: 0;
}
.agent-panel-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
min-width: 0;
}
.agent-panel-tabs::-webkit-scrollbar {
display: none;
}
/* Agent 迷你小标签页: 紧凑小巧,主要展示对应 agent 图标与关闭按钮 */
.agent-tab {
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px;
background: var(--bg-tab-inactive, rgba(255, 255, 255, 0.04));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 4px);
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-muted);
flex-shrink: 0;
}
.agent-tab:hover {
background: var(--bg-tab-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
border-color: var(--border-hover, rgba(255, 255, 255, 0.15));
}
.agent-tab.active {
background: var(--bg-tab-active, rgba(59, 130, 246, 0.15));
border-color: var(--border-active, #3b82f6);
color: var(--accent-color, #7aa2f7);
box-shadow: 0 0 8px var(--accent-glow, rgba(59, 130, 246, 0.25));
}
.agent-tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
transition: color 0.15s ease, opacity 0.15s ease;
}
.agent-tab-icon svg {
width: 14px;
height: 14px;
}
/* Agent 品牌特化专属色彩与微光效果 */
.agent-tab[data-agent-id="agy"] .agent-tab-icon,
.ctx-menu-item#ctx-open-agy .ctx-menu-icon {
color: #4285f4;
}
.agent-tab[data-agent-id="codex"] .agent-tab-icon,
.ctx-menu-item#ctx-open-codex .ctx-menu-icon {
color: #f1f5f9;
}
[data-theme="light"] .agent-tab[data-agent-id="codex"] .agent-tab-icon,
[data-theme="light"] .ctx-menu-item#ctx-open-codex .ctx-menu-icon {
color: #000000;
}
.agent-tab[data-agent-id="claude"] .agent-tab-icon,
.ctx-menu-item#ctx-open-claude .ctx-menu-icon {
color: #d97757;
}
.agent-tab:not(.active) .agent-tab-icon {
opacity: 0.85;
}
.agent-tab:hover .agent-tab-icon,
.agent-tab.active .agent-tab-icon {
opacity: 1;
}
.agent-tab.active[data-agent-id="agy"] {
background: rgba(66, 133, 244, 0.16);
border-color: #4285f4;
box-shadow: 0 0 8px rgba(66, 133, 244, 0.35);
}
.agent-tab.active[data-agent-id="codex"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
}
[data-theme="light"] .agent-tab.active[data-agent-id="codex"] {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.agent-tab.active[data-agent-id="claude"] {
background: rgba(217, 119, 87, 0.16);
border-color: #d97757;
box-shadow: 0 0 8px rgba(217, 119, 87, 0.3);
}
/* 标签做小,只展示对应 agent 图标 (文字隐藏,hover显示title) */
.agent-tab-name {
display: none;
}
.agent-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
margin-left: 2px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
border-radius: 2px;
cursor: pointer;
opacity: 0.6;
transition: all 0.12s ease;
}
.agent-tab-close:hover {
opacity: 1;
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.agent-panel-header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.agent-panel-close-btn {
width: 20px;
height: 20px;
padding: 0;
}
.agent-panel-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.agent-panel-body {
flex: 1;
width: 100%;
height: calc(100% - 28px);
position: relative;
overflow: hidden;
display: flex;
}
.agent-tab-view {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: none;
}
.agent-tab-view.active {
display: flex;
}
/* 文件浏览面板 (FilePanel) */
.file-panel {
display: flex;
flex-direction: column;
background: var(--bg-app);
border-right: 1px solid var(--border-color);
width: 240px;
min-width: 160px;
max-width: 500px;
height: 100%;
position: relative;
overflow: hidden;
user-select: none;
flex-shrink: 0;
}
.file-panel-header {
height: 28px;
min-height: 28px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px 0 10px;
}
.file-panel-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.file-panel-header-icon {
color: var(--accent-color, #7aa2f7);
flex-shrink: 0;
}
.file-panel-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--accent-color, #7aa2f7);
text-transform: uppercase;
white-space: nowrap;
}
.file-panel-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.file-panel-header-actions .btn-icon {
width: 22px;
height: 22px;
border-radius: var(--border-radius-sm, 4px);
}
.file-toggle-hidden {
transition: all 0.15s ease;
}
.file-toggle-hidden.active {
background: rgba(59, 130, 246, 0.2) !important;
color: var(--accent-color, #7aa2f7) !important;
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
}
.file-panel-pathbar {
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono, monospace);
color: var(--text-muted);
background: rgba(0, 0, 0, 0.18);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
}
.file-panel-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-main);
cursor: pointer;
border-radius: var(--border-radius-sm, 4px);
transition: background 0.12s ease, color 0.12s ease;
}
.file-item:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text-bright);
}
.file-item.selected {
background: rgba(59, 130, 246, 0.18);
color: var(--accent-color, #7aa2f7);
}
.file-item.is-hidden {
opacity: 0.55;
font-style: italic;
}
.file-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.file-item.is-directory .file-item-icon {
color: #e0af68;
}
.file-item.is-file .file-item-icon {
color: var(--text-muted);
}
.file-item-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-size {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
}
.file-panel-empty,
.file-panel-error {
padding: 20px 10px;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
.file-panel-error {
color: #f87171;
}
.file-panel-footer {
height: 22px;
min-height: 22px;
background: var(--bg-titlebar);
border-top: 1px solid var(--border-color);
padding: 0 8px;
display: flex;
align-items: center;
font-size: 10px;
color: var(--text-muted);
}
+387
View File
@@ -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));
}
+19 -4
View File
@@ -5,8 +5,10 @@
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
max-width: calc(100vw - 320px);
-webkit-app-region: no-drag;
flex: 0 1 auto;
min-width: 0;
padding: 0 6px;
-webkit-app-region: no-drag !important;
}
.tab-bar::-webkit-scrollbar {
@@ -30,6 +32,12 @@
white-space: nowrap;
max-width: 180px;
min-width: 90px;
-webkit-app-region: no-drag !important;
}
.tab-item svg,
.tab-item svg * {
pointer-events: none !important;
}
.tab-item:hover {
@@ -66,6 +74,8 @@
border-radius: 50%;
opacity: 0;
transition: all 0.12s ease;
-webkit-app-region: no-drag !important;
pointer-events: auto !important;
}
.tab-item:hover .tab-close,
@@ -92,10 +102,15 @@
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
-webkit-app-region: no-drag;
-webkit-app-region: no-drag !important;
}
.new-tab-btn svg,
.new-tab-btn svg * {
pointer-events: none !important;
}
.new-tab-btn:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--accent-color);
}
+118
View File
@@ -18,12 +18,17 @@
--titlebar-height: 38px;
--statusbar-height: 26px;
--sidebar-width: 250px;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--terminal-font-size: 14px;
--terminal-line-height: 1.25;
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
--accent-text: #0f141c;
}
/* Tokyo Night */
@@ -36,8 +41,11 @@
--border-active: #7aa2f7;
--text-main: #c0caf5;
--text-muted: #565f89;
--text-bright: #ffffff;
--accent-color: #7aa2f7;
--accent-glow: rgba(122, 162, 247, 0.3);
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
}
/* Catppuccin Mocha */
@@ -50,8 +58,11 @@
--border-active: #cba6f7;
--text-main: #cdd6f4;
--text-muted: #6c7086;
--text-bright: #ffffff;
--accent-color: #cba6f7;
--accent-glow: rgba(203, 166, 247, 0.3);
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
}
/* Dracula */
@@ -64,8 +75,11 @@
--border-active: #bd93f9;
--text-main: #f8f8f2;
--text-muted: #6272a4;
--text-bright: #ffffff;
--accent-color: #ff79c6;
--accent-glow: rgba(255, 121, 198, 0.3);
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
}
/* One Dark */
@@ -78,8 +92,11 @@
--border-active: #61afef;
--text-main: #abb2bf;
--text-muted: #5c6370;
--text-bright: #ffffff;
--accent-color: #61afef;
--accent-glow: rgba(97, 175, 239, 0.3);
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
}
/* Cyberpunk Neon */
@@ -92,6 +109,107 @@
--border-active: #00ffcc;
--text-main: #e2f0fb;
--text-muted: #4e6a88;
--text-bright: #ffffff;
--accent-color: #ff007f;
--accent-glow: rgba(255, 0, 127, 0.4);
--btn-hover: rgba(255, 255, 255, 0.08);
--kbd-bg: rgba(0, 0, 0, 0.2);
--accent-text: #ffffff;
}
/* ==================== 高品质浅色主题系列 ==================== */
/* Catppuccin Latte */
[data-theme="catppuccin-latte"] {
--bg-app: rgba(239, 241, 245, 0.94);
--bg-titlebar: rgba(230, 233, 239, 0.96);
--bg-tab-active: rgba(220, 224, 232, 0.98);
--bg-tab-inactive: rgba(239, 241, 245, 0.65);
--bg-tab-hover: rgba(230, 233, 239, 0.9);
--border-color: rgba(76, 79, 105, 0.15);
--border-active: #1e66f5;
--text-main: #4c4f69;
--text-muted: #7c7f93;
--text-bright: #1e1e2e;
--accent-color: #1e66f5;
--accent-glow: rgba(30, 102, 245, 0.25);
--btn-hover: rgba(0, 0, 0, 0.06);
--kbd-bg: rgba(0, 0, 0, 0.06);
--accent-text: #ffffff;
}
/* GitHub Light */
[data-theme="github-light"] {
--bg-app: rgba(255, 255, 255, 0.96);
--bg-titlebar: rgba(246, 248, 250, 0.98);
--bg-tab-active: rgba(255, 255, 255, 0.98);
--bg-tab-inactive: rgba(246, 248, 250, 0.7);
--bg-tab-hover: rgba(240, 242, 245, 0.9);
--border-color: rgba(31, 35, 40, 0.15);
--border-active: #0969da;
--text-main: #24292f;
--text-muted: #6e7781;
--text-bright: #1f2328;
--accent-color: #0969da;
--accent-glow: rgba(9, 105, 218, 0.25);
--btn-hover: rgba(0, 0, 0, 0.06);
--kbd-bg: rgba(0, 0, 0, 0.06);
--accent-text: #ffffff;
}
/* Tokyo Night Day */
[data-theme="tokyo-night-day"] {
--bg-app: rgba(225, 226, 231, 0.94);
--bg-titlebar: rgba(213, 214, 219, 0.96);
--bg-tab-active: rgba(235, 236, 240, 0.98);
--bg-tab-inactive: rgba(225, 226, 231, 0.65);
--bg-tab-hover: rgba(213, 214, 219, 0.9);
--border-color: rgba(55, 96, 191, 0.15);
--border-active: #2e7de9;
--text-main: #3760bf;
--text-muted: #6172b0;
--text-bright: #182848;
--accent-color: #2e7de9;
--accent-glow: rgba(46, 125, 233, 0.25);
--btn-hover: rgba(0, 0, 0, 0.06);
--kbd-bg: rgba(0, 0, 0, 0.06);
--accent-text: #ffffff;
}
/* Solarized Light */
[data-theme="solarized-light"] {
--bg-app: rgba(253, 246, 227, 0.94);
--bg-titlebar: rgba(238, 232, 213, 0.96);
--bg-tab-active: rgba(245, 239, 223, 0.98);
--bg-tab-inactive: rgba(253, 246, 227, 0.65);
--bg-tab-hover: rgba(238, 232, 213, 0.9);
--border-color: rgba(101, 123, 131, 0.18);
--border-active: #268bd2;
--text-main: #657b83;
--text-muted: #93a1a1;
--text-bright: #073642;
--accent-color: #268bd2;
--accent-glow: rgba(38, 139, 210, 0.25);
--btn-hover: rgba(0, 0, 0, 0.06);
--kbd-bg: rgba(0, 0, 0, 0.06);
--accent-text: #ffffff;
}
/* One Light */
[data-theme="one-light"] {
--bg-app: rgba(250, 250, 250, 0.95);
--bg-titlebar: rgba(240, 240, 240, 0.98);
--bg-tab-active: rgba(255, 255, 255, 0.98);
--bg-tab-inactive: rgba(240, 240, 240, 0.7);
--bg-tab-hover: rgba(230, 230, 230, 0.9);
--border-color: rgba(56, 58, 66, 0.15);
--border-active: #4078f2;
--text-main: #383a42;
--text-muted: #6f7179;
--text-bright: #202227;
--accent-color: #4078f2;
--accent-glow: rgba(64, 120, 242, 0.25);
--btn-hover: rgba(0, 0, 0, 0.06);
--kbd-bg: rgba(0, 0, 0, 0.06);
--accent-text: #ffffff;
}
+92 -7
View File
@@ -13,12 +13,21 @@
<link rel="stylesheet" href="css/tabs.css">
<link rel="stylesheet" href="css/split.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/sidebar.css">
</head>
<body>
<div id="app">
<!-- 顶部现代拖拽式标题栏 -->
<header class="titlebar">
<div class="titlebar-left">
<div id="app" class="sidebar-collapsed">
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
<header class="titlebar" id="titlebar">
<div class="titlebar-left sidebar-collapsed" id="titlebar-left">
<!-- 侧边栏折叠/展开按钮 -->
<button class="btn-icon btn-toggle-sidebar" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
</button>
<div class="app-brand">
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="4 17 10 11 4 5"></polyline>
@@ -27,7 +36,15 @@
<span>hatty</span>
</div>
<!-- 标签页栏 -->
<!-- 当前活跃工作区标识 (上层结构指示) -->
<div class="titlebar-workspace-tag" id="titlebar-workspace-tag" title="当前激活工作区">
<span class="ws-tag-dot"></span>
<span class="ws-tag-name" id="titlebar-ws-name">本机电脑 (Local PC)</span>
</div>
</div>
<!-- 标签页栏与新建按钮 -->
<div class="tab-bar-container" id="tab-bar-container">
<div class="tab-bar" id="tab-bar"></div>
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -54,6 +71,17 @@
<line x1="3" y1="12" x2="21" y2="12"></line>
</svg>
</button>
<button class="btn-icon" id="btn-file-browser" title="文件浏览面板 (Ctrl+Shift+U)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
</button>
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button class="btn-icon" id="btn-palette" title="快速命令面板 (Ctrl+Shift+P / F1)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
@@ -89,8 +117,50 @@
</div>
</header>
<!-- 终端主视口 -->
<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">
@@ -140,6 +210,21 @@
<div class="modal-overlay" id="settings-overlay">
<div class="settings-modal" id="settings-modal"></div>
</div>
<!-- 工作区创建/编辑模态框 -->
<div class="modal-overlay" id="workspace-modal-overlay">
<div class="settings-modal workspace-modal">
<div class="settings-header">
<h3 id="workspace-modal-title">新建工作区</h3>
<button class="btn-icon" id="btn-close-workspace-modal">&times;</button>
</div>
<div class="settings-body" id="workspace-modal-body"></div>
<div class="modal-footer">
<button class="btn-secondary" id="btn-cancel-workspace">取消</button>
<button class="btn-primary" id="btn-save-workspace">保存并进入</button>
</div>
</div>
</div>
</div>
<script type="module" src="js/app.js"></script>
+265
View File
@@ -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}">&times;</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
View File
@@ -1,22 +1,23 @@
import { themeManager } from './theme-manager.js';
import { TabManager } from './tab-manager.js';
import { WorkspaceManager } from './workspace-manager.js';
import { CommandPalette } from './command-palette.js';
import { SettingsModal } from './settings.js';
import { ContextMenu } from './context-menu.js';
class TerminalApp {
constructor() {
this.initElements();
this.initTheme();
this.initTabManager();
this.initWorkspaceManager();
this.initContextMenu();
this.initModals();
this.initSearch();
this.initWindowControls();
this.initIpcListeners();
this.initKeyboardShortcuts();
this.initStatusBar();
// 创建初始标签页
this.tabManager.createTab();
}
initElements() {
@@ -55,15 +56,65 @@ class TerminalApp {
this.tabManager = new TabManager({
tabBar: this.tabBar,
viewport: this.viewport,
getSpawnOptions: (wsId) => (this.workspaceManager ? this.workspaceManager.getSpawnOptions(wsId) : {}),
onActiveChange: (activeTerm) => {
if (activeTerm && this.statusShell) {
this.statusShell.textContent = activeTerm.title || 'bash';
}
this.updateSplitButtonsState(activeTerm);
},
onContextMenu: (e, term) => {
this.contextMenu?.show(e, term);
},
});
this.newTabBtn.addEventListener('click', () => {
this.tabManager.createTab();
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
this.tabManager.createTab({
workspaceId: activeWs?.id,
title: spawnOpts.title,
cwd: spawnOpts.cwd,
shell: spawnOpts.shell,
args: spawnOpts.args,
});
});
}
updateSplitButtonsState(activeTerm) {
const isAgent = !!(
activeTerm?.isAgent ||
activeTerm?.element?.closest?.('.agent-panel') ||
activeTerm?.terminalWrapper?.closest?.('.agent-panel')
);
const btnH = document.getElementById('btn-split-h');
const btnV = document.getElementById('btn-split-v');
if (btnH) {
btnH.disabled = isAgent;
btnH.classList.toggle('disabled', isAgent);
btnH.title = isAgent ? 'Agent 面板不可分屏' : '水平分屏 (Ctrl+Shift+E)';
}
if (btnV) {
btnV.disabled = isAgent;
btnV.classList.toggle('disabled', isAgent);
btnV.title = isAgent ? 'Agent 面板不可分屏' : '垂直分屏 (Ctrl+Shift+O)';
}
}
initWorkspaceManager() {
this.workspaceManager = new WorkspaceManager({
tabManager: this.tabManager,
});
}
initContextMenu() {
this.contextMenu = new ContextMenu({
tabManager: this.tabManager,
workspaceManager: this.workspaceManager,
});
window.addEventListener('terminal-contextmenu', (e) => {
this.contextMenu?.show(e.detail.event, e.detail.termView);
});
}
@@ -73,6 +124,7 @@ class TerminalApp {
input: this.commandPaletteInput,
list: this.commandPaletteList,
tabManager: this.tabManager,
workspaceManager: this.workspaceManager,
onOpenSettings: () => this.settings.open(),
onToggleSearch: () => this.toggleSearch(),
});
@@ -94,12 +146,24 @@ class TerminalApp {
document.getElementById('btn-split-h')?.addEventListener('click', () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal');
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('horizontal');
}
});
document.getElementById('btn-split-v')?.addEventListener('click', () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical');
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('vertical');
}
});
document.getElementById('btn-file-browser')?.addEventListener('click', () => {
this.toggleFileBrowser();
});
document.getElementById('btn-search')?.addEventListener('click', () => {
this.toggleSearch();
});
document.getElementById('status-theme')?.addEventListener('click', () => {
@@ -157,6 +221,12 @@ class TerminalApp {
}
}
toggleFileBrowser(initialDir = null) {
const tab = this.tabManager?.getActiveTab();
if (!tab || !tab.splitTree) return null;
return tab.splitTree.toggleFilePanel(initialDir);
}
initWindowControls() {
document.getElementById('win-min')?.addEventListener('click', () => {
window.terminalAPI.minimize();
@@ -203,82 +273,149 @@ class TerminalApp {
}
initKeyboardShortcuts() {
window.addEventListener('keydown', (e) => {
// Ctrl+Shift+P 或 F1: 命令面板
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
e.preventDefault();
this.commandPalette.toggle();
return;
}
// Ctrl+Shift+T: 新建标签
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);
window.addEventListener(
'keydown',
(e) => {
// Ctrl+Shift+P 或 F1: 命令面板
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
e.preventDefault();
e.stopPropagation();
this.commandPalette.toggle();
return;
}
return;
}
// Ctrl+Shift+E: 水平分屏 (左右)
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
e.preventDefault();
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal');
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();
// Ctrl+B: 切换左侧工作区侧边栏
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b') {
e.preventDefault();
e.stopPropagation();
this.workspaceManager?.toggleSidebar();
return;
}
return;
}
// Alt+1 .. Alt+9: 快速直达标签页
if (e.altKey && e.key >= '1' && e.key <= '9') {
e.preventDefault();
const index = parseInt(e.key, 10) - 1;
const ids = Array.from(this.tabManager.tabs.keys());
if (ids[index]) {
this.tabManager.switchTab(ids[index]);
const isEditingText = (el) => {
if (!el) return false;
const tag = el.tagName?.toLowerCase();
return (tag === 'input' || tag === 'textarea') && !el.classList.contains('xterm-helper-textarea');
};
// Ctrl+Shift+C: 复制当前选中终端文本
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') {
if (isEditingText(e.target)) return;
e.preventDefault();
e.stopPropagation();
const activeTerm = this.tabManager.getActiveTerminal();
if (activeTerm) activeTerm.copySelection();
return;
}
}
});
// Ctrl+Shift+V: 粘贴剪贴板文本到当前终端
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'v') {
if (isEditingText(e.target)) return;
e.preventDefault();
e.stopPropagation();
const activeTerm = this.tabManager.getActiveTerminal();
if (activeTerm) activeTerm.pasteFromClipboard();
return;
}
// Ctrl+Shift+T: 新建当前工作区标签页
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
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() {
@@ -295,5 +432,5 @@ class TerminalApp {
// 页面加载就绪后启动终端
window.addEventListener('DOMContentLoaded', () => {
new TerminalApp();
window.terminalApp = new TerminalApp();
});
+61 -3
View File
@@ -6,6 +6,7 @@ export class CommandPalette {
this.input = options.input;
this.list = options.list;
this.tabManager = options.tabManager;
this.workspaceManager = options.workspaceManager;
this.onOpenSettings = options.onOpenSettings || (() => {});
this.onToggleSearch = options.onToggleSearch || (() => {});
@@ -19,12 +20,36 @@ export class CommandPalette {
initCommands() {
this.baseCommands = [
{
id: 'toggle-sidebar',
title: '切换工作区侧边栏 (Toggle Workspace Sidebar)',
shortcut: 'Ctrl+B',
icon: 'layout',
action: () => this.workspaceManager?.toggleSidebar(),
},
{
id: 'new-workspace',
title: '新建工作区: 本地PC / SSH远程 / 开发板 (New Workspace)',
shortcut: '',
icon: 'plus',
action: () => this.workspaceManager?.openCreateModal(),
},
{
id: 'new-tab',
title: '新建标签页 (New Tab)',
shortcut: 'Ctrl+Shift+T',
icon: 'plus',
action: () => this.tabManager.createTab(),
action: () => {
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
this.tabManager.createTab({
workspaceId: activeWs?.id,
title: spawnOpts.title,
cwd: spawnOpts.cwd,
shell: spawnOpts.shell,
args: spawnOpts.args,
});
},
},
{
id: 'split-horizontal',
@@ -33,7 +58,9 @@ export class CommandPalette {
icon: 'columns',
action: () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal');
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('horizontal');
}
},
},
{
@@ -43,7 +70,9 @@ export class CommandPalette {
icon: 'rows',
action: () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical');
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('vertical');
}
},
},
{
@@ -58,6 +87,15 @@ export class CommandPalette {
}
},
},
{
id: 'toggle-file-browser',
title: '切换文件浏览面板 (Toggle File Explorer)',
shortcut: 'Ctrl+Shift+U',
icon: 'folder',
action: () => {
this.tabManager?.toggleFileBrowser();
},
},
{
id: 'search',
title: '终端内容快速搜索 (Search Buffer)',
@@ -65,6 +103,26 @@ export class CommandPalette {
icon: 'search',
action: () => this.onToggleSearch(),
},
{
id: 'copy-selection',
title: '复制终端选中内容 (Copy Selection)',
shortcut: 'Ctrl+Shift+C',
icon: 'copy',
action: () => {
const term = this.tabManager.getActiveTerminal();
if (term) term.copySelection();
},
},
{
id: 'paste-clipboard',
title: '粘贴剪贴板内容到终端 (Paste Clipboard)',
shortcut: 'Ctrl+Shift+V',
icon: 'clipboard',
action: () => {
const term = this.tabManager.getActiveTerminal();
if (term) term.pasteFromClipboard();
},
},
{
id: 'clear',
title: '清空当前屏幕缓冲区 (Clear Buffer)',
+409
View File
@@ -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);
}
}
+297
View File
@@ -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();
}
}
+310 -5
View File
@@ -1,31 +1,39 @@
import { TerminalView } from './terminal-view.js';
import { AgentPanel } from './agent-panel.js';
import { FilePanel } from './file-panel.js';
export class TabSplitTree {
constructor(options = {}) {
this.container = options.container;
this.tabId = options.tabId;
this.spawnOptions = options.spawnOptions || {};
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
this.onEmpty = options.onEmpty || (() => {});
this.onTitleChange = options.onTitleChange || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.terminals = new Map(); // id -> { termView, paneElement, parentElement }
this.activeTerminal = null;
this.agentPanel = null; // 每个大标签页独占至多一个 AgentPanel
this.filePanel = null; // 每个大标签页独占至多一个 FilePanel
this.rootElement = document.createElement('div');
this.rootElement.className = 'split-container horizontal';
this.container.appendChild(this.rootElement);
// 创建初始窗格
this.addPane(this.rootElement);
this.addPane(this.rootElement, 'horizontal', this.spawnOptions);
}
addPane(parentElement, orientation = 'horizontal') {
addPane(parentElement, orientation = 'horizontal', paneSpawnOptions = {}) {
const paneElement = document.createElement('div');
paneElement.className = 'split-pane';
parentElement.appendChild(paneElement);
const termView = new TerminalView({
...this.spawnOptions,
...paneSpawnOptions,
container: paneElement,
onFocus: (term) => {
this.setActiveTerminal(term);
@@ -38,6 +46,9 @@ export class TabSplitTree {
onExit: () => {
this.closeTerminal(termView.id);
},
onContextMenu: (e, term) => {
this.onContextMenu?.(e, term);
},
});
this.terminals.set(termView.id, {
@@ -54,8 +65,8 @@ export class TabSplitTree {
return termView;
}
splitCurrent(direction = 'horizontal') {
if (!this.activeTerminal) return;
splitCurrent(direction = 'horizontal', customSpawnOptions = null) {
if (!this.activeTerminal || this.activeTerminal.isAgent) return;
const currentInfo = this.terminals.get(this.activeTerminal.id);
if (!currentInfo) return;
@@ -79,7 +90,12 @@ export class TabSplitTree {
splitWrapper.appendChild(divider);
// 添加新窗格
const newTerm = this.addPane(splitWrapper, direction);
const currentSpawn = currentInfo ? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args } : this.spawnOptions;
const finalSpawn = {
...currentSpawn,
...(customSpawnOptions || {}),
};
const newTerm = this.addPane(splitWrapper, direction, finalSpawn);
const newPane = this.terminals.get(newTerm.id).paneElement;
newPane.style.flex = '1 1 0%';
@@ -91,6 +107,259 @@ export class TabSplitTree {
this.fitAll();
newTerm.focus();
}, 50);
return newTerm;
}
splitRoot(direction = 'horizontal', customSpawnOptions = null) {
if (this.terminals.size === 0) return null;
// 创建新的顶级分屏容器,使新窗口竖向占满整个容器高度,对当前标签页整体左右分屏
const newRoot = document.createElement('div');
newRoot.className = `split-container ${direction}`;
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
newRoot.appendChild(oldRoot);
// 添加顶级分割线
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
// 添加新窗格(Agent 终端)
const currentInfo = this.activeTerminal ? this.terminals.get(this.activeTerminal.id) : null;
const currentSpawn = currentInfo
? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args }
: this.spawnOptions;
const finalSpawn = {
...currentSpawn,
...(customSpawnOptions || {}),
};
const newTerm = this.addPane(newRoot, direction, finalSpawn);
const newPane = this.terminals.get(newTerm.id).paneElement;
newPane.style.flex = '1 1 0%';
// 更新 rootElement 为新容器
this.rootElement = newRoot;
// 为分割线绑定拖拽调节事件
this.setupDividerDrag(divider, oldRoot, newPane, direction);
// 刷新自适应尺寸与焦点
setTimeout(() => {
this.fitAll();
newTerm.focus();
}, 50);
return newTerm;
}
/**
* 在特化的 Agent 面板中打开 Agent。
* 每个大标签页独占一个此类面板,多个 agent 在该面板内部以多标签页形式展示。
*/
openAgent(agent, customSpawnOptions = {}) {
// 1. 如果该大标签页已经拥有 Agent 面板且仍然挂载在 DOM 中,直接在已有面板中新增/切换标签页
if (this.agentPanel && this.agentPanel.paneElement.isConnected) {
return this.agentPanel.openAgent(agent, customSpawnOptions);
}
// 2. 如果尚未创建 Agent 面板,创建并在右侧分屏(竖向占满全高)
this.agentPanel = new AgentPanel({
tabSplitTree: this,
onContextMenu: this.onContextMenu,
onClose: () => {
this.removeAgentPanel();
},
});
this.splitRootForAgentPanel(this.agentPanel);
return this.agentPanel.openAgent(agent, customSpawnOptions);
}
splitRootForAgentPanel(agentPanel) {
if (this.terminals.size === 0) {
this.rootElement.appendChild(agentPanel.paneElement);
agentPanel.paneElement.style.flex = '1 1 0%';
return;
}
const newRoot = document.createElement('div');
newRoot.className = 'split-container horizontal';
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
newRoot.appendChild(oldRoot);
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
newRoot.appendChild(agentPanel.paneElement);
agentPanel.paneElement.style.flex = '1 1 0%';
this.rootElement = newRoot;
this.setupDividerDrag(divider, oldRoot, agentPanel.paneElement, 'horizontal');
setTimeout(() => {
this.fitAll();
}, 50);
}
removeAgentPanel() {
if (!this.agentPanel) return;
const pane = this.agentPanel.paneElement;
const parent = pane.parentElement;
// 移除相邻分割线
const prev = pane.previousElementSibling;
const next = pane.nextElementSibling;
if (prev && prev.classList.contains('split-divider')) {
prev.remove();
} else if (next && next.classList.contains('split-divider')) {
next.remove();
}
pane.remove();
if (parent === this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
}
}
this.agentPanel = null;
// 如果当前活跃终端在 Agent 面板上,焦点转移给剩余的常规终端
if (!this.activeTerminal || !this.terminals.has(this.activeTerminal.id)) {
if (this.terminals.size > 0) {
const nextTerm = this.terminals.values().next().value.termView;
this.setActiveTerminal(nextTerm);
nextTerm.focus();
}
}
setTimeout(() => this.fitAll(), 40);
}
/**
* 打开文件浏览面板 (每个大标签页独占至多一个)
*/
openFilePanel(initialDir = null) {
if (this.filePanel && this.filePanel.paneElement.isConnected) {
if (initialDir) {
this.filePanel.loadDirectory(initialDir);
}
return this.filePanel;
}
this.filePanel = new FilePanel({
tabSplitTree: this,
initialDir,
onClose: () => {
this.removeFilePanel();
},
});
this.splitRootForFilePanel(this.filePanel);
return this.filePanel;
}
/**
* 切换文件浏览面板显示/隐藏
*/
toggleFilePanel(initialDir = null) {
if (this.filePanel && this.filePanel.paneElement.isConnected) {
this.filePanel.destroy();
return null;
}
return this.openFilePanel(initialDir);
}
splitRootForFilePanel(filePanel) {
if (this.terminals.size === 0) {
this.rootElement.insertBefore(filePanel.paneElement, this.rootElement.firstChild);
filePanel.paneElement.style.width = '240px';
filePanel.paneElement.style.flex = '0 0 240px';
return;
}
const newRoot = document.createElement('div');
newRoot.className = 'split-container horizontal';
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
// 文件浏览面板放置于当前终端区域最左侧
newRoot.appendChild(filePanel.paneElement);
filePanel.paneElement.style.width = '240px';
filePanel.paneElement.style.flex = '0 0 240px';
filePanel.paneElement.style.minWidth = '160px';
filePanel.paneElement.style.maxWidth = '500px';
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
newRoot.appendChild(oldRoot);
this.rootElement = newRoot;
this.setupDividerDrag(divider, filePanel.paneElement, oldRoot, 'horizontal');
setTimeout(() => {
this.fitAll();
}, 50);
}
removeFilePanel() {
if (!this.filePanel) return;
const pane = this.filePanel.paneElement;
const parent = pane.parentElement;
const next = pane.nextElementSibling;
const prev = pane.previousElementSibling;
if (next && next.classList.contains('split-divider')) {
next.remove();
} else if (prev && prev.classList.contains('split-divider')) {
prev.remove();
}
pane.remove();
if (parent === this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
}
}
this.filePanel = null;
setTimeout(() => this.fitAll(), 40);
}
setupDividerDrag(divider, firstElement, secondElement, direction) {
@@ -151,6 +420,12 @@ export class TabSplitTree {
const info = this.terminals.get(termId);
if (!info) return;
// 如果是 Agent 终端,委托给 AgentPanel 关闭该 agent 标签
if (info.termView.isAgent && this.agentPanel) {
this.agentPanel.closeAgent(info.termView.agentId);
return;
}
info.termView.destroy();
const pane = info.paneElement;
const parent = pane.parentElement;
@@ -177,6 +452,28 @@ export class TabSplitTree {
const singleChild = remainingChildren[0];
singleChild.style.flex = parent.style.flex || '1 1 0%';
parent.parentElement.replaceChild(singleChild, parent);
} else if (remainingChildren.length === 0) {
const pPrev = parent.previousElementSibling;
const pNext = parent.nextElementSibling;
if (pPrev && pPrev.classList.contains('split-divider')) {
pPrev.remove();
} else if (pNext && pNext.classList.contains('split-divider')) {
pNext.remove();
}
parent.remove();
}
}
// 如果根容器中仅剩 1 个子元素且该子元素也是 split-container,消除顶层冗余包裹
if (this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
}
}
@@ -199,6 +496,14 @@ export class TabSplitTree {
}
destroy() {
if (this.agentPanel) {
this.agentPanel.destroy();
this.agentPanel = null;
}
if (this.filePanel) {
this.filePanel.destroy();
this.filePanel = null;
}
for (const info of this.terminals.values()) {
info.termView.destroy();
}
+133 -22
View File
@@ -4,13 +4,64 @@ export class TabManager {
constructor(options = {}) {
this.tabBar = options.tabBar;
this.viewport = options.viewport;
this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
this.onActiveChange = options.onActiveChange || (() => {});
this.onTabListChange = options.onTabListChange || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.tabs = new Map(); // tabId -> { tabElement, viewElement, splitTree, title }
this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title }
this.activeTabId = null;
this.activeWorkspaceId = 'default';
this.workspaceLastTab = new Map(); // workspaceId -> lastActiveTabId
}
createTab(title = 'bash') {
setActiveWorkspace(workspaceId, defaultSpawnOptions = {}) {
this.activeWorkspaceId = workspaceId;
// 更新各标签在顶部标签栏中的可见性
for (const [id, tab] of this.tabs.entries()) {
const match = tab.workspaceId === workspaceId;
tab.tabElement.style.display = match ? '' : 'none';
if (!match) {
tab.tabElement.classList.remove('active');
tab.viewElement.classList.remove('active');
}
}
// 确定此工作区要激活的标签页
const workspaceTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === workspaceId);
if (workspaceTabs.length === 0) {
// 当前工作区无标签页时自动创建
this.createTab({
workspaceId,
title: defaultSpawnOptions.title || 'bash',
cwd: defaultSpawnOptions.cwd,
shell: defaultSpawnOptions.shell,
args: defaultSpawnOptions.args,
});
} else {
let targetId = this.workspaceLastTab.get(workspaceId);
if (!targetId || !this.tabs.has(targetId)) {
targetId = workspaceTabs[workspaceTabs.length - 1].tabId;
}
this.switchTab(targetId);
}
this.onTabListChange();
}
createTab(options = {}) {
const workspaceId = typeof options === 'object' && options.workspaceId ? options.workspaceId : this.activeWorkspaceId;
const defaultSpawn = this.getSpawnOptions(workspaceId) || {};
const title = typeof options === 'string' ? options : (options.title || defaultSpawn.title || 'bash');
const spawnOptions = typeof options === 'object' && options.spawnOptions ? options.spawnOptions : (
typeof options === 'object' ? {
cwd: options.cwd !== undefined ? options.cwd : defaultSpawn.cwd,
shell: options.shell !== undefined ? options.shell : defaultSpawn.shell,
args: options.args !== undefined ? options.args : defaultSpawn.args,
} : defaultSpawn
);
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
// 1. 创建标签页 DOM
@@ -34,6 +85,7 @@ export class TabManager {
const splitTree = new TabSplitTree({
container: viewElement,
tabId,
spawnOptions,
onActiveTerminalChange: (term) => {
if (this.activeTabId === tabId) {
this.onActiveChange(term);
@@ -45,6 +97,9 @@ export class TabManager {
onEmpty: () => {
this.closeTab(tabId);
},
onContextMenu: (e, term) => {
this.onContextMenu?.(e, term);
},
});
// 4. 标签页点击与关闭事件
@@ -56,22 +111,44 @@ export class TabManager {
}
});
// 根据当前工作区控制是否在顶部标签栏可见
if (workspaceId !== this.activeWorkspaceId) {
tabElement.style.display = 'none';
}
this.tabBar.appendChild(tabElement);
this.tabs.set(tabId, {
tabId,
workspaceId,
tabElement,
viewElement,
splitTree,
title,
});
this.switchTab(tabId);
if (workspaceId === this.activeWorkspaceId) {
this.switchTab(tabId);
}
this.onTabListChange();
return tabId;
}
switchTab(tabId) {
if (!this.tabs.has(tabId)) return;
const targetTab = this.tabs.get(tabId);
if (!targetTab) return;
// 如果该标签属于不同工作区,同步更新 activeWorkspaceId
if (targetTab.workspaceId !== this.activeWorkspaceId) {
this.activeWorkspaceId = targetTab.workspaceId;
for (const [id, tab] of this.tabs.entries()) {
const match = tab.workspaceId === targetTab.workspaceId;
tab.tabElement.style.display = match ? '' : 'none';
}
}
this.workspaceLastTab.set(targetTab.workspaceId, tabId);
for (const [id, tab] of this.tabs.entries()) {
const isActive = id === tabId;
@@ -88,26 +165,33 @@ export class TabManager {
}, 30);
}
}
this.onTabListChange();
}
closeTab(tabId) {
const tab = this.tabs.get(tabId);
if (!tab) return;
const wsId = tab.workspaceId;
tab.splitTree.destroy();
tab.tabElement.remove();
tab.viewElement.remove();
this.tabs.delete(tabId);
// 如果还有其他标签页,切换到上一个或下一个
if (this.tabs.size > 0) {
const remainingIds = Array.from(this.tabs.keys());
const nextId = remainingIds[remainingIds.length - 1];
this.switchTab(nextId);
} else {
// 没有任何标签页时自动新建一个
this.createTab();
// 寻找同一工作区的其他标签
const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId);
if (this.activeTabId === tabId) {
if (remainingWsTabs.length > 0) {
const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId;
this.switchTab(nextId);
} else {
// 当前工作区没有其他标签页了,自动新建一个
this.createTab({ workspaceId: wsId });
}
}
this.onTabListChange();
}
updateTabTitle(tabId, newTitle) {
@@ -118,6 +202,7 @@ export class TabManager {
if (titleEl) {
titleEl.textContent = newTitle;
}
this.onTabListChange();
}
}
@@ -130,19 +215,45 @@ export class TabManager {
return activeTab ? activeTab.splitTree.activeTerminal : null;
}
getTabsForWorkspace(workspaceId) {
return Array.from(this.tabs.values())
.filter((t) => t.workspaceId === workspaceId)
.map((t) => ({
tabId: t.tabId,
title: t.title,
isActive: t.tabId === this.activeTabId,
}));
}
nextTab() {
const ids = Array.from(this.tabs.keys());
if (ids.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId);
const nextIndex = (currentIndex + 1) % ids.length;
this.switchTab(ids[nextIndex]);
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
if (wsTabs.length <= 1) return;
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
const nextIndex = (currentIndex + 1) % wsTabs.length;
this.switchTab(wsTabs[nextIndex].tabId);
}
prevTab() {
const ids = Array.from(this.tabs.keys());
if (ids.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId);
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
this.switchTab(ids[prevIndex]);
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
if (wsTabs.length <= 1) return;
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
this.switchTab(wsTabs[prevIndex].tabId);
}
toggleFileBrowser(tabId = null) {
const tab = tabId ? this.tabs.get(tabId) : this.getActiveTab();
if (tab && tab.splitTree) {
return tab.splitTree.toggleFilePanel();
}
return null;
}
openFileBrowser(tabId = null, dir = null) {
const tab = tabId ? this.tabs.get(tabId) : this.getActiveTab();
if (tab && tab.splitTree) {
return tab.splitTree.openFilePanel(dir);
}
return null;
}
}
+124 -5
View File
@@ -10,11 +10,14 @@ export class TerminalView {
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
this.container = options.container;
this.cwd = options.cwd || '';
this.shell = options.shell || undefined;
this.args = options.args || [];
this.title = options.title || 'bash';
this.onTitleChange = options.onTitleChange || (() => {});
this.onFocus = options.onFocus || (() => {});
this.onExit = options.onExit || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.title = 'bash';
this.isFocused = false;
this.initTerminal();
@@ -41,7 +44,7 @@ export class TerminalView {
theme: currentTheme.xterm,
macOptionIsMeta: true,
rightClickSelectsWord: true,
convertEol: true,
convertEol: false,
});
// 插件初始化
@@ -58,9 +61,24 @@ export class TerminalView {
this.terminalWrapper = document.createElement('div');
this.terminalWrapper.className = 'terminal-wrapper';
this.container.appendChild(this.terminalWrapper);
this.element = this.terminalWrapper;
this.xterm.open(this.terminalWrapper);
// 右键上下文菜单
this.terminalWrapper.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.focus();
if (typeof this.onContextMenu === 'function') {
this.onContextMenu(e, this);
} else {
window.dispatchEvent(new CustomEvent('terminal-contextmenu', {
detail: { event: e, termView: this }
}));
}
});
// 启用 WebGL 硬件加速 (GPU 渲染)
try {
this.webglAddon = new WebglAddon();
@@ -73,6 +91,49 @@ export class TerminalView {
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
}
// 自定义按键过滤:允许应用级全局快捷键穿透终端聚焦拦截
this.xterm.attachCustomKeyEventHandler((event) => {
// Ctrl+B: 侧边栏
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') {
return false;
}
// Ctrl+Shift+C: 复制选中内容
if (event.ctrlKey && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'c') {
if (event.type === 'keydown') {
this.copySelection();
}
return false;
}
// Ctrl+Shift+V: 粘贴剪贴板内容
if (event.ctrlKey && event.shiftKey && !event.altKey && event.key.toLowerCase() === 'v') {
if (event.type === 'keydown') {
this.pasteFromClipboard();
}
return false;
}
// Ctrl+Shift+T / W / E / O / F / P
if (event.ctrlKey && event.shiftKey && ['t', 'w', 'e', 'o', 'f', 'p'].includes(event.key.toLowerCase())) {
return false;
}
// F1: 命令面板
if (event.key === 'F1') {
return false;
}
// Ctrl+,: 设置
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key === ',') {
return false;
}
// Ctrl+Tab / Ctrl+Shift+Tab
if (event.ctrlKey && event.key === 'Tab') {
return false;
}
// Alt+1 .. Alt+9
if (event.altKey && event.key >= '1' && event.key <= '9') {
return false;
}
return true;
});
// 监听输入
this.xterm.onData((data) => {
window.terminalAPI.writePty(this.id, data);
@@ -92,6 +153,17 @@ export class TerminalView {
this.onFocus(this);
});
// 监听 PTY 退出并触发 onExit 回调
if (window.terminalAPI?.onPtyExit) {
this.unsubscribeExit = window.terminalAPI.onPtyExit(({ id, exitCode }) => {
if (id === this.id) {
if (typeof this.onExit === 'function') {
this.onExit(exitCode);
}
}
});
}
// 主题监听
this.unsubscribeTheme = themeManager.onThemeChange((theme) => {
this.xterm.options.theme = theme.xterm;
@@ -113,11 +185,20 @@ export class TerminalView {
cols,
rows,
cwd: this.cwd,
shell: this.shell,
args: this.args,
}).then((res) => {
if (res && res.shell) {
this.title = res.shell.split('/').pop() || 'bash';
this.onTitleChange(this.title);
if (res && res.error) {
this.xterm.writeln(`\r\n\x1b[31m[创建终端进程失败: ${res.error}]\x1b[0m\r\n`);
}
if (res && res.shell) {
if (!this.title || this.title === 'bash') {
this.title = res.shell.split('/').pop() || 'bash';
this.onTitleChange(this.title);
}
}
}).catch((err) => {
this.xterm.writeln(`\r\n\x1b[31m[启动终端异常: ${err.message}]\x1b[0m\r\n`);
});
// 聚焦
@@ -142,6 +223,40 @@ export class TerminalView {
this.onFocus(this);
}
async copySelection() {
const selection = this.xterm.getSelection();
if (!selection) return false;
try {
if (window.terminalAPI?.writeClipboard) {
window.terminalAPI.writeClipboard(selection);
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(selection);
}
return true;
} catch (err) {
console.warn('Failed to copy selection to clipboard:', err);
return false;
}
}
async pasteFromClipboard() {
try {
let text = '';
if (window.terminalAPI?.readClipboard) {
text = await window.terminalAPI.readClipboard();
} else if (navigator.clipboard?.readText) {
text = await navigator.clipboard.readText();
}
if (text) {
this.xterm.paste(text);
return true;
}
} catch (err) {
console.warn('Failed to paste from clipboard:', err);
}
return false;
}
updateSettings(settings) {
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
@@ -152,6 +267,10 @@ export class TerminalView {
destroy() {
if (this.unsubscribeTheme) this.unsubscribeTheme();
if (this.unsubscribeExit) {
this.unsubscribeExit();
this.unsubscribeExit = null;
}
if (this.resizeObserver) this.resizeObserver.disconnect();
window.terminalAPI.killPty(this.id);
this.xterm.dispose();
+140
View File
@@ -140,6 +140,146 @@ export const THEMES = {
brightWhite: '#ffffff',
},
},
'catppuccin-latte': {
name: 'Catppuccin Latte (浅色)',
themeName: 'catppuccin-latte',
xterm: {
background: '#eff1f5',
foreground: '#4c4f69',
cursor: '#dc8a78',
cursorAccent: '#eff1f5',
selectionBackground: '#ccd0da',
selectionForeground: '#4c4f69',
black: '#5c5f77',
red: '#d20f39',
green: '#40a02b',
yellow: '#df8e1d',
blue: '#1e66f5',
magenta: '#ea76cb',
cyan: '#179299',
white: '#acb0be',
brightBlack: '#6c6f85',
brightRed: '#d20f39',
brightGreen: '#40a02b',
brightYellow: '#df8e1d',
brightBlue: '#1e66f5',
brightMagenta: '#ea76cb',
brightCyan: '#179299',
brightWhite: '#bcc0cc',
},
},
'github-light': {
name: 'GitHub Light (浅色)',
themeName: 'github-light',
xterm: {
background: '#ffffff',
foreground: '#24292f',
cursor: '#0969da',
cursorAccent: '#ffffff',
selectionBackground: '#b6e3ff',
selectionForeground: '#24292f',
black: '#24292f',
red: '#cf222e',
green: '#1a7f37',
yellow: '#9a6700',
blue: '#0969da',
magenta: '#8250df',
cyan: '#1b7c83',
white: '#6e7781',
brightBlack: '#57606a',
brightRed: '#a40e26',
brightGreen: '#116329',
brightYellow: '#633c01',
brightBlue: '#0550ae',
brightMagenta: '#6639ba',
brightCyan: '#114c53',
brightWhite: '#8c959f',
},
},
'tokyo-night-day': {
name: 'Tokyo Night Day (浅色)',
themeName: 'tokyo-night-day',
xterm: {
background: '#e1e2e7',
foreground: '#3760bf',
cursor: '#3760bf',
cursorAccent: '#e1e2e7',
selectionBackground: '#b7c1e3',
selectionForeground: '#3760bf',
black: '#e9e9ed',
red: '#f52a65',
green: '#587539',
yellow: '#8c6c3e',
blue: '#2e7de9',
magenta: '#9854f1',
cyan: '#007197',
white: '#6172b0',
brightBlack: '#a1a6c5',
brightRed: '#f52a65',
brightGreen: '#587539',
brightYellow: '#8c6c3e',
brightBlue: '#2e7de9',
brightMagenta: '#9854f1',
brightCyan: '#007197',
brightWhite: '#3760bf',
},
},
'solarized-light': {
name: 'Solarized Light (浅色)',
themeName: 'solarized-light',
xterm: {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
cursorAccent: '#fdf6e3',
selectionBackground: '#eee8d5',
selectionForeground: '#586e75',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#002b36',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
},
},
'one-light': {
name: 'One Light (浅色)',
themeName: 'one-light',
xterm: {
background: '#fafafa',
foreground: '#383a42',
cursor: '#526fff',
cursorAccent: '#fafafa',
selectionBackground: '#e5e5e6',
selectionForeground: '#383a42',
black: '#383a42',
red: '#e45649',
green: '#50a14f',
yellow: '#c18401',
blue: '#4078f2',
magenta: '#a626a4',
cyan: '#0184bc',
white: '#a0a1a7',
brightBlack: '#4f525d',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
},
};
class ThemeManager {
+665
View File
@@ -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="关闭">&times;</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);
}
}
+1420
View File
File diff suppressed because it is too large Load Diff