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
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
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hatty",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
|
||||
"main": "src/main/index.cjs",
|
||||
"scripts": {
|
||||
|
||||
+142
-1
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PtyManager } = require('./pty-manager.cjs');
|
||||
@@ -101,6 +101,147 @@ ipcMain.on('pty:kill', (event, { id }) => {
|
||||
if (ptyManager) ptyManager.kill({ id });
|
||||
});
|
||||
|
||||
// 剪贴板 IPC
|
||||
ipcMain.handle('clipboard:read', () => {
|
||||
return clipboard.readText();
|
||||
});
|
||||
|
||||
ipcMain.on('clipboard:write', (event, text) => {
|
||||
clipboard.writeText(text || '');
|
||||
});
|
||||
|
||||
// 获取 PTY 实时工作目录
|
||||
ipcMain.handle('pty:get-cwd', (event, { id }) => {
|
||||
return ptyManager ? ptyManager.getCwd({ id }) : null;
|
||||
});
|
||||
|
||||
// 检查可执行命令路径
|
||||
const fs = require('fs');
|
||||
function findExecutable(command) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const exts = isWindows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
||||
const dirs = (process.env.PATH || '').split(path.delimiter);
|
||||
const extraDirs = [
|
||||
path.join(os.homedir(), '.local/bin'),
|
||||
path.join(os.homedir(), '.npm-global/bin'),
|
||||
path.join(os.homedir(), '.cargo/bin'),
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
];
|
||||
const allDirs = [...new Set([...dirs, ...extraDirs])];
|
||||
for (const dir of allDirs) {
|
||||
for (const ext of exts) {
|
||||
const fullPath = path.join(dir, command + ext);
|
||||
try {
|
||||
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
||||
return fullPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查已安装的 Agent (agy / codex / claude)
|
||||
ipcMain.handle('app:get-installed-agents', () => {
|
||||
const agentDefs = [
|
||||
{ id: 'agy', name: 'agy', cmd: 'agy', desc: 'Antigravity CLI Agent' },
|
||||
{ id: 'codex', name: 'codex', cmd: 'codex', desc: 'OpenAI CodeX CLI' },
|
||||
{ id: 'claude', name: 'claude', cmd: 'claude', desc: 'Claude Code Agent' },
|
||||
];
|
||||
return agentDefs.map((agent) => {
|
||||
const execPath = findExecutable(agent.cmd);
|
||||
return {
|
||||
...agent,
|
||||
path: execPath,
|
||||
installed: !!execPath,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 文件浏览 IPC:读取目录中的文件和文件夹
|
||||
ipcMain.handle('fs:read-dir', async (event, { dirPath, showHidden = false } = {}) => {
|
||||
try {
|
||||
let targetPath = dirPath || '~';
|
||||
if (targetPath === '~' || targetPath.startsWith('~/')) {
|
||||
targetPath = path.join(os.homedir(), targetPath.slice(targetPath === '~' ? 1 : 2));
|
||||
}
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return { success: false, error: '目录不存在: ' + targetPath, dirPath: targetPath };
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { success: false, error: '路径不是有效目录: ' + targetPath, dirPath: targetPath };
|
||||
}
|
||||
|
||||
const dirents = await fs.promises.readdir(targetPath, { withFileTypes: true });
|
||||
const entries = [];
|
||||
|
||||
for (const d of dirents) {
|
||||
const isHidden = d.name.startsWith('.');
|
||||
if (!showHidden && isHidden) continue;
|
||||
|
||||
let isDirectory = d.isDirectory();
|
||||
let isFile = d.isFile();
|
||||
let isSymbolicLink = d.isSymbolicLink();
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
|
||||
const fullItemPath = path.join(targetPath, d.name);
|
||||
if (isSymbolicLink) {
|
||||
try {
|
||||
const realStat = await fs.promises.stat(fullItemPath);
|
||||
isDirectory = realStat.isDirectory();
|
||||
isFile = realStat.isFile();
|
||||
size = realStat.size;
|
||||
mtime = realStat.mtimeMs;
|
||||
} catch (_) {}
|
||||
} else {
|
||||
try {
|
||||
if (isFile) {
|
||||
const fStat = await fs.promises.stat(fullItemPath);
|
||||
size = fStat.size;
|
||||
mtime = fStat.mtimeMs;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: d.name,
|
||||
path: fullItemPath,
|
||||
isDirectory,
|
||||
isFile,
|
||||
isSymbolicLink,
|
||||
isHidden,
|
||||
size,
|
||||
mtime,
|
||||
});
|
||||
}
|
||||
|
||||
// 排序:文件夹优先,同类按文件名升序排列
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true });
|
||||
});
|
||||
|
||||
const parentPath = path.dirname(targetPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dirPath: targetPath,
|
||||
parentPath: parentPath !== targetPath ? parentPath : null,
|
||||
entries,
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, dirPath };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('app:get-system-info', () => {
|
||||
return {
|
||||
platform: os.platform(),
|
||||
|
||||
@@ -99,6 +99,18 @@ class PtyManager {
|
||||
}
|
||||
}
|
||||
|
||||
getCwd({ id }) {
|
||||
const ptyProcess = this.ptys.get(id);
|
||||
if (!ptyProcess || !ptyProcess.pid) return null;
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const fs = require('fs');
|
||||
return fs.readlinkSync(`/proc/${ptyProcess.pid}/cwd`);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
killAll() {
|
||||
for (const [id, ptyProcess] of this.ptys.entries()) {
|
||||
try {
|
||||
|
||||
@@ -32,4 +32,15 @@ contextBridge.exposeInMainWorld('terminalAPI', {
|
||||
|
||||
// 系统信息
|
||||
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
|
||||
|
||||
// 剪贴板管理
|
||||
readClipboard: () => ipcRenderer.invoke('clipboard:read'),
|
||||
writeClipboard: (text) => ipcRenderer.send('clipboard:write', text),
|
||||
|
||||
// PTY 与 Agent 查询
|
||||
getPtyCwd: (id) => ipcRenderer.invoke('pty:get-cwd', { id }),
|
||||
getInstalledAgents: () => ipcRenderer.invoke('app:get-installed-agents'),
|
||||
|
||||
// 文件浏览
|
||||
readDir: (options) => ipcRenderer.invoke('fs:read-dir', options),
|
||||
});
|
||||
|
||||
@@ -261,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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
<link rel="stylesheet" href="css/sidebar.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="app" class="sidebar-collapsed">
|
||||
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
|
||||
<header class="titlebar" id="titlebar">
|
||||
<div class="titlebar-left" id="titlebar-left">
|
||||
<div class="titlebar-left sidebar-collapsed" id="titlebar-left">
|
||||
<!-- 侧边栏折叠/展开按钮 -->
|
||||
<button class="btn-icon btn-toggle-sidebar" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -71,6 +71,11 @@
|
||||
<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>
|
||||
@@ -113,9 +118,9 @@
|
||||
</header>
|
||||
|
||||
<!-- 应用主体容器 (含左侧工作区侧边栏与终端视口) -->
|
||||
<div class="app-body">
|
||||
<div class="app-body sidebar-collapsed">
|
||||
<!-- 左侧工作区侧边栏 -->
|
||||
<aside class="workspace-sidebar" id="workspace-sidebar">
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { TerminalView } from './terminal-view.js';
|
||||
|
||||
export const AGENT_ICONS = {
|
||||
// Google Antigravity (agy) 官方标志:Google 经典四色渐变 (蓝 #4285F4、红 #EA4335、黄 #FBBC05、绿 #34A853)
|
||||
agy: `<svg width="14" height="14" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="agy-google-gradient" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#34A853"/>
|
||||
<stop offset="32%" stop-color="#FBBC05"/>
|
||||
<stop offset="65%" stop-color="#EA4335"/>
|
||||
<stop offset="100%" stop-color="#4285F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M19.08 19.44C20.05 20.17 21.5 19.68 20.17 18.35C16.18 14.48 17.02 3.83 12.06 3.83C7.1 3.83 7.95 14.48 3.95 18.35C2.5 19.81 4.07 20.17 5.04 19.44C8.79 16.9 8.55 12.42 12.06 12.42C15.57 12.42 15.33 16.9 19.08 19.44Z" fill="url(#agy-google-gradient)"></path>
|
||||
</svg>`,
|
||||
// OpenAI ChatGPT / Codex (codex) 标志:从 iconfont.cn (q=chatgpt) 抓取的经典黑色 ChatGPT 标志
|
||||
codex: `<svg width="14" height="14" viewBox="0 0 1024 1024" fill="currentColor">
|
||||
<path d="M939.7888 421.4144a248.832 248.832 0 0 0-21.376-204.3904 251.6608 251.6608 0 0 0-271.0656-120.7424A248.832 248.832 0 0 0 459.648 12.608a251.6864 251.6864 0 0 0-240.0896 174.2592 248.9216 248.9216 0 0 0-166.4 120.704 251.6992 251.6992 0 0 0 30.9504 295.1168 248.832 248.832 0 0 0 21.3888 204.3904 251.6864 251.6864 0 0 0 271.0656 120.7424 248.832 248.832 0 0 0 187.6864 83.6736 251.6736 251.6736 0 0 0 240.1664-174.3488 248.9216 248.9216 0 0 0 166.4-120.7168 251.6992 251.6992 0 0 0-31.04-295.0144zM564.352 946.2016a186.6624 186.6624 0 0 1-119.8208-43.328c1.5104-0.832 4.1728-2.2784 5.9008-3.3536L649.3184 784.64a32.3328 32.3328 0 0 0 16.3328-28.288V475.9296l84.0704 48.5376a3.008 3.008 0 0 1 1.6384 2.304v232.2176a187.4304 187.4304 0 0 1-187.008 187.2z m-402.1888-171.776a186.5472 186.5472 0 0 1-22.336-125.44c1.472 0.8832 4.0576 2.4576 5.9008 3.52l198.8992 114.8928a32.3712 32.3712 0 0 0 32.6656 0l242.8288-140.2112v97.0752a3.008 3.008 0 0 1-1.2032 2.5856l-201.0496 116.0832a187.392 187.392 0 0 1-255.7056-68.5056zM109.824 340.224a186.5088 186.5088 0 0 1 97.4336-82.0736c0 1.7152-0.1024 4.736-0.1024 6.848v229.7728a32.32 32.32 0 0 0 16.3328 28.288l242.816 140.1856-84.0576 48.5376a3.008 3.008 0 0 1-2.8416 0.256L178.3296 595.84A187.392 187.392 0 0 1 109.824 340.224z m690.688 160.7296L557.696 360.7424l84.0576-48.512a3.008 3.008 0 0 1 2.8288-0.256l201.088 116.0832a187.2256 187.2256 0 0 1-28.928 337.8176V529.2416a32.3072 32.3072 0 0 0-16.2304-28.288z m83.6736-125.9264c-1.4848-0.9088-4.0576-2.4704-5.9136-3.5328l-198.8864-114.88a32.4096 32.4096 0 0 0-32.6656 0L403.8912 396.8256v-97.088a3.008 3.008 0 0 1 1.2032-2.5856l201.0496-115.9808a187.2128 187.2128 0 0 1 278.0416 193.856zM358.1824 548.0576l-84.096-48.5376a3.008 3.008 0 0 1-1.6256-2.304V264.9984a187.2128 187.2128 0 0 1 307.008-143.744 172.8 172.8 0 0 0-5.9136 3.3408l-198.8864 114.88a32.32 32.32 0 0 0-16.3456 28.288l-0.1408 280.2944z m45.6704-98.4576L512 387.136l108.16 62.4256v124.8896L512 636.8768l-108.16-62.4256v-124.8512z"></path>
|
||||
</svg>`,
|
||||
// Anthropic Claude (claude) 官方标志:经典 14 瓣温暖射线火花
|
||||
claude: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path>
|
||||
</svg>`,
|
||||
// 通用/默认 AI Agent 标志
|
||||
default: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM7.5 13a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"></path>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 专属 Agent 面板 (AgentPanel)
|
||||
* 每个大标签页独占一个此类面板,用于在右侧全高分屏展示 AI Agent。
|
||||
* 支持在单一面板内以小标签页 (多 Agent) 形式展示。
|
||||
*/
|
||||
export class AgentPanel {
|
||||
constructor(options = {}) {
|
||||
this.tabSplitTree = options.tabSplitTree;
|
||||
this.onClose = options.onClose || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.paneElement = document.createElement('div');
|
||||
this.paneElement.className = 'split-pane agent-panel';
|
||||
this.paneElement.dataset.agentPanel = 'true';
|
||||
|
||||
this.tabs = new Map(); // agentId -> { tabEl, viewEl, termView, agent }
|
||||
this.activeAgentId = null;
|
||||
|
||||
this.initDOM();
|
||||
}
|
||||
|
||||
initDOM() {
|
||||
this.paneElement.innerHTML = `
|
||||
<div class="agent-panel-header">
|
||||
<div class="agent-panel-header-left">
|
||||
<span class="agent-panel-title">AGENT</span>
|
||||
<div class="agent-panel-tabs" role="tablist"></div>
|
||||
</div>
|
||||
<div class="agent-panel-header-actions">
|
||||
<button class="btn-icon agent-panel-close-btn" title="关闭 Agent 面板 (Ctrl+Shift+W)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-panel-body"></div>
|
||||
`;
|
||||
|
||||
this.tabsContainer = this.paneElement.querySelector('.agent-panel-tabs');
|
||||
this.bodyContainer = this.paneElement.querySelector('.agent-panel-body');
|
||||
this.closeBtn = this.paneElement.querySelector('.agent-panel-close-btn');
|
||||
|
||||
this.closeBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.destroy();
|
||||
});
|
||||
|
||||
this.paneElement.addEventListener('click', () => {
|
||||
const activeInfo = this.tabs.get(this.activeAgentId);
|
||||
if (activeInfo?.termView) {
|
||||
activeInfo.termView.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAgent(agent, spawnOptions = {}) {
|
||||
const agentId = agent.id || agent.cmd;
|
||||
|
||||
// 1. 如果该 agent 已经在此面板中打开,直接激活并聚焦
|
||||
if (this.tabs.has(agentId)) {
|
||||
this.switchAgent(agentId);
|
||||
return this.tabs.get(agentId).termView;
|
||||
}
|
||||
|
||||
// 2. 创建 Agent 视图容器并设为激活状态,确保 TerminalView 初始化时能测量真实 DOM 宽高
|
||||
for (const entry of this.tabs.values()) {
|
||||
entry.tabEl.classList.remove('active');
|
||||
entry.viewEl.style.display = 'none';
|
||||
entry.viewEl.classList.remove('active');
|
||||
}
|
||||
const viewEl = document.createElement('div');
|
||||
viewEl.className = 'agent-tab-view active';
|
||||
viewEl.style.display = 'flex';
|
||||
this.bodyContainer.appendChild(viewEl);
|
||||
|
||||
// 3. 创建 TerminalView (直接 exec Agent 进程,Agent 退出后 PTY 即刻关闭并联动关闭该 Agent 面板)
|
||||
const agentCmd = agent.path ? `"${agent.path}"` : agent.cmd;
|
||||
const termView = new TerminalView({
|
||||
cwd: spawnOptions.cwd || '~',
|
||||
shell: spawnOptions.shell || '/bin/bash',
|
||||
args: spawnOptions.args || ['-c', `exec ${agentCmd}`],
|
||||
title: agent.name || agentId,
|
||||
container: viewEl,
|
||||
onFocus: (term) => {
|
||||
this.tabSplitTree.setActiveTerminal(term);
|
||||
},
|
||||
onTitleChange: (title) => {
|
||||
if (this.tabSplitTree.activeTerminal === termView) {
|
||||
this.tabSplitTree.onTitleChange(title);
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
this.closeAgent(agentId);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
// 标记该终端为特化的 Agent 终端
|
||||
termView.isAgent = true;
|
||||
termView.agentId = agentId;
|
||||
termView.agentName = agent.name || agentId;
|
||||
|
||||
// 4. 创建紧凑型小标签页 (仅展示对应 agent 图标与小关闭按钮)
|
||||
const tabEl = document.createElement('div');
|
||||
tabEl.className = 'agent-tab';
|
||||
tabEl.dataset.agentId = agentId;
|
||||
tabEl.title = `${agent.name || agentId} (AI Agent)`;
|
||||
|
||||
const iconSvg = AGENT_ICONS[agentId] || AGENT_ICONS.default;
|
||||
tabEl.innerHTML = `
|
||||
<span class="agent-tab-icon">${iconSvg}</span>
|
||||
<span class="agent-tab-name">${agent.name || agentId}</span>
|
||||
<button class="agent-tab-close" title="关闭 ${agent.name || agentId}">×</button>
|
||||
`;
|
||||
|
||||
// 标签页点击切换
|
||||
tabEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.switchAgent(agentId);
|
||||
});
|
||||
|
||||
// 标签页内关闭按钮
|
||||
const tabCloseBtn = tabEl.querySelector('.agent-tab-close');
|
||||
tabCloseBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeAgent(agentId);
|
||||
});
|
||||
|
||||
this.tabsContainer.appendChild(tabEl);
|
||||
|
||||
// 5. 存储并注册进 tabSplitTree 的 terminals 集合中
|
||||
const agentEntry = {
|
||||
agentId,
|
||||
agent,
|
||||
tabEl,
|
||||
viewEl,
|
||||
termView,
|
||||
};
|
||||
this.tabs.set(agentId, agentEntry);
|
||||
|
||||
this.tabSplitTree.terminals.set(termView.id, {
|
||||
termView,
|
||||
paneElement: this.paneElement,
|
||||
parentElement: this.paneElement.parentElement,
|
||||
});
|
||||
|
||||
// 6. 切换到新建的 agent
|
||||
this.switchAgent(agentId);
|
||||
|
||||
return termView;
|
||||
}
|
||||
|
||||
switchAgent(agentId) {
|
||||
if (!this.tabs.has(agentId)) return;
|
||||
this.activeAgentId = agentId;
|
||||
|
||||
for (const [id, entry] of this.tabs.entries()) {
|
||||
const isActive = id === agentId;
|
||||
entry.tabEl.classList.toggle('active', isActive);
|
||||
entry.viewEl.style.display = isActive ? 'flex' : 'none';
|
||||
entry.viewEl.classList.toggle('active', isActive);
|
||||
}
|
||||
|
||||
const currentEntry = this.tabs.get(agentId);
|
||||
if (currentEntry?.termView) {
|
||||
this.tabSplitTree.setActiveTerminal(currentEntry.termView);
|
||||
currentEntry.termView.fit();
|
||||
currentEntry.termView.focus();
|
||||
setTimeout(() => {
|
||||
currentEntry.termView.fit();
|
||||
currentEntry.termView.focus();
|
||||
}, 40);
|
||||
}
|
||||
}
|
||||
|
||||
closeAgent(agentId) {
|
||||
const entry = this.tabs.get(agentId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.termView.destroy();
|
||||
this.tabSplitTree.terminals.delete(entry.termView.id);
|
||||
|
||||
entry.tabEl.remove();
|
||||
entry.viewEl.remove();
|
||||
this.tabs.delete(agentId);
|
||||
|
||||
if (this.tabs.size === 0) {
|
||||
// 当 Agent 面板中无任何 agent 时,销毁整个 Agent 面板
|
||||
this.destroy();
|
||||
} else {
|
||||
// 切换至剩余的其他 agent
|
||||
if (this.activeAgentId === agentId) {
|
||||
const nextId = this.tabs.keys().next().value;
|
||||
this.switchAgent(nextId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeCurrentAgent() {
|
||||
if (this.activeAgentId) {
|
||||
this.closeAgent(this.activeAgentId);
|
||||
} else if (this.tabs.size > 0) {
|
||||
const firstId = this.tabs.keys().next().value;
|
||||
this.closeAgent(firstId);
|
||||
} else {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTerminal() {
|
||||
if (!this.activeAgentId) return null;
|
||||
return this.tabs.get(this.activeAgentId)?.termView || null;
|
||||
}
|
||||
|
||||
fit() {
|
||||
const active = this.getActiveTerminal();
|
||||
if (active) active.fit();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// 销毁面板内所有运行中的终端
|
||||
for (const entry of this.tabs.values()) {
|
||||
entry.termView.destroy();
|
||||
this.tabSplitTree.terminals.delete(entry.termView.id);
|
||||
}
|
||||
this.tabs.clear();
|
||||
this.activeAgentId = null;
|
||||
|
||||
// 通知外部从 splitTree 移除 Agent 面板窗格
|
||||
this.onClose(this);
|
||||
}
|
||||
}
|
||||
+93
-4
@@ -3,6 +3,7 @@ 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() {
|
||||
@@ -10,6 +11,7 @@ class TerminalApp {
|
||||
this.initTheme();
|
||||
this.initTabManager();
|
||||
this.initWorkspaceManager();
|
||||
this.initContextMenu();
|
||||
this.initModals();
|
||||
this.initSearch();
|
||||
this.initWindowControls();
|
||||
@@ -59,6 +61,10 @@ class TerminalApp {
|
||||
if (activeTerm && this.statusShell) {
|
||||
this.statusShell.textContent = activeTerm.title || 'bash';
|
||||
}
|
||||
this.updateSplitButtonsState(activeTerm);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.contextMenu?.show(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,12 +81,43 @@ class TerminalApp {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
initModals() {
|
||||
this.commandPalette = new CommandPalette({
|
||||
overlay: this.commandPaletteOverlay,
|
||||
@@ -109,12 +146,20 @@ 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', () => {
|
||||
@@ -176,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();
|
||||
@@ -241,6 +292,32 @@ class TerminalApp {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditingText = (el) => {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName?.toLowerCase();
|
||||
return (tag === 'input' || tag === 'textarea') && !el.classList.contains('xterm-helper-textarea');
|
||||
};
|
||||
|
||||
// Ctrl+Shift+C: 复制当前选中终端文本
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') {
|
||||
if (isEditingText(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeTerm = this.tabManager.getActiveTerminal();
|
||||
if (activeTerm) activeTerm.copySelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+V: 粘贴剪贴板文本到当前终端
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'v') {
|
||||
if (isEditingText(e.target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeTerm = this.tabManager.getActiveTerminal();
|
||||
if (activeTerm) activeTerm.pasteFromClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+T: 新建当前工作区标签页
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
@@ -273,7 +350,9 @@ class TerminalApp {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('horizontal');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,7 +361,9 @@ class TerminalApp {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
||||
tab.splitTree.splitCurrent('vertical');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -294,6 +375,14 @@ class TerminalApp {
|
||||
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();
|
||||
|
||||
@@ -58,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');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,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');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -83,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)',
|
||||
@@ -90,6 +103,26 @@ export class CommandPalette {
|
||||
icon: 'search',
|
||||
action: () => this.onToggleSearch(),
|
||||
},
|
||||
{
|
||||
id: 'copy-selection',
|
||||
title: '复制终端选中内容 (Copy Selection)',
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
icon: 'copy',
|
||||
action: () => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.copySelection();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'paste-clipboard',
|
||||
title: '粘贴剪贴板内容到终端 (Paste Clipboard)',
|
||||
shortcut: 'Ctrl+Shift+V',
|
||||
icon: 'clipboard',
|
||||
action: () => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.pasteFromClipboard();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
title: '清空当前屏幕缓冲区 (Clear Buffer)',
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 终端右键上下文菜单组件 (ContextMenu)
|
||||
* 提供终端基础操作(复制、粘贴、分屏、清屏)
|
||||
*/
|
||||
import { AGENT_ICONS } from './agent-panel.js';
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(options = {}) {
|
||||
this.tabManager = options.tabManager;
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
this.menuElement = null;
|
||||
this.currentTerminal = null;
|
||||
this.isOpen = false;
|
||||
|
||||
this.initElement();
|
||||
this.bindGlobalEvents();
|
||||
}
|
||||
|
||||
initElement() {
|
||||
this.menuElement = document.createElement('div');
|
||||
this.menuElement.className = 'terminal-context-menu';
|
||||
this.menuElement.id = 'terminal-context-menu';
|
||||
document.body.appendChild(this.menuElement);
|
||||
|
||||
// 阻止菜单内部右键默认行为
|
||||
this.menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
bindGlobalEvents() {
|
||||
// 点击外部关闭
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (this.isOpen && !this.menuElement.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Esc 关闭
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this.isOpen && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 窗口尺寸变化或失焦关闭
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.isOpen) this.close();
|
||||
});
|
||||
window.addEventListener('blur', () => {
|
||||
if (this.isOpen) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
async show(e, termView) {
|
||||
if (!termView) return;
|
||||
this.currentTerminal = termView;
|
||||
|
||||
// 检查是否有选中文本
|
||||
const hasSelection = termView.xterm ? termView.xterm.hasSelection() : false;
|
||||
|
||||
// 检查当前终端是否是 Agent 终端 (在 Agent 面板内)
|
||||
const isAgent = !!(
|
||||
termView.isAgent ||
|
||||
termView.element?.closest('.agent-panel') ||
|
||||
termView.terminalWrapper?.closest('.agent-panel')
|
||||
);
|
||||
|
||||
// 检查当前终端是否处于分屏状态(所属标签页中是否有多于 1 个终端)
|
||||
let activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab || !activeTab.splitTree?.terminals.has(termView.id)) {
|
||||
if (this.tabManager?.tabs) {
|
||||
for (const tab of this.tabManager.tabs.values()) {
|
||||
if (tab.splitTree?.terminals.has(termView.id)) {
|
||||
activeTab = tab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const isSplit = isAgent || !!(activeTab && activeTab.splitTree && activeTab.splitTree.terminals.size > 1);
|
||||
|
||||
// 获取本机已安装的 Agent (agy, codex, claude) - 仅在非 Agent 面板中右键时检索
|
||||
let installedAgents = [];
|
||||
if (!isAgent) {
|
||||
try {
|
||||
if (window.terminalAPI?.getInstalledAgents) {
|
||||
const agents = await window.terminalAPI.getInstalledAgents();
|
||||
installedAgents = (agents || []).filter((a) => a.installed);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to query installed agents:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染菜单项
|
||||
this.renderMenu(hasSelection, installedAgents, isSplit, isAgent);
|
||||
|
||||
// 计算定位与防止越界
|
||||
this.menuElement.style.display = 'flex';
|
||||
this.menuElement.classList.add('active');
|
||||
this.isOpen = true;
|
||||
|
||||
const menuRect = this.menuElement.getBoundingClientRect();
|
||||
let x = e.clientX || 0;
|
||||
let y = e.clientY || 0;
|
||||
|
||||
if (x + menuRect.width > window.innerWidth - 8) {
|
||||
x = window.innerWidth - menuRect.width - 8;
|
||||
}
|
||||
if (y + menuRect.height > window.innerHeight - 8) {
|
||||
y = window.innerHeight - menuRect.height - 8;
|
||||
}
|
||||
if (x < 8) x = 8;
|
||||
if (y < 8) y = 8;
|
||||
|
||||
this.menuElement.style.left = `${Math.round(x)}px`;
|
||||
this.menuElement.style.top = `${Math.round(y)}px`;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
this.menuElement.classList.remove('active');
|
||||
this.menuElement.style.display = 'none';
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
renderMenu(hasSelection, installedAgents, isSplit = false, isAgent = false) {
|
||||
this.menuElement.innerHTML = '';
|
||||
|
||||
// 1. 复制
|
||||
const copyItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>`,
|
||||
label: '复制 (Copy)',
|
||||
shortcut: 'Ctrl+Shift+C',
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
this.currentTerminal?.copySelection();
|
||||
},
|
||||
});
|
||||
copyItem.id = 'ctx-copy';
|
||||
this.menuElement.appendChild(copyItem);
|
||||
|
||||
// 2. 粘贴
|
||||
const pasteItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>`,
|
||||
label: '粘贴 (Paste)',
|
||||
shortcut: 'Ctrl+Shift+V',
|
||||
action: () => {
|
||||
this.currentTerminal?.pasteFromClipboard();
|
||||
},
|
||||
});
|
||||
pasteItem.id = 'ctx-paste';
|
||||
this.menuElement.appendChild(pasteItem);
|
||||
|
||||
// 分隔线
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
// Agent 面板特化菜单:只有关闭窗口功能,没有分屏和打开终端功能
|
||||
if (isAgent) {
|
||||
const closeItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
label: '关闭窗口',
|
||||
shortcut: 'Ctrl+Shift+W',
|
||||
disabled: false,
|
||||
title: '关闭当前窗口 (Ctrl+Shift+W)',
|
||||
action: () => {
|
||||
this.closeCurrentSplitTerminal();
|
||||
},
|
||||
});
|
||||
closeItem.id = 'ctx-close-term';
|
||||
closeItem.classList.add('ctx-close-pane');
|
||||
this.menuElement.appendChild(closeItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 常规终端菜单
|
||||
// 3. 浏览当前目录文件
|
||||
const fileItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>`,
|
||||
label: '浏览当前目录文件',
|
||||
shortcut: 'Ctrl+Shift+U',
|
||||
action: async () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (!tab || !tab.splitTree) return;
|
||||
let cwd = this.currentTerminal?.cwd || '~';
|
||||
try {
|
||||
if (this.currentTerminal && window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(this.currentTerminal.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
tab.splitTree.toggleFilePanel(cwd);
|
||||
},
|
||||
});
|
||||
fileItem.id = 'ctx-file-browser';
|
||||
this.menuElement.appendChild(fileItem);
|
||||
|
||||
// 4. 向右分屏
|
||||
const splitHItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||
</svg>`,
|
||||
label: '向右水平分屏',
|
||||
shortcut: 'Ctrl+Shift+E',
|
||||
action: () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
},
|
||||
});
|
||||
splitHItem.id = 'ctx-split-h';
|
||||
this.menuElement.appendChild(splitHItem);
|
||||
|
||||
// 4. 向下分屏
|
||||
const splitVItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>`,
|
||||
label: '向下垂直分屏',
|
||||
shortcut: 'Ctrl+Shift+O',
|
||||
action: () => {
|
||||
const tab = this.tabManager?.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
},
|
||||
});
|
||||
splitVItem.id = 'ctx-split-v';
|
||||
this.menuElement.appendChild(splitVItem);
|
||||
|
||||
// 5. 关闭当前终端 (只关闭分屏的终端)
|
||||
const closeItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
label: '关闭当前终端',
|
||||
shortcut: 'Ctrl+Shift+W',
|
||||
disabled: !isSplit,
|
||||
title: !isSplit ? '仅在分屏时可关闭终端' : '关闭当前终端 (Ctrl+Shift+W)',
|
||||
action: () => {
|
||||
this.closeCurrentSplitTerminal();
|
||||
},
|
||||
});
|
||||
closeItem.id = 'ctx-close-term';
|
||||
closeItem.classList.add('ctx-close-pane');
|
||||
this.menuElement.appendChild(closeItem);
|
||||
|
||||
// 分隔线
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
// 6. 清屏
|
||||
const clearItem = this.createMenuItem({
|
||||
icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>`,
|
||||
label: '清空当前屏幕',
|
||||
shortcut: 'Ctrl+L',
|
||||
action: () => {
|
||||
this.currentTerminal?.xterm?.clear();
|
||||
},
|
||||
});
|
||||
clearItem.id = 'ctx-clear';
|
||||
this.menuElement.appendChild(clearItem);
|
||||
|
||||
// 7. 已安装的 Agent (如果有安装)
|
||||
if (installedAgents && installedAgents.length > 0) {
|
||||
this.menuElement.appendChild(this.createDivider());
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ctx-menu-header';
|
||||
header.textContent = 'AI AGENTS (右侧分屏)';
|
||||
this.menuElement.appendChild(header);
|
||||
|
||||
for (const agent of installedAgents) {
|
||||
const agentItem = this.createMenuItem({
|
||||
icon: AGENT_ICONS[agent.id] || AGENT_ICONS.default,
|
||||
label: `打开 ${agent.name}`,
|
||||
badge: '右侧分屏',
|
||||
action: () => {
|
||||
this.launchAgentInSplit(agent);
|
||||
},
|
||||
});
|
||||
agentItem.id = `ctx-open-${agent.id}`;
|
||||
this.menuElement.appendChild(agentItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createMenuItem({ icon, label, shortcut, badge, disabled, title, action }) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ctx-menu-item' + (disabled ? ' disabled' : '');
|
||||
if (title) item.title = title;
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'ctx-menu-item-left';
|
||||
left.innerHTML = `<span class="ctx-menu-icon">${icon || ''}</span><span class="ctx-menu-label">${label}</span>`;
|
||||
item.appendChild(left);
|
||||
|
||||
if (badge) {
|
||||
const badgeEl = document.createElement('span');
|
||||
badgeEl.className = 'ctx-menu-badge';
|
||||
badgeEl.textContent = badge;
|
||||
item.appendChild(badgeEl);
|
||||
} else if (shortcut) {
|
||||
const shortcutEl = document.createElement('span');
|
||||
shortcutEl.className = 'ctx-menu-shortcut';
|
||||
shortcutEl.textContent = shortcut;
|
||||
item.appendChild(shortcutEl);
|
||||
}
|
||||
|
||||
if (!disabled && action) {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
createDivider() {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'ctx-menu-divider';
|
||||
return divider;
|
||||
}
|
||||
|
||||
async launchAgentInSplit(agent) {
|
||||
const activeTab = this.tabManager?.getActiveTab();
|
||||
if (!activeTab || !activeTab.splitTree) return;
|
||||
|
||||
// 获取当前右键终端的真实工作目录
|
||||
let cwd = this.currentTerminal?.cwd || '~';
|
||||
try {
|
||||
if (this.currentTerminal && window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(this.currentTerminal.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 新打开的 agent 在特化的 Agent 面板中展示 (每个大标签页独占一个,内部多小标签页)
|
||||
const agentCmd = agent.path ? `"${agent.path}"` : (agent.cmd || 'bash');
|
||||
const spawnOpts = {
|
||||
cwd,
|
||||
shell: '/bin/bash',
|
||||
args: ['-c', `exec ${agentCmd}`],
|
||||
title: agent.name || agent.cmd,
|
||||
};
|
||||
|
||||
if (typeof activeTab.splitTree.openAgent === 'function') {
|
||||
activeTab.splitTree.openAgent(agent, spawnOpts);
|
||||
} else if (typeof activeTab.splitTree.splitRoot === 'function') {
|
||||
activeTab.splitTree.splitRoot('horizontal', spawnOpts);
|
||||
} else {
|
||||
activeTab.splitTree.splitCurrent('horizontal', spawnOpts);
|
||||
}
|
||||
}
|
||||
|
||||
closeCurrentSplitTerminal() {
|
||||
if (!this.currentTerminal) return;
|
||||
|
||||
let tab = this.tabManager?.getActiveTab();
|
||||
if (!tab || !tab.splitTree?.terminals.has(this.currentTerminal.id)) {
|
||||
if (this.tabManager?.tabs) {
|
||||
for (const t of this.tabManager.tabs.values()) {
|
||||
if (t.splitTree?.terminals.has(this.currentTerminal.id)) {
|
||||
tab = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tab || !tab.splitTree) return;
|
||||
|
||||
// 如果是 Agent 终端,直接通过 splitTree.closeTerminal 关闭当前 agent 标签页/窗口
|
||||
if (
|
||||
this.currentTerminal.isAgent ||
|
||||
this.currentTerminal.element?.closest('.agent-panel') ||
|
||||
this.currentTerminal.terminalWrapper?.closest('.agent-panel')
|
||||
) {
|
||||
tab.splitTree.closeTerminal(this.currentTerminal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 严格限制:只关闭分屏的终端(即窗格数量大于 1),单个终端绝不关闭
|
||||
if (tab.splitTree.terminals.size <= 1) return;
|
||||
|
||||
tab.splitTree.closeTerminal(this.currentTerminal.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 文件浏览面板组件 (FilePanel)
|
||||
* 每个大标签页独占至多一个,默认展示终端同目录的文件与文件夹。
|
||||
* 支持进入子目录、返回上层、刷新,以及显隐隐藏文件 (dotfiles) 的开关切换。
|
||||
*/
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0 || !bytes) return '';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
if (i <= 0) return bytes + ' B';
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export class FilePanel {
|
||||
constructor(options = {}) {
|
||||
this.tabSplitTree = options.tabSplitTree;
|
||||
this.initialDir = options.initialDir || null;
|
||||
this.onClose = options.onClose || (() => {});
|
||||
|
||||
this.showHidden = false; // 是否显示隐藏文件
|
||||
this.currentPath = null;
|
||||
this.parentPath = null;
|
||||
this.entries = [];
|
||||
this.selectedPath = null;
|
||||
|
||||
this.paneElement = document.createElement('div');
|
||||
this.paneElement.className = 'split-pane file-panel';
|
||||
this.paneElement.dataset.filePanel = 'true';
|
||||
|
||||
this.initDOM();
|
||||
this.initPath();
|
||||
}
|
||||
|
||||
initDOM() {
|
||||
this.paneElement.innerHTML = `
|
||||
<div class="file-panel-header">
|
||||
<div class="file-panel-header-left">
|
||||
<svg class="file-panel-header-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span class="file-panel-title">文件浏览</span>
|
||||
</div>
|
||||
<div class="file-panel-header-actions">
|
||||
<button class="btn-icon file-toggle-hidden" id="file-toggle-hidden" title="显示隐藏文件 (当前已隐藏)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-up-btn" title="返回上一级目录">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-refresh-btn" title="刷新目录 (Refresh)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon file-panel-close-btn" title="关闭文件浏览面板">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-panel-pathbar">
|
||||
<span class="file-panel-path" title="">/</span>
|
||||
</div>
|
||||
<div class="file-panel-body">
|
||||
<div class="file-list" role="list"></div>
|
||||
<div class="file-panel-empty" style="display: none;">目录为空</div>
|
||||
<div class="file-panel-error" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="file-panel-footer">
|
||||
<span class="file-count-info">0 项</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.toggleHiddenBtn = this.paneElement.querySelector('.file-toggle-hidden');
|
||||
this.upBtn = this.paneElement.querySelector('.file-up-btn');
|
||||
this.refreshBtn = this.paneElement.querySelector('.file-refresh-btn');
|
||||
this.closeBtn = this.paneElement.querySelector('.file-panel-close-btn');
|
||||
this.pathDisplay = this.paneElement.querySelector('.file-panel-path');
|
||||
this.fileListEl = this.paneElement.querySelector('.file-list');
|
||||
this.emptyEl = this.paneElement.querySelector('.file-panel-empty');
|
||||
this.errorEl = this.paneElement.querySelector('.file-panel-error');
|
||||
this.footerInfo = this.paneElement.querySelector('.file-count-info');
|
||||
|
||||
// 绑定显隐隐藏文件开关
|
||||
this.toggleHiddenBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleHidden();
|
||||
});
|
||||
|
||||
// 返回上级
|
||||
this.upBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.parentPath) {
|
||||
this.loadDirectory(this.parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新
|
||||
this.refreshBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.currentPath) {
|
||||
this.loadDirectory(this.currentPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭文件面板
|
||||
this.closeBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
async initPath() {
|
||||
if (this.initialDir) {
|
||||
await this.loadDirectory(this.initialDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认展示终端同目录
|
||||
let cwd = '~';
|
||||
const term = this.tabSplitTree?.activeTerminal;
|
||||
if (term) {
|
||||
if (term.cwd) cwd = term.cwd;
|
||||
try {
|
||||
if (window.terminalAPI?.getPtyCwd) {
|
||||
const realCwd = await window.terminalAPI.getPtyCwd(term.id);
|
||||
if (realCwd) cwd = realCwd;
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (this.tabSplitTree?.spawnOptions?.cwd) {
|
||||
cwd = this.tabSplitTree.spawnOptions.cwd;
|
||||
}
|
||||
|
||||
await this.loadDirectory(cwd);
|
||||
}
|
||||
|
||||
async loadDirectory(dirPath) {
|
||||
if (!dirPath) return;
|
||||
|
||||
try {
|
||||
this.errorEl.style.display = 'none';
|
||||
const result = await window.terminalAPI.readDir({
|
||||
dirPath,
|
||||
showHidden: this.showHidden,
|
||||
});
|
||||
|
||||
if (!result || !result.success) {
|
||||
this.errorEl.textContent = result?.error || '无法读取目录';
|
||||
this.errorEl.style.display = 'block';
|
||||
this.emptyEl.style.display = 'none';
|
||||
this.fileListEl.innerHTML = '';
|
||||
this.footerInfo.textContent = '读取失败';
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentPath = result.dirPath;
|
||||
this.parentPath = result.parentPath;
|
||||
this.entries = result.entries || [];
|
||||
|
||||
// 更新顶部路径指示
|
||||
this.pathDisplay.textContent = this.currentPath;
|
||||
this.pathDisplay.title = this.currentPath;
|
||||
|
||||
// 更新返回上级按钮状态
|
||||
if (this.upBtn) {
|
||||
this.upBtn.disabled = !this.parentPath;
|
||||
this.upBtn.classList.toggle('disabled', !this.parentPath);
|
||||
}
|
||||
|
||||
// 渲染列表
|
||||
this.renderEntries();
|
||||
} catch (err) {
|
||||
console.error('Failed to load directory in FilePanel:', err);
|
||||
this.errorEl.textContent = err.message || '加载目录出错';
|
||||
this.errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
renderEntries() {
|
||||
this.fileListEl.innerHTML = '';
|
||||
|
||||
if (this.entries.length === 0) {
|
||||
this.emptyEl.style.display = 'block';
|
||||
this.footerInfo.textContent = '空目录';
|
||||
return;
|
||||
}
|
||||
|
||||
this.emptyEl.style.display = 'none';
|
||||
|
||||
let dirCount = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
for (const item of this.entries) {
|
||||
if (item.isDirectory) dirCount++;
|
||||
else fileCount++;
|
||||
|
||||
const itemEl = document.createElement('div');
|
||||
itemEl.className = 'file-item' + (item.isDirectory ? ' is-directory' : ' is-file');
|
||||
if (item.isHidden) itemEl.classList.add('is-hidden');
|
||||
if (this.selectedPath === item.path) itemEl.classList.add('selected');
|
||||
itemEl.dataset.path = item.path;
|
||||
itemEl.title = `${item.name}${item.size ? ' (' + formatFileSize(item.size) + ')' : ''}`;
|
||||
|
||||
const iconSvg = item.isDirectory
|
||||
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"></path>
|
||||
</svg>`
|
||||
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>`;
|
||||
|
||||
const sizeStr = item.isFile && item.size !== undefined ? formatFileSize(item.size) : '';
|
||||
|
||||
itemEl.innerHTML = `
|
||||
<span class="file-item-icon">${iconSvg}</span>
|
||||
<span class="file-item-name">${item.name}</span>
|
||||
${sizeStr ? `<span class="file-item-size">${sizeStr}</span>` : ''}
|
||||
`;
|
||||
|
||||
// 单击选择
|
||||
itemEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectItem(item.path, itemEl);
|
||||
if (item.isDirectory) {
|
||||
// 文件夹单击进入
|
||||
this.loadDirectory(item.path);
|
||||
}
|
||||
});
|
||||
|
||||
// 双击进入或操作
|
||||
itemEl.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.isDirectory) {
|
||||
this.loadDirectory(item.path);
|
||||
} else {
|
||||
// 双击文件复制路径到剪贴板
|
||||
if (window.terminalAPI?.writeClipboard) {
|
||||
window.terminalAPI.writeClipboard(item.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.fileListEl.appendChild(itemEl);
|
||||
}
|
||||
|
||||
this.footerInfo.textContent = `${dirCount} 个文件夹, ${fileCount} 个文件`;
|
||||
}
|
||||
|
||||
selectItem(itemPath, itemEl) {
|
||||
this.selectedPath = itemPath;
|
||||
const prev = this.fileListEl.querySelector('.file-item.selected');
|
||||
if (prev) prev.classList.remove('selected');
|
||||
itemEl?.classList.add('selected');
|
||||
}
|
||||
|
||||
toggleHidden() {
|
||||
this.showHidden = !this.showHidden;
|
||||
this.updateHiddenButton();
|
||||
if (this.currentPath) {
|
||||
this.loadDirectory(this.currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
updateHiddenButton() {
|
||||
if (!this.toggleHiddenBtn) return;
|
||||
this.toggleHiddenBtn.classList.toggle('active', this.showHidden);
|
||||
this.toggleHiddenBtn.title = this.showHidden ? '隐藏隐藏文件 (当前已显示)' : '显示隐藏文件 (当前已隐藏)';
|
||||
|
||||
// 图标切换 (睁眼 / 闭眼)
|
||||
this.toggleHiddenBtn.innerHTML = this.showHidden
|
||||
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</svg>`
|
||||
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.onClose(this);
|
||||
this.paneElement.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TerminalView } from './terminal-view.js';
|
||||
import { AgentPanel } from './agent-panel.js';
|
||||
import { FilePanel } from './file-panel.js';
|
||||
|
||||
export class TabSplitTree {
|
||||
constructor(options = {}) {
|
||||
@@ -8,9 +10,12 @@ export class TabSplitTree {
|
||||
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';
|
||||
@@ -41,6 +46,9 @@ export class TabSplitTree {
|
||||
onExit: () => {
|
||||
this.closeTerminal(termView.id);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
this.terminals.set(termView.id, {
|
||||
@@ -57,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;
|
||||
|
||||
@@ -83,7 +91,11 @@ export class TabSplitTree {
|
||||
|
||||
// 添加新窗格
|
||||
const currentSpawn = currentInfo ? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args } : this.spawnOptions;
|
||||
const newTerm = this.addPane(splitWrapper, direction, currentSpawn);
|
||||
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%';
|
||||
|
||||
@@ -95,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) {
|
||||
@@ -155,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;
|
||||
@@ -181,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,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();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export class TabManager {
|
||||
this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
|
||||
this.onActiveChange = options.onActiveChange || (() => {});
|
||||
this.onTabListChange = options.onTabListChange || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title }
|
||||
this.activeTabId = null;
|
||||
@@ -96,6 +97,9 @@ export class TabManager {
|
||||
onEmpty: () => {
|
||||
this.closeTab(tabId);
|
||||
},
|
||||
onContextMenu: (e, term) => {
|
||||
this.onContextMenu?.(e, term);
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 标签页点击与关闭事件
|
||||
@@ -236,4 +240,20 @@ export class TabManager {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export class TerminalView {
|
||||
this.onTitleChange = options.onTitleChange || (() => {});
|
||||
this.onFocus = options.onFocus || (() => {});
|
||||
this.onExit = options.onExit || (() => {});
|
||||
this.onContextMenu = options.onContextMenu || null;
|
||||
|
||||
this.isFocused = false;
|
||||
|
||||
@@ -43,7 +44,7 @@ export class TerminalView {
|
||||
theme: currentTheme.xterm,
|
||||
macOptionIsMeta: true,
|
||||
rightClickSelectsWord: true,
|
||||
convertEol: true,
|
||||
convertEol: false,
|
||||
});
|
||||
|
||||
// 插件初始化
|
||||
@@ -60,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();
|
||||
@@ -81,6 +97,20 @@ export class TerminalView {
|
||||
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;
|
||||
@@ -123,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;
|
||||
@@ -182,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;
|
||||
@@ -192,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();
|
||||
|
||||
@@ -76,7 +76,7 @@ export class WorkspaceManager {
|
||||
|
||||
this.workspaces = [];
|
||||
this.activeWorkspaceId = 'ws_local';
|
||||
this.isCollapsed = false;
|
||||
this.isCollapsed = true;
|
||||
this.editingWorkspaceId = null;
|
||||
|
||||
this.init();
|
||||
@@ -122,13 +122,14 @@ export class WorkspaceManager {
|
||||
this.activeWorkspaceId = this.workspaces[0].id;
|
||||
}
|
||||
|
||||
this.isCollapsed = localStorage.getItem('hatty:sidebar-collapsed') === 'true';
|
||||
if (this.isCollapsed) {
|
||||
this.sidebar?.classList.add('collapsed');
|
||||
// 打开程序时,工作区默认折叠起来
|
||||
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));
|
||||
|
||||
+1001
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user