feat: add agent panel, file panel, context menu, fix agy cursor, and bump to v1.1.0

- 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:
2026-09-25 01:45:51 +08:00
parent 2db388c2f2
commit a95ce8baa5
17 changed files with 3215 additions and 29 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hatty", "name": "hatty",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hatty", "name": "hatty",
"version": "1.0.1", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hatty", "name": "hatty",
"version": "1.0.1", "version": "1.1.0",
"description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端", "description": "hatty - 一个超高颜值、极速响应的现代 Linux 终端",
"main": "src/main/index.cjs", "main": "src/main/index.cjs",
"scripts": { "scripts": {
+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 path = require('path');
const os = require('os'); const os = require('os');
const { PtyManager } = require('./pty-manager.cjs'); const { PtyManager } = require('./pty-manager.cjs');
@@ -101,6 +101,147 @@ ipcMain.on('pty:kill', (event, { id }) => {
if (ptyManager) ptyManager.kill({ id }); if (ptyManager) ptyManager.kill({ id });
}); });
// 剪贴板 IPC
ipcMain.handle('clipboard:read', () => {
return clipboard.readText();
});
ipcMain.on('clipboard:write', (event, text) => {
clipboard.writeText(text || '');
});
// 获取 PTY 实时工作目录
ipcMain.handle('pty:get-cwd', (event, { id }) => {
return ptyManager ? ptyManager.getCwd({ id }) : null;
});
// 检查可执行命令路径
const fs = require('fs');
function findExecutable(command) {
const isWindows = process.platform === 'win32';
const exts = isWindows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
const dirs = (process.env.PATH || '').split(path.delimiter);
const extraDirs = [
path.join(os.homedir(), '.local/bin'),
path.join(os.homedir(), '.npm-global/bin'),
path.join(os.homedir(), '.cargo/bin'),
'/usr/local/bin',
'/usr/bin',
'/bin',
];
const allDirs = [...new Set([...dirs, ...extraDirs])];
for (const dir of allDirs) {
for (const ext of exts) {
const fullPath = path.join(dir, command + ext);
try {
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
} catch (_) {}
}
}
return null;
}
// 检查已安装的 Agent (agy / codex / claude)
ipcMain.handle('app:get-installed-agents', () => {
const agentDefs = [
{ id: 'agy', name: 'agy', cmd: 'agy', desc: 'Antigravity CLI Agent' },
{ id: 'codex', name: 'codex', cmd: 'codex', desc: 'OpenAI CodeX CLI' },
{ id: 'claude', name: 'claude', cmd: 'claude', desc: 'Claude Code Agent' },
];
return agentDefs.map((agent) => {
const execPath = findExecutable(agent.cmd);
return {
...agent,
path: execPath,
installed: !!execPath,
};
});
});
// 文件浏览 IPC:读取目录中的文件和文件夹
ipcMain.handle('fs:read-dir', async (event, { dirPath, showHidden = false } = {}) => {
try {
let targetPath = dirPath || '~';
if (targetPath === '~' || targetPath.startsWith('~/')) {
targetPath = path.join(os.homedir(), targetPath.slice(targetPath === '~' ? 1 : 2));
}
targetPath = path.resolve(targetPath);
if (!fs.existsSync(targetPath)) {
return { success: false, error: '目录不存在: ' + targetPath, dirPath: targetPath };
}
const stat = await fs.promises.stat(targetPath);
if (!stat.isDirectory()) {
return { success: false, error: '路径不是有效目录: ' + targetPath, dirPath: targetPath };
}
const dirents = await fs.promises.readdir(targetPath, { withFileTypes: true });
const entries = [];
for (const d of dirents) {
const isHidden = d.name.startsWith('.');
if (!showHidden && isHidden) continue;
let isDirectory = d.isDirectory();
let isFile = d.isFile();
let isSymbolicLink = d.isSymbolicLink();
let size = 0;
let mtime = 0;
const fullItemPath = path.join(targetPath, d.name);
if (isSymbolicLink) {
try {
const realStat = await fs.promises.stat(fullItemPath);
isDirectory = realStat.isDirectory();
isFile = realStat.isFile();
size = realStat.size;
mtime = realStat.mtimeMs;
} catch (_) {}
} else {
try {
if (isFile) {
const fStat = await fs.promises.stat(fullItemPath);
size = fStat.size;
mtime = fStat.mtimeMs;
}
} catch (_) {}
}
entries.push({
name: d.name,
path: fullItemPath,
isDirectory,
isFile,
isSymbolicLink,
isHidden,
size,
mtime,
});
}
// 排序:文件夹优先,同类按文件名升序排列
entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true });
});
const parentPath = path.dirname(targetPath);
return {
success: true,
dirPath: targetPath,
parentPath: parentPath !== targetPath ? parentPath : null,
entries,
};
} catch (err) {
return { success: false, error: err.message, dirPath };
}
});
ipcMain.handle('app:get-system-info', () => { ipcMain.handle('app:get-system-info', () => {
return { return {
platform: os.platform(), platform: os.platform(),
+12
View File
@@ -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() { killAll() {
for (const [id, ptyProcess] of this.ptys.entries()) { for (const [id, ptyProcess] of this.ptys.entries()) {
try { try {
+11
View File
@@ -32,4 +32,15 @@ contextBridge.exposeInMainWorld('terminalAPI', {
// 系统信息 // 系统信息
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'), getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
// 剪贴板管理
readClipboard: () => ipcRenderer.invoke('clipboard:read'),
writeClipboard: (text) => ipcRenderer.send('clipboard:write', text),
// PTY 与 Agent 查询
getPtyCwd: (id) => ipcRenderer.invoke('pty:get-cwd', { id }),
getInstalledAgents: () => ipcRenderer.invoke('app:get-installed-agents'),
// 文件浏览
readDir: (options) => ipcRenderer.invoke('fs:read-dir', options),
}); });
+526
View File
@@ -261,3 +261,529 @@
from { transform: translateY(-10px); opacity: 0; } from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
/* 终端右键上下文菜单 */
.terminal-context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-titlebar);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md, 6px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5), 0 0 1px rgba(255, 255, 255, 0.1);
min-width: 200px;
padding: 4px;
display: none;
flex-direction: column;
gap: 2px;
user-select: none;
font-size: 12px;
color: var(--text-color);
animation: fadeIn 0.1s ease;
backdrop-filter: blur(12px);
}
.terminal-context-menu.active {
display: flex;
}
.ctx-menu-header {
padding: 6px 8px 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.ctx-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: var(--border-radius-sm, 4px);
cursor: pointer;
transition: background 0.12s, color 0.12s;
color: var(--text-color);
}
.ctx-menu-item:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-bright);
}
.ctx-menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ctx-menu-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.ctx-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--text-muted);
flex-shrink: 0;
}
.ctx-menu-item:hover:not(.disabled) .ctx-menu-icon {
color: var(--border-active, #3b82f6);
}
.ctx-menu-label {
font-size: 12px;
}
.ctx-menu-shortcut {
font-size: 10px;
color: var(--text-muted);
margin-left: 12px;
opacity: 0.8;
}
.ctx-menu-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
margin-left: 12px;
letter-spacing: 0.2px;
white-space: nowrap;
}
.ctx-menu-divider {
height: 1px;
background: var(--border-color);
margin: 4px 2px;
opacity: 0.6;
}
#ctx-close-term:hover:not(.disabled) {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
#ctx-close-term:hover:not(.disabled) .ctx-menu-icon {
color: #ef4444;
}
/* 顶部/全局图标按钮禁用状态:变灰且不可点击 */
.btn-icon:disabled,
.btn-icon.disabled {
opacity: 0.28 !important;
cursor: not-allowed !important;
pointer-events: none !important;
filter: grayscale(1);
}
/* 专属 Agent 面板 (AgentPanel) */
.agent-panel {
display: flex;
flex-direction: column;
background: var(--bg-app);
border-left: 1px solid var(--border-color);
min-width: 240px;
position: relative;
overflow: hidden;
}
.agent-panel-header {
height: 28px;
min-height: 28px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px 0 10px;
user-select: none;
}
.agent-panel-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.agent-panel-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--accent-color, #7aa2f7);
text-transform: uppercase;
flex-shrink: 0;
}
.agent-panel-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
min-width: 0;
}
.agent-panel-tabs::-webkit-scrollbar {
display: none;
}
/* Agent 迷你小标签页: 紧凑小巧,主要展示对应 agent 图标与关闭按钮 */
.agent-tab {
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px;
background: var(--bg-tab-inactive, rgba(255, 255, 255, 0.04));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 4px);
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-muted);
flex-shrink: 0;
}
.agent-tab:hover {
background: var(--bg-tab-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
border-color: var(--border-hover, rgba(255, 255, 255, 0.15));
}
.agent-tab.active {
background: var(--bg-tab-active, rgba(59, 130, 246, 0.15));
border-color: var(--border-active, #3b82f6);
color: var(--accent-color, #7aa2f7);
box-shadow: 0 0 8px var(--accent-glow, rgba(59, 130, 246, 0.25));
}
.agent-tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
transition: color 0.15s ease, opacity 0.15s ease;
}
.agent-tab-icon svg {
width: 14px;
height: 14px;
}
/* Agent 品牌特化专属色彩与微光效果 */
.agent-tab[data-agent-id="agy"] .agent-tab-icon,
.ctx-menu-item#ctx-open-agy .ctx-menu-icon {
color: #4285f4;
}
.agent-tab[data-agent-id="codex"] .agent-tab-icon,
.ctx-menu-item#ctx-open-codex .ctx-menu-icon {
color: #f1f5f9;
}
[data-theme="light"] .agent-tab[data-agent-id="codex"] .agent-tab-icon,
[data-theme="light"] .ctx-menu-item#ctx-open-codex .ctx-menu-icon {
color: #000000;
}
.agent-tab[data-agent-id="claude"] .agent-tab-icon,
.ctx-menu-item#ctx-open-claude .ctx-menu-icon {
color: #d97757;
}
.agent-tab:not(.active) .agent-tab-icon {
opacity: 0.85;
}
.agent-tab:hover .agent-tab-icon,
.agent-tab.active .agent-tab-icon {
opacity: 1;
}
.agent-tab.active[data-agent-id="agy"] {
background: rgba(66, 133, 244, 0.16);
border-color: #4285f4;
box-shadow: 0 0 8px rgba(66, 133, 244, 0.35);
}
.agent-tab.active[data-agent-id="codex"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
}
[data-theme="light"] .agent-tab.active[data-agent-id="codex"] {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.agent-tab.active[data-agent-id="claude"] {
background: rgba(217, 119, 87, 0.16);
border-color: #d97757;
box-shadow: 0 0 8px rgba(217, 119, 87, 0.3);
}
/* 标签做小,只展示对应 agent 图标 (文字隐藏,hover显示title) */
.agent-tab-name {
display: none;
}
.agent-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
margin-left: 2px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
border-radius: 2px;
cursor: pointer;
opacity: 0.6;
transition: all 0.12s ease;
}
.agent-tab-close:hover {
opacity: 1;
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.agent-panel-header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.agent-panel-close-btn {
width: 20px;
height: 20px;
padding: 0;
}
.agent-panel-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.agent-panel-body {
flex: 1;
width: 100%;
height: calc(100% - 28px);
position: relative;
overflow: hidden;
display: flex;
}
.agent-tab-view {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: none;
}
.agent-tab-view.active {
display: flex;
}
/* 文件浏览面板 (FilePanel) */
.file-panel {
display: flex;
flex-direction: column;
background: var(--bg-app);
border-right: 1px solid var(--border-color);
width: 240px;
min-width: 160px;
max-width: 500px;
height: 100%;
position: relative;
overflow: hidden;
user-select: none;
flex-shrink: 0;
}
.file-panel-header {
height: 28px;
min-height: 28px;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px 0 10px;
}
.file-panel-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.file-panel-header-icon {
color: var(--accent-color, #7aa2f7);
flex-shrink: 0;
}
.file-panel-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--accent-color, #7aa2f7);
text-transform: uppercase;
white-space: nowrap;
}
.file-panel-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.file-panel-header-actions .btn-icon {
width: 22px;
height: 22px;
border-radius: var(--border-radius-sm, 4px);
}
.file-toggle-hidden {
transition: all 0.15s ease;
}
.file-toggle-hidden.active {
background: rgba(59, 130, 246, 0.2) !important;
color: var(--accent-color, #7aa2f7) !important;
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
}
.file-panel-pathbar {
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono, monospace);
color: var(--text-muted);
background: rgba(0, 0, 0, 0.18);
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
}
.file-panel-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
.file-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-main);
cursor: pointer;
border-radius: var(--border-radius-sm, 4px);
transition: background 0.12s ease, color 0.12s ease;
}
.file-item:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text-bright);
}
.file-item.selected {
background: rgba(59, 130, 246, 0.18);
color: var(--accent-color, #7aa2f7);
}
.file-item.is-hidden {
opacity: 0.55;
font-style: italic;
}
.file-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.file-item.is-directory .file-item-icon {
color: #e0af68;
}
.file-item.is-file .file-item-icon {
color: var(--text-muted);
}
.file-item-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-size {
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
}
.file-panel-empty,
.file-panel-error {
padding: 20px 10px;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
.file-panel-error {
color: #f87171;
}
.file-panel-footer {
height: 22px;
min-height: 22px;
background: var(--bg-titlebar);
border-top: 1px solid var(--border-color);
padding: 0 8px;
display: flex;
align-items: center;
font-size: 10px;
color: var(--text-muted);
}
+9 -4
View File
@@ -16,10 +16,10 @@
<link rel="stylesheet" href="css/sidebar.css"> <link rel="stylesheet" href="css/sidebar.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app" class="sidebar-collapsed">
<!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) --> <!-- 顶部现代拖拽式标题栏 (顶栏与标签页栏合二为一) -->
<header class="titlebar" id="titlebar"> <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)"> <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"> <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> <line x1="3" y1="12" x2="21" y2="12"></line>
</svg> </svg>
</button> </button>
<button class="btn-icon" id="btn-file-browser" title="文件浏览面板 (Ctrl+Shift+U)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
</button>
<button class="btn-icon" id="btn-search" title="查找文本 (Ctrl+Shift+F)"> <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"> <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> <circle cx="11" cy="11" r="8"></circle>
@@ -113,9 +118,9 @@
</header> </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-header">
<div class="sidebar-title"> <div class="sidebar-title">
<svg class="sidebar-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="sidebar-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+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);
}
}
+93 -4
View File
@@ -3,6 +3,7 @@ import { TabManager } from './tab-manager.js';
import { WorkspaceManager } from './workspace-manager.js'; import { WorkspaceManager } from './workspace-manager.js';
import { CommandPalette } from './command-palette.js'; import { CommandPalette } from './command-palette.js';
import { SettingsModal } from './settings.js'; import { SettingsModal } from './settings.js';
import { ContextMenu } from './context-menu.js';
class TerminalApp { class TerminalApp {
constructor() { constructor() {
@@ -10,6 +11,7 @@ class TerminalApp {
this.initTheme(); this.initTheme();
this.initTabManager(); this.initTabManager();
this.initWorkspaceManager(); this.initWorkspaceManager();
this.initContextMenu();
this.initModals(); this.initModals();
this.initSearch(); this.initSearch();
this.initWindowControls(); this.initWindowControls();
@@ -59,6 +61,10 @@ class TerminalApp {
if (activeTerm && this.statusShell) { if (activeTerm && this.statusShell) {
this.statusShell.textContent = activeTerm.title || 'bash'; this.statusShell.textContent = activeTerm.title || 'bash';
} }
this.updateSplitButtonsState(activeTerm);
},
onContextMenu: (e, term) => {
this.contextMenu?.show(e, term);
}, },
}); });
@@ -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() { initWorkspaceManager() {
this.workspaceManager = new WorkspaceManager({ this.workspaceManager = new WorkspaceManager({
tabManager: this.tabManager, 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() { initModals() {
this.commandPalette = new CommandPalette({ this.commandPalette = new CommandPalette({
overlay: this.commandPaletteOverlay, overlay: this.commandPaletteOverlay,
@@ -109,12 +146,20 @@ class TerminalApp {
document.getElementById('btn-split-h')?.addEventListener('click', () => { document.getElementById('btn-split-h')?.addEventListener('click', () => {
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('horizontal');
}
}); });
document.getElementById('btn-split-v')?.addEventListener('click', () => { document.getElementById('btn-split-v')?.addEventListener('click', () => {
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('vertical');
}
});
document.getElementById('btn-file-browser')?.addEventListener('click', () => {
this.toggleFileBrowser();
}); });
document.getElementById('btn-search')?.addEventListener('click', () => { 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() { initWindowControls() {
document.getElementById('win-min')?.addEventListener('click', () => { document.getElementById('win-min')?.addEventListener('click', () => {
window.terminalAPI.minimize(); window.terminalAPI.minimize();
@@ -241,6 +292,32 @@ class TerminalApp {
return; 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: 新建当前工作区标签页 // Ctrl+Shift+T: 新建当前工作区标签页
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault(); e.preventDefault();
@@ -273,7 +350,9 @@ class TerminalApp {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('horizontal');
}
return; return;
} }
@@ -282,7 +361,9 @@ class TerminalApp {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('vertical');
}
return; return;
} }
@@ -294,6 +375,14 @@ class TerminalApp {
return; 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+,: 设置 // Ctrl+,: 设置
if (e.ctrlKey && e.key === ',') { if (e.ctrlKey && e.key === ',') {
e.preventDefault(); e.preventDefault();
+35 -2
View File
@@ -58,7 +58,9 @@ export class CommandPalette {
icon: 'columns', icon: 'columns',
action: () => { action: () => {
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('horizontal');
}
}, },
}, },
{ {
@@ -68,7 +70,9 @@ export class CommandPalette {
icon: 'rows', icon: 'rows',
action: () => { action: () => {
const tab = this.tabManager.getActiveTab(); const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical'); if (tab && !tab.splitTree.activeTerminal?.isAgent) {
tab.splitTree.splitCurrent('vertical');
}
}, },
}, },
{ {
@@ -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', id: 'search',
title: '终端内容快速搜索 (Search Buffer)', title: '终端内容快速搜索 (Search Buffer)',
@@ -90,6 +103,26 @@ export class CommandPalette {
icon: 'search', icon: 'search',
action: () => this.onToggleSearch(), action: () => this.onToggleSearch(),
}, },
{
id: 'copy-selection',
title: '复制终端选中内容 (Copy Selection)',
shortcut: 'Ctrl+Shift+C',
icon: 'copy',
action: () => {
const term = this.tabManager.getActiveTerminal();
if (term) term.copySelection();
},
},
{
id: 'paste-clipboard',
title: '粘贴剪贴板内容到终端 (Paste Clipboard)',
shortcut: 'Ctrl+Shift+V',
icon: 'clipboard',
action: () => {
const term = this.tabManager.getActiveTerminal();
if (term) term.pasteFromClipboard();
},
},
{ {
id: 'clear', id: 'clear',
title: '清空当前屏幕缓冲区 (Clear Buffer)', title: '清空当前屏幕缓冲区 (Clear Buffer)',
+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();
}
}
+304 -3
View File
@@ -1,4 +1,6 @@
import { TerminalView } from './terminal-view.js'; import { TerminalView } from './terminal-view.js';
import { AgentPanel } from './agent-panel.js';
import { FilePanel } from './file-panel.js';
export class TabSplitTree { export class TabSplitTree {
constructor(options = {}) { constructor(options = {}) {
@@ -8,9 +10,12 @@ export class TabSplitTree {
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {}); this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
this.onEmpty = options.onEmpty || (() => {}); this.onEmpty = options.onEmpty || (() => {});
this.onTitleChange = options.onTitleChange || (() => {}); this.onTitleChange = options.onTitleChange || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.terminals = new Map(); // id -> { termView, paneElement, parentElement } this.terminals = new Map(); // id -> { termView, paneElement, parentElement }
this.activeTerminal = null; this.activeTerminal = null;
this.agentPanel = null; // 每个大标签页独占至多一个 AgentPanel
this.filePanel = null; // 每个大标签页独占至多一个 FilePanel
this.rootElement = document.createElement('div'); this.rootElement = document.createElement('div');
this.rootElement.className = 'split-container horizontal'; this.rootElement.className = 'split-container horizontal';
@@ -41,6 +46,9 @@ export class TabSplitTree {
onExit: () => { onExit: () => {
this.closeTerminal(termView.id); this.closeTerminal(termView.id);
}, },
onContextMenu: (e, term) => {
this.onContextMenu?.(e, term);
},
}); });
this.terminals.set(termView.id, { this.terminals.set(termView.id, {
@@ -57,8 +65,8 @@ export class TabSplitTree {
return termView; return termView;
} }
splitCurrent(direction = 'horizontal') { splitCurrent(direction = 'horizontal', customSpawnOptions = null) {
if (!this.activeTerminal) return; if (!this.activeTerminal || this.activeTerminal.isAgent) return;
const currentInfo = this.terminals.get(this.activeTerminal.id); const currentInfo = this.terminals.get(this.activeTerminal.id);
if (!currentInfo) return; if (!currentInfo) return;
@@ -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 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; const newPane = this.terminals.get(newTerm.id).paneElement;
newPane.style.flex = '1 1 0%'; newPane.style.flex = '1 1 0%';
@@ -95,6 +107,259 @@ export class TabSplitTree {
this.fitAll(); this.fitAll();
newTerm.focus(); newTerm.focus();
}, 50); }, 50);
return newTerm;
}
splitRoot(direction = 'horizontal', customSpawnOptions = null) {
if (this.terminals.size === 0) return null;
// 创建新的顶级分屏容器,使新窗口竖向占满整个容器高度,对当前标签页整体左右分屏
const newRoot = document.createElement('div');
newRoot.className = `split-container ${direction}`;
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
newRoot.appendChild(oldRoot);
// 添加顶级分割线
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
// 添加新窗格(Agent 终端)
const currentInfo = this.activeTerminal ? this.terminals.get(this.activeTerminal.id) : null;
const currentSpawn = currentInfo
? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args }
: this.spawnOptions;
const finalSpawn = {
...currentSpawn,
...(customSpawnOptions || {}),
};
const newTerm = this.addPane(newRoot, direction, finalSpawn);
const newPane = this.terminals.get(newTerm.id).paneElement;
newPane.style.flex = '1 1 0%';
// 更新 rootElement 为新容器
this.rootElement = newRoot;
// 为分割线绑定拖拽调节事件
this.setupDividerDrag(divider, oldRoot, newPane, direction);
// 刷新自适应尺寸与焦点
setTimeout(() => {
this.fitAll();
newTerm.focus();
}, 50);
return newTerm;
}
/**
* 在特化的 Agent 面板中打开 Agent。
* 每个大标签页独占一个此类面板,多个 agent 在该面板内部以多标签页形式展示。
*/
openAgent(agent, customSpawnOptions = {}) {
// 1. 如果该大标签页已经拥有 Agent 面板且仍然挂载在 DOM 中,直接在已有面板中新增/切换标签页
if (this.agentPanel && this.agentPanel.paneElement.isConnected) {
return this.agentPanel.openAgent(agent, customSpawnOptions);
}
// 2. 如果尚未创建 Agent 面板,创建并在右侧分屏(竖向占满全高)
this.agentPanel = new AgentPanel({
tabSplitTree: this,
onContextMenu: this.onContextMenu,
onClose: () => {
this.removeAgentPanel();
},
});
this.splitRootForAgentPanel(this.agentPanel);
return this.agentPanel.openAgent(agent, customSpawnOptions);
}
splitRootForAgentPanel(agentPanel) {
if (this.terminals.size === 0) {
this.rootElement.appendChild(agentPanel.paneElement);
agentPanel.paneElement.style.flex = '1 1 0%';
return;
}
const newRoot = document.createElement('div');
newRoot.className = 'split-container horizontal';
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
newRoot.appendChild(oldRoot);
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
newRoot.appendChild(agentPanel.paneElement);
agentPanel.paneElement.style.flex = '1 1 0%';
this.rootElement = newRoot;
this.setupDividerDrag(divider, oldRoot, agentPanel.paneElement, 'horizontal');
setTimeout(() => {
this.fitAll();
}, 50);
}
removeAgentPanel() {
if (!this.agentPanel) return;
const pane = this.agentPanel.paneElement;
const parent = pane.parentElement;
// 移除相邻分割线
const prev = pane.previousElementSibling;
const next = pane.nextElementSibling;
if (prev && prev.classList.contains('split-divider')) {
prev.remove();
} else if (next && next.classList.contains('split-divider')) {
next.remove();
}
pane.remove();
if (parent === this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
}
}
this.agentPanel = null;
// 如果当前活跃终端在 Agent 面板上,焦点转移给剩余的常规终端
if (!this.activeTerminal || !this.terminals.has(this.activeTerminal.id)) {
if (this.terminals.size > 0) {
const nextTerm = this.terminals.values().next().value.termView;
this.setActiveTerminal(nextTerm);
nextTerm.focus();
}
}
setTimeout(() => this.fitAll(), 40);
}
/**
* 打开文件浏览面板 (每个大标签页独占至多一个)
*/
openFilePanel(initialDir = null) {
if (this.filePanel && this.filePanel.paneElement.isConnected) {
if (initialDir) {
this.filePanel.loadDirectory(initialDir);
}
return this.filePanel;
}
this.filePanel = new FilePanel({
tabSplitTree: this,
initialDir,
onClose: () => {
this.removeFilePanel();
},
});
this.splitRootForFilePanel(this.filePanel);
return this.filePanel;
}
/**
* 切换文件浏览面板显示/隐藏
*/
toggleFilePanel(initialDir = null) {
if (this.filePanel && this.filePanel.paneElement.isConnected) {
this.filePanel.destroy();
return null;
}
return this.openFilePanel(initialDir);
}
splitRootForFilePanel(filePanel) {
if (this.terminals.size === 0) {
this.rootElement.insertBefore(filePanel.paneElement, this.rootElement.firstChild);
filePanel.paneElement.style.width = '240px';
filePanel.paneElement.style.flex = '0 0 240px';
return;
}
const newRoot = document.createElement('div');
newRoot.className = 'split-container horizontal';
newRoot.style.flex = '1 1 0%';
newRoot.style.width = '100%';
newRoot.style.height = '100%';
const oldRoot = this.rootElement;
oldRoot.style.flex = '1 1 0%';
this.container.replaceChild(newRoot, oldRoot);
// 文件浏览面板放置于当前终端区域最左侧
newRoot.appendChild(filePanel.paneElement);
filePanel.paneElement.style.width = '240px';
filePanel.paneElement.style.flex = '0 0 240px';
filePanel.paneElement.style.minWidth = '160px';
filePanel.paneElement.style.maxWidth = '500px';
const divider = document.createElement('div');
divider.className = 'split-divider';
newRoot.appendChild(divider);
newRoot.appendChild(oldRoot);
this.rootElement = newRoot;
this.setupDividerDrag(divider, filePanel.paneElement, oldRoot, 'horizontal');
setTimeout(() => {
this.fitAll();
}, 50);
}
removeFilePanel() {
if (!this.filePanel) return;
const pane = this.filePanel.paneElement;
const parent = pane.parentElement;
const next = pane.nextElementSibling;
const prev = pane.previousElementSibling;
if (next && next.classList.contains('split-divider')) {
next.remove();
} else if (prev && prev.classList.contains('split-divider')) {
prev.remove();
}
pane.remove();
if (parent === this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
}
}
this.filePanel = null;
setTimeout(() => this.fitAll(), 40);
} }
setupDividerDrag(divider, firstElement, secondElement, direction) { setupDividerDrag(divider, firstElement, secondElement, direction) {
@@ -155,6 +420,12 @@ export class TabSplitTree {
const info = this.terminals.get(termId); const info = this.terminals.get(termId);
if (!info) return; if (!info) return;
// 如果是 Agent 终端,委托给 AgentPanel 关闭该 agent 标签
if (info.termView.isAgent && this.agentPanel) {
this.agentPanel.closeAgent(info.termView.agentId);
return;
}
info.termView.destroy(); info.termView.destroy();
const pane = info.paneElement; const pane = info.paneElement;
const parent = pane.parentElement; const parent = pane.parentElement;
@@ -181,6 +452,28 @@ export class TabSplitTree {
const singleChild = remainingChildren[0]; const singleChild = remainingChildren[0];
singleChild.style.flex = parent.style.flex || '1 1 0%'; singleChild.style.flex = parent.style.flex || '1 1 0%';
parent.parentElement.replaceChild(singleChild, parent); parent.parentElement.replaceChild(singleChild, parent);
} else if (remainingChildren.length === 0) {
const pPrev = parent.previousElementSibling;
const pNext = parent.nextElementSibling;
if (pPrev && pPrev.classList.contains('split-divider')) {
pPrev.remove();
} else if (pNext && pNext.classList.contains('split-divider')) {
pNext.remove();
}
parent.remove();
}
}
// 如果根容器中仅剩 1 个子元素且该子元素也是 split-container,消除顶层冗余包裹
if (this.rootElement) {
const remainingChildren = Array.from(this.rootElement.children).filter(
(el) => !el.classList.contains('split-divider')
);
if (remainingChildren.length === 1 && remainingChildren[0].classList.contains('split-container')) {
const singleChild = remainingChildren[0];
singleChild.style.flex = '1 1 0%';
this.container.replaceChild(singleChild, this.rootElement);
this.rootElement = singleChild;
} }
} }
@@ -203,6 +496,14 @@ export class TabSplitTree {
} }
destroy() { destroy() {
if (this.agentPanel) {
this.agentPanel.destroy();
this.agentPanel = null;
}
if (this.filePanel) {
this.filePanel.destroy();
this.filePanel = null;
}
for (const info of this.terminals.values()) { for (const info of this.terminals.values()) {
info.termView.destroy(); info.termView.destroy();
} }
+20
View File
@@ -7,6 +7,7 @@ export class TabManager {
this.getSpawnOptions = options.getSpawnOptions || (() => ({})); this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
this.onActiveChange = options.onActiveChange || (() => {}); this.onActiveChange = options.onActiveChange || (() => {});
this.onTabListChange = options.onTabListChange || (() => {}); this.onTabListChange = options.onTabListChange || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title } this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title }
this.activeTabId = null; this.activeTabId = null;
@@ -96,6 +97,9 @@ export class TabManager {
onEmpty: () => { onEmpty: () => {
this.closeTab(tabId); this.closeTab(tabId);
}, },
onContextMenu: (e, term) => {
this.onContextMenu?.(e, term);
},
}); });
// 4. 标签页点击与关闭事件 // 4. 标签页点击与关闭事件
@@ -236,4 +240,20 @@ export class TabManager {
const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length; const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
this.switchTab(wsTabs[prevIndex].tabId); 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;
}
} }
+80 -1
View File
@@ -16,6 +16,7 @@ export class TerminalView {
this.onTitleChange = options.onTitleChange || (() => {}); this.onTitleChange = options.onTitleChange || (() => {});
this.onFocus = options.onFocus || (() => {}); this.onFocus = options.onFocus || (() => {});
this.onExit = options.onExit || (() => {}); this.onExit = options.onExit || (() => {});
this.onContextMenu = options.onContextMenu || null;
this.isFocused = false; this.isFocused = false;
@@ -43,7 +44,7 @@ export class TerminalView {
theme: currentTheme.xterm, theme: currentTheme.xterm,
macOptionIsMeta: true, macOptionIsMeta: true,
rightClickSelectsWord: true, rightClickSelectsWord: true,
convertEol: true, convertEol: false,
}); });
// 插件初始化 // 插件初始化
@@ -60,9 +61,24 @@ export class TerminalView {
this.terminalWrapper = document.createElement('div'); this.terminalWrapper = document.createElement('div');
this.terminalWrapper.className = 'terminal-wrapper'; this.terminalWrapper.className = 'terminal-wrapper';
this.container.appendChild(this.terminalWrapper); this.container.appendChild(this.terminalWrapper);
this.element = this.terminalWrapper;
this.xterm.open(this.terminalWrapper); this.xterm.open(this.terminalWrapper);
// 右键上下文菜单
this.terminalWrapper.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.focus();
if (typeof this.onContextMenu === 'function') {
this.onContextMenu(e, this);
} else {
window.dispatchEvent(new CustomEvent('terminal-contextmenu', {
detail: { event: e, termView: this }
}));
}
});
// 启用 WebGL 硬件加速 (GPU 渲染) // 启用 WebGL 硬件加速 (GPU 渲染)
try { try {
this.webglAddon = new WebglAddon(); this.webglAddon = new WebglAddon();
@@ -81,6 +97,20 @@ export class TerminalView {
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') { if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') {
return false; 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 // Ctrl+Shift+T / W / E / O / F / P
if (event.ctrlKey && event.shiftKey && ['t', 'w', 'e', 'o', 'f', 'p'].includes(event.key.toLowerCase())) { if (event.ctrlKey && event.shiftKey && ['t', 'w', 'e', 'o', 'f', 'p'].includes(event.key.toLowerCase())) {
return false; return false;
@@ -123,6 +153,17 @@ export class TerminalView {
this.onFocus(this); this.onFocus(this);
}); });
// 监听 PTY 退出并触发 onExit 回调
if (window.terminalAPI?.onPtyExit) {
this.unsubscribeExit = window.terminalAPI.onPtyExit(({ id, exitCode }) => {
if (id === this.id) {
if (typeof this.onExit === 'function') {
this.onExit(exitCode);
}
}
});
}
// 主题监听 // 主题监听
this.unsubscribeTheme = themeManager.onThemeChange((theme) => { this.unsubscribeTheme = themeManager.onThemeChange((theme) => {
this.xterm.options.theme = theme.xterm; this.xterm.options.theme = theme.xterm;
@@ -182,6 +223,40 @@ export class TerminalView {
this.onFocus(this); this.onFocus(this);
} }
async copySelection() {
const selection = this.xterm.getSelection();
if (!selection) return false;
try {
if (window.terminalAPI?.writeClipboard) {
window.terminalAPI.writeClipboard(selection);
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(selection);
}
return true;
} catch (err) {
console.warn('Failed to copy selection to clipboard:', err);
return false;
}
}
async pasteFromClipboard() {
try {
let text = '';
if (window.terminalAPI?.readClipboard) {
text = await window.terminalAPI.readClipboard();
} else if (navigator.clipboard?.readText) {
text = await navigator.clipboard.readText();
}
if (text) {
this.xterm.paste(text);
return true;
}
} catch (err) {
console.warn('Failed to paste from clipboard:', err);
}
return false;
}
updateSettings(settings) { updateSettings(settings) {
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize; if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily; if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
@@ -192,6 +267,10 @@ export class TerminalView {
destroy() { destroy() {
if (this.unsubscribeTheme) this.unsubscribeTheme(); if (this.unsubscribeTheme) this.unsubscribeTheme();
if (this.unsubscribeExit) {
this.unsubscribeExit();
this.unsubscribeExit = null;
}
if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.resizeObserver) this.resizeObserver.disconnect();
window.terminalAPI.killPty(this.id); window.terminalAPI.killPty(this.id);
this.xterm.dispose(); this.xterm.dispose();
+8 -7
View File
@@ -76,7 +76,7 @@ export class WorkspaceManager {
this.workspaces = []; this.workspaces = [];
this.activeWorkspaceId = 'ws_local'; this.activeWorkspaceId = 'ws_local';
this.isCollapsed = false; this.isCollapsed = true;
this.editingWorkspaceId = null; this.editingWorkspaceId = null;
this.init(); this.init();
@@ -122,13 +122,14 @@ export class WorkspaceManager {
this.activeWorkspaceId = this.workspaces[0].id; this.activeWorkspaceId = this.workspaces[0].id;
} }
this.isCollapsed = localStorage.getItem('hatty:sidebar-collapsed') === 'true'; // 打开程序时,工作区默认折叠起来
if (this.isCollapsed) { this.isCollapsed = true;
this.sidebar?.classList.add('collapsed'); if (this.sidebar) {
document.querySelector('.app-body')?.classList.add('sidebar-collapsed'); this.sidebar.classList.add('collapsed');
document.getElementById('app')?.classList.add('sidebar-collapsed');
document.getElementById('titlebar-left')?.classList.add('sidebar-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) { } catch (e) {
console.warn('Failed to load workspace state:', e); console.warn('Failed to load workspace state:', e);
this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES)); this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES));
+1001 -4
View File
File diff suppressed because it is too large Load Diff