Files
hatty/src/renderer/js/app.js
T
zzzjy 3589e5c927
Build and Release / Build & Release DEB Package (push) Successful in 3m27s
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
2026-09-25 01:45:51 +08:00

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();
});