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
437 lines
14 KiB
JavaScript
437 lines
14 KiB
JavaScript
import { themeManager } from './theme-manager.js';
|
|
import { TabManager } from './tab-manager.js';
|
|
import { WorkspaceManager } from './workspace-manager.js';
|
|
import { CommandPalette } from './command-palette.js';
|
|
import { SettingsModal } from './settings.js';
|
|
import { ContextMenu } from './context-menu.js';
|
|
|
|
class TerminalApp {
|
|
constructor() {
|
|
this.initElements();
|
|
this.initTheme();
|
|
this.initTabManager();
|
|
this.initWorkspaceManager();
|
|
this.initContextMenu();
|
|
this.initModals();
|
|
this.initSearch();
|
|
this.initWindowControls();
|
|
this.initIpcListeners();
|
|
this.initKeyboardShortcuts();
|
|
this.initStatusBar();
|
|
}
|
|
|
|
initElements() {
|
|
this.tabBar = document.getElementById('tab-bar');
|
|
this.viewport = document.getElementById('main-viewport');
|
|
this.newTabBtn = document.getElementById('btn-new-tab');
|
|
this.commandPaletteOverlay = document.getElementById('command-palette-overlay');
|
|
this.commandPaletteInput = document.getElementById('command-palette-input');
|
|
this.commandPaletteList = document.getElementById('command-palette-list');
|
|
this.settingsOverlay = document.getElementById('settings-overlay');
|
|
this.settingsModal = document.getElementById('settings-modal');
|
|
this.searchBar = document.getElementById('search-bar');
|
|
this.searchInput = document.getElementById('search-input');
|
|
this.searchClose = document.getElementById('search-close');
|
|
this.searchNext = document.getElementById('search-next');
|
|
this.searchPrev = document.getElementById('search-prev');
|
|
|
|
this.statusTheme = document.getElementById('status-theme');
|
|
this.statusShell = document.getElementById('status-shell');
|
|
this.statusHost = document.getElementById('status-host');
|
|
}
|
|
|
|
initTheme() {
|
|
themeManager.init();
|
|
if (this.statusTheme) {
|
|
this.statusTheme.textContent = themeManager.getCurrentTheme().name;
|
|
}
|
|
themeManager.onThemeChange((theme) => {
|
|
if (this.statusTheme) {
|
|
this.statusTheme.textContent = theme.name;
|
|
}
|
|
});
|
|
}
|
|
|
|
initTabManager() {
|
|
this.tabManager = new TabManager({
|
|
tabBar: this.tabBar,
|
|
viewport: this.viewport,
|
|
getSpawnOptions: (wsId) => (this.workspaceManager ? this.workspaceManager.getSpawnOptions(wsId) : {}),
|
|
onActiveChange: (activeTerm) => {
|
|
if (activeTerm && this.statusShell) {
|
|
this.statusShell.textContent = activeTerm.title || 'bash';
|
|
}
|
|
this.updateSplitButtonsState(activeTerm);
|
|
},
|
|
onContextMenu: (e, term) => {
|
|
this.contextMenu?.show(e, term);
|
|
},
|
|
});
|
|
|
|
this.newTabBtn.addEventListener('click', () => {
|
|
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
|
|
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
|
|
this.tabManager.createTab({
|
|
workspaceId: activeWs?.id,
|
|
title: spawnOpts.title,
|
|
cwd: spawnOpts.cwd,
|
|
shell: spawnOpts.shell,
|
|
args: spawnOpts.args,
|
|
});
|
|
});
|
|
}
|
|
|
|
updateSplitButtonsState(activeTerm) {
|
|
const isAgent = !!(
|
|
activeTerm?.isAgent ||
|
|
activeTerm?.element?.closest?.('.agent-panel') ||
|
|
activeTerm?.terminalWrapper?.closest?.('.agent-panel')
|
|
);
|
|
const btnH = document.getElementById('btn-split-h');
|
|
const btnV = document.getElementById('btn-split-v');
|
|
if (btnH) {
|
|
btnH.disabled = isAgent;
|
|
btnH.classList.toggle('disabled', isAgent);
|
|
btnH.title = isAgent ? 'Agent 面板不可分屏' : '水平分屏 (Ctrl+Shift+E)';
|
|
}
|
|
if (btnV) {
|
|
btnV.disabled = isAgent;
|
|
btnV.classList.toggle('disabled', isAgent);
|
|
btnV.title = isAgent ? 'Agent 面板不可分屏' : '垂直分屏 (Ctrl+Shift+O)';
|
|
}
|
|
}
|
|
|
|
initWorkspaceManager() {
|
|
this.workspaceManager = new WorkspaceManager({
|
|
tabManager: this.tabManager,
|
|
});
|
|
}
|
|
|
|
initContextMenu() {
|
|
this.contextMenu = new ContextMenu({
|
|
tabManager: this.tabManager,
|
|
workspaceManager: this.workspaceManager,
|
|
});
|
|
|
|
window.addEventListener('terminal-contextmenu', (e) => {
|
|
this.contextMenu?.show(e.detail.event, e.detail.termView);
|
|
});
|
|
}
|
|
|
|
initModals() {
|
|
this.commandPalette = new CommandPalette({
|
|
overlay: this.commandPaletteOverlay,
|
|
input: this.commandPaletteInput,
|
|
list: this.commandPaletteList,
|
|
tabManager: this.tabManager,
|
|
workspaceManager: this.workspaceManager,
|
|
onOpenSettings: () => this.settings.open(),
|
|
onToggleSearch: () => this.toggleSearch(),
|
|
});
|
|
|
|
this.settings = new SettingsModal({
|
|
overlay: this.settingsOverlay,
|
|
modal: this.settingsModal,
|
|
tabManager: this.tabManager,
|
|
});
|
|
|
|
// 快捷按钮绑定
|
|
document.getElementById('btn-palette')?.addEventListener('click', () => {
|
|
this.commandPalette.toggle();
|
|
});
|
|
|
|
document.getElementById('btn-settings')?.addEventListener('click', () => {
|
|
this.settings.open();
|
|
});
|
|
|
|
document.getElementById('btn-split-h')?.addEventListener('click', () => {
|
|
const tab = this.tabManager.getActiveTab();
|
|
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.activeTerminal?.isAgent) {
|
|
tab.splitTree.splitCurrent('vertical');
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-file-browser')?.addEventListener('click', () => {
|
|
this.toggleFileBrowser();
|
|
});
|
|
|
|
document.getElementById('btn-search')?.addEventListener('click', () => {
|
|
this.toggleSearch();
|
|
});
|
|
|
|
document.getElementById('status-theme')?.addEventListener('click', () => {
|
|
this.commandPalette.open();
|
|
this.commandPalette.input.value = '切换主题';
|
|
this.commandPalette.filter('切换主题');
|
|
});
|
|
}
|
|
|
|
initSearch() {
|
|
const doSearch = (direction = 'next') => {
|
|
const term = this.tabManager.getActiveTerminal();
|
|
const query = this.searchInput.value;
|
|
if (!term || !query) return;
|
|
|
|
if (direction === 'next') {
|
|
term.searchAddon.findNext(query, { incremental: true });
|
|
} else {
|
|
term.searchAddon.findPrevious(query);
|
|
}
|
|
};
|
|
|
|
this.searchInput.addEventListener('input', () => doSearch('next'));
|
|
this.searchInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
doSearch(e.shiftKey ? 'prev' : 'next');
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
this.closeSearch();
|
|
}
|
|
});
|
|
|
|
this.searchNext?.addEventListener('click', () => doSearch('next'));
|
|
this.searchPrev?.addEventListener('click', () => doSearch('prev'));
|
|
this.searchClose?.addEventListener('click', () => this.closeSearch());
|
|
}
|
|
|
|
toggleSearch() {
|
|
if (this.searchBar.classList.contains('active')) {
|
|
this.closeSearch();
|
|
} else {
|
|
this.searchBar.classList.add('active');
|
|
this.searchInput.value = '';
|
|
this.searchInput.focus();
|
|
}
|
|
}
|
|
|
|
closeSearch() {
|
|
this.searchBar.classList.remove('active');
|
|
const term = this.tabManager.getActiveTerminal();
|
|
if (term) {
|
|
term.searchAddon.clearDecorations();
|
|
term.focus();
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
document.getElementById('win-max')?.addEventListener('click', () => {
|
|
window.terminalAPI.maximize();
|
|
});
|
|
|
|
document.getElementById('win-close')?.addEventListener('click', () => {
|
|
window.terminalAPI.close();
|
|
});
|
|
|
|
// 双击标题栏空白处最大化/还原
|
|
document.getElementById('titlebar-drag-spacer')?.addEventListener('dblclick', () => {
|
|
window.terminalAPI.maximize();
|
|
});
|
|
document.querySelector('.app-brand')?.addEventListener('dblclick', () => {
|
|
window.terminalAPI.maximize();
|
|
});
|
|
}
|
|
|
|
initIpcListeners() {
|
|
// 监听 PTY 数据并派发给对应 terminal
|
|
window.terminalAPI.onPtyData(({ id, data }) => {
|
|
for (const tab of this.tabManager.tabs.values()) {
|
|
const info = tab.splitTree.terminals.get(id);
|
|
if (info) {
|
|
info.termView.xterm.write(data);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 监听 PTY 退出事件
|
|
window.terminalAPI.onPtyExit(({ id, exitCode }) => {
|
|
for (const tab of this.tabManager.tabs.values()) {
|
|
if (tab.splitTree.terminals.has(id)) {
|
|
tab.splitTree.closeTerminal(id);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
initKeyboardShortcuts() {
|
|
window.addEventListener(
|
|
'keydown',
|
|
(e) => {
|
|
// Ctrl+Shift+P 或 F1: 命令面板
|
|
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.commandPalette.toggle();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+B: 切换左侧工作区侧边栏
|
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.workspaceManager?.toggleSidebar();
|
|
return;
|
|
}
|
|
|
|
const isEditingText = (el) => {
|
|
if (!el) return false;
|
|
const tag = el.tagName?.toLowerCase();
|
|
return (tag === 'input' || tag === 'textarea') && !el.classList.contains('xterm-helper-textarea');
|
|
};
|
|
|
|
// Ctrl+Shift+C: 复制当前选中终端文本
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') {
|
|
if (isEditingText(e.target)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const activeTerm = this.tabManager.getActiveTerminal();
|
|
if (activeTerm) activeTerm.copySelection();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+V: 粘贴剪贴板文本到当前终端
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'v') {
|
|
if (isEditingText(e.target)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const activeTerm = this.tabManager.getActiveTerminal();
|
|
if (activeTerm) activeTerm.pasteFromClipboard();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+T: 新建当前工作区标签页
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const activeWs = this.workspaceManager ? this.workspaceManager.getActiveWorkspace() : null;
|
|
const spawnOpts = this.workspaceManager ? this.workspaceManager.getSpawnOptions(activeWs?.id) : {};
|
|
this.tabManager.createTab({
|
|
workspaceId: activeWs?.id,
|
|
title: spawnOpts.title,
|
|
cwd: spawnOpts.cwd,
|
|
shell: spawnOpts.shell,
|
|
args: spawnOpts.args,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+W: 关闭窗格
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'w') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const tab = this.tabManager.getActiveTab();
|
|
if (tab && tab.splitTree.activeTerminal) {
|
|
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+E: 水平分屏 (左右)
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const tab = this.tabManager.getActiveTab();
|
|
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
|
tab.splitTree.splitCurrent('horizontal');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+O: 垂直分屏 (上下)
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const tab = this.tabManager.getActiveTab();
|
|
if (tab && !tab.splitTree.activeTerminal?.isAgent) {
|
|
tab.splitTree.splitCurrent('vertical');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+F: 搜索
|
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.toggleSearch();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+U 或 Alt+F: 切换文件浏览面板
|
|
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'u') || (e.altKey && !e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === 'f')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.toggleFileBrowser();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+,: 设置
|
|
if (e.ctrlKey && e.key === ',') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.settings.open();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
|
|
if (e.ctrlKey && e.key === 'Tab') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.shiftKey) {
|
|
this.tabManager.prevTab();
|
|
} else {
|
|
this.tabManager.nextTab();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Alt+1 .. Alt+9: 快速直达标签页
|
|
if (e.altKey && e.key >= '1' && e.key <= '9') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const index = parseInt(e.key, 10) - 1;
|
|
const ids = Array.from(this.tabManager.tabs.keys());
|
|
if (ids[index]) {
|
|
this.tabManager.switchTab(ids[index]);
|
|
}
|
|
}
|
|
},
|
|
true // 启用捕获阶段,确保终端即使在聚焦/输入状态下应用级快捷键也能被优先拦截响应
|
|
);
|
|
}
|
|
|
|
async initStatusBar() {
|
|
try {
|
|
const info = await window.terminalAPI.getSystemInfo();
|
|
if (info && this.statusHost) {
|
|
this.statusHost.textContent = `${info.user}@${info.hostname}`;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load system info:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 页面加载就绪后启动终端
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
window.terminalApp = new TerminalApp();
|
|
});
|