feat: initial commit of next_term modern linux terminal

This commit is contained in:
2026-09-22 00:47:00 +08:00
commit c58c319805
20 changed files with 3120 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
import { themeManager } from './theme-manager.js';
import { TabManager } from './tab-manager.js';
import { CommandPalette } from './command-palette.js';
import { SettingsModal } from './settings.js';
class TerminalApp {
constructor() {
this.initElements();
this.initTheme();
this.initTabManager();
this.initModals();
this.initSearch();
this.initWindowControls();
this.initIpcListeners();
this.initKeyboardShortcuts();
this.initStatusBar();
// 创建初始标签页
this.tabManager.createTab();
}
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,
onActiveChange: (activeTerm) => {
if (activeTerm && this.statusShell) {
this.statusShell.textContent = activeTerm.title || 'bash';
}
},
});
this.newTabBtn.addEventListener('click', () => {
this.tabManager.createTab();
});
}
initModals() {
this.commandPalette = new CommandPalette({
overlay: this.commandPaletteOverlay,
input: this.commandPaletteInput,
list: this.commandPaletteList,
tabManager: this.tabManager,
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.splitCurrent('horizontal');
});
document.getElementById('btn-split-v')?.addEventListener('click', () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical');
});
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();
}
}
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();
});
}
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();
this.commandPalette.toggle();
return;
}
// Ctrl+Shift+T: 新建标签
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault();
this.tabManager.createTab();
return;
}
// Ctrl+Shift+W: 关闭窗格
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'w') {
e.preventDefault();
const tab = this.tabManager.getActiveTab();
if (tab && tab.splitTree.activeTerminal) {
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
}
return;
}
// Ctrl+Shift+E: 水平分屏 (左右)
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
e.preventDefault();
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal');
return;
}
// Ctrl+Shift+O: 垂直分屏 (上下)
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
e.preventDefault();
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical');
return;
}
// Ctrl+Shift+F: 搜索
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
this.toggleSearch();
return;
}
// Ctrl+,: 设置
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
this.settings.open();
return;
}
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) {
this.tabManager.prevTab();
} else {
this.tabManager.nextTab();
}
return;
}
// Alt+1 .. Alt+9: 快速直达标签页
if (e.altKey && e.key >= '1' && e.key <= '9') {
e.preventDefault();
const index = parseInt(e.key, 10) - 1;
const ids = Array.from(this.tabManager.tabs.keys());
if (ids[index]) {
this.tabManager.switchTab(ids[index]);
}
}
});
}
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', () => {
new TerminalApp();
});
+207
View File
@@ -0,0 +1,207 @@
import { themeManager, THEMES } from './theme-manager.js';
export class CommandPalette {
constructor(options = {}) {
this.overlay = options.overlay;
this.input = options.input;
this.list = options.list;
this.tabManager = options.tabManager;
this.onOpenSettings = options.onOpenSettings || (() => {});
this.onToggleSearch = options.onToggleSearch || (() => {});
this.selectedIndex = 0;
this.isOpen = false;
this.filteredCommands = [];
this.initCommands();
this.bindEvents();
}
initCommands() {
this.baseCommands = [
{
id: 'new-tab',
title: '新建标签页 (New Tab)',
shortcut: 'Ctrl+Shift+T',
icon: 'plus',
action: () => this.tabManager.createTab(),
},
{
id: 'split-horizontal',
title: '向右水平分屏 (Split Right)',
shortcut: 'Ctrl+Shift+E',
icon: 'columns',
action: () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('horizontal');
},
},
{
id: 'split-vertical',
title: '向下垂直分屏 (Split Down)',
shortcut: 'Ctrl+Shift+O',
icon: 'rows',
action: () => {
const tab = this.tabManager.getActiveTab();
if (tab) tab.splitTree.splitCurrent('vertical');
},
},
{
id: 'close-pane',
title: '关闭当前窗格/标签页 (Close Pane)',
shortcut: 'Ctrl+Shift+W',
icon: 'x',
action: () => {
const tab = this.tabManager.getActiveTab();
if (tab && tab.splitTree.activeTerminal) {
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
}
},
},
{
id: 'search',
title: '终端内容快速搜索 (Search Buffer)',
shortcut: 'Ctrl+Shift+F',
icon: 'search',
action: () => this.onToggleSearch(),
},
{
id: 'clear',
title: '清空当前屏幕缓冲区 (Clear Buffer)',
shortcut: 'Ctrl+L',
icon: 'trash',
action: () => {
const term = this.tabManager.getActiveTerminal();
if (term) term.xterm.clear();
},
},
{
id: 'settings',
title: '打开终端外观与核心配置 (Settings)',
shortcut: 'Ctrl+,',
icon: 'settings',
action: () => this.onOpenSettings(),
},
];
// 添加预设主题快速切换项
for (const [key, theme] of Object.entries(THEMES)) {
this.baseCommands.push({
id: `theme-${key}`,
title: `切换主题: ${theme.name}`,
shortcut: '',
icon: 'palette',
action: () => themeManager.applyTheme(key),
});
}
this.filteredCommands = [...this.baseCommands];
}
bindEvents() {
this.input.addEventListener('input', () => {
this.filter(this.input.value.trim().toLowerCase());
});
this.input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = (this.selectedIndex + 1) % this.filteredCommands.length;
this.renderList();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = (this.selectedIndex - 1 + this.filteredCommands.length) % this.filteredCommands.length;
this.renderList();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'Escape') {
e.preventDefault();
this.close();
}
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) {
this.close();
}
});
}
open() {
this.isOpen = true;
this.overlay.classList.add('active');
this.input.value = '';
this.selectedIndex = 0;
this.filteredCommands = [...this.baseCommands];
this.renderList();
setTimeout(() => this.input.focus(), 50);
}
close() {
this.isOpen = false;
this.overlay.classList.remove('active');
const term = this.tabManager.getActiveTerminal();
if (term) term.focus();
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
filter(query) {
if (!query) {
this.filteredCommands = [...this.baseCommands];
} else {
this.filteredCommands = this.baseCommands.filter((cmd) =>
cmd.title.toLowerCase().includes(query) || cmd.id.toLowerCase().includes(query)
);
}
this.selectedIndex = 0;
this.renderList();
}
renderList() {
this.list.innerHTML = '';
if (this.filteredCommands.length === 0) {
this.list.innerHTML = `<div style="padding: 12px; color: var(--text-muted); text-align: center; font-size: 12px;">未匹配到相关命令</div>`;
return;
}
this.filteredCommands.forEach((cmd, idx) => {
const item = document.createElement('div');
item.className = `palette-item ${idx === this.selectedIndex ? 'selected' : ''}`;
item.innerHTML = `
<div class="palette-item-left">
<span>${cmd.title}</span>
</div>
${cmd.shortcut ? `<span class="palette-shortcut">${cmd.shortcut}</span>` : ''}
`;
item.addEventListener('click', () => {
this.selectedIndex = idx;
this.executeSelected();
});
this.list.appendChild(item);
});
// 确保选中项在视口内
const selectedEl = this.list.children[this.selectedIndex];
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}
executeSelected() {
const cmd = this.filteredCommands[this.selectedIndex];
if (cmd) {
this.close();
cmd.action();
}
}
}
+166
View File
@@ -0,0 +1,166 @@
import { themeManager, THEMES } from './theme-manager.js';
export class SettingsModal {
constructor(options = {}) {
this.overlay = options.overlay;
this.modal = options.modal;
this.tabManager = options.tabManager;
this.settings = this.loadSettings();
this.initUI();
this.bindEvents();
}
loadSettings() {
const defaults = {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 14,
cursorStyle: 'block',
cursorBlink: true,
theme: themeManager.currentThemeKey,
};
try {
const saved = JSON.parse(localStorage.getItem('next_term:settings') || '{}');
return { ...defaults, ...saved };
} catch {
return defaults;
}
}
saveSettings() {
localStorage.setItem('next_term:settings', JSON.stringify(this.settings));
this.applyAll();
}
initUI() {
// 渲染设置项
const themeOptions = Object.keys(THEMES)
.map((key) => `<option value="${key}" ${this.settings.theme === key ? 'selected' : ''}>${THEMES[key].name}</option>`)
.join('');
this.modal.innerHTML = `
<div class="settings-header">
<h3>终端偏好设置 (Preferences)</h3>
<button class="btn-icon close-settings">&times;</button>
</div>
<div class="settings-body">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">色彩主题 (Color Theme)</span>
<span class="setting-desc">选择预设的高对比度高颜值现代配色方案</span>
</div>
<div class="setting-control">
<select id="setting-theme">${themeOptions}</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">字体大小 (Font Size)</span>
<span class="setting-desc">网格字符栅格化大小 (11px ~ 24px)</span>
</div>
<div class="setting-control">
<input type="number" id="setting-fontSize" min="11" max="24" value="${this.settings.fontSize}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">字体族 (Font Family)</span>
<span class="setting-desc">优先使用的等宽编程字体</span>
</div>
<div class="setting-control">
<select id="setting-fontFamily">
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
<option value="'Fira Code', monospace">Fira Code</option>
<option value="'Cascadia Code', monospace">Cascadia Code</option>
<option value="monospace">系统默认等宽 (Monospace)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">光标样式 (Cursor Style)</span>
<span class="setting-desc">终端提示符光标形态</span>
</div>
<div class="setting-control">
<select id="setting-cursorStyle">
<option value="block" ${this.settings.cursorStyle === 'block' ? 'selected' : ''}>方块光标 (Block)</option>
<option value="bar" ${this.settings.cursorStyle === 'bar' ? 'selected' : ''}>细竖线光标 (Bar)</option>
<option value="underline" ${this.settings.cursorStyle === 'underline' ? 'selected' : ''}>下划线光标 (Underline)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">光标平滑闪烁 (Cursor Blink)</span>
<span class="setting-desc">启用后光标将周期性呼吸闪烁</span>
</div>
<div class="setting-control">
<input type="checkbox" id="setting-cursorBlink" ${this.settings.cursorBlink ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
</div>
</div>
</div>
`;
const fontSelect = this.modal.querySelector('#setting-fontFamily');
if (fontSelect) fontSelect.value = this.settings.fontFamily;
}
bindEvents() {
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.modal.querySelector('.close-settings')?.addEventListener('click', () => {
this.close();
});
// 监听表单变更
this.modal.querySelector('#setting-theme')?.addEventListener('change', (e) => {
this.settings.theme = e.target.value;
themeManager.applyTheme(this.settings.theme);
this.saveSettings();
});
this.modal.querySelector('#setting-fontSize')?.addEventListener('change', (e) => {
this.settings.fontSize = parseInt(e.target.value, 10) || 14;
this.saveSettings();
});
this.modal.querySelector('#setting-fontFamily')?.addEventListener('change', (e) => {
this.settings.fontFamily = e.target.value;
this.saveSettings();
});
this.modal.querySelector('#setting-cursorStyle')?.addEventListener('change', (e) => {
this.settings.cursorStyle = e.target.value;
this.saveSettings();
});
this.modal.querySelector('#setting-cursorBlink')?.addEventListener('change', (e) => {
this.settings.cursorBlink = e.target.checked;
this.saveSettings();
});
}
applyAll() {
for (const tab of this.tabManager.tabs.values()) {
for (const info of tab.splitTree.terminals.values()) {
info.termView.updateSettings(this.settings);
}
}
}
open() {
this.overlay.classList.add('active');
}
close() {
this.overlay.classList.remove('active');
const term = this.tabManager.getActiveTerminal();
if (term) term.focus();
}
}
+140
View File
@@ -0,0 +1,140 @@
import { TerminalView } from './terminal-view.js';
export class TabSplitTree {
constructor(options = {}) {
this.container = options.container;
this.tabId = options.tabId;
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
this.onEmpty = options.onEmpty || (() => {});
this.onTitleChange = options.onTitleChange || (() => {});
this.terminals = new Map(); // id -> TerminalView
this.activeTerminal = null;
this.rootElement = document.createElement('div');
this.rootElement.className = 'split-container horizontal';
this.container.appendChild(this.rootElement);
// 创建初始窗格
this.addPane(this.rootElement);
}
addPane(parentElement, orientation = 'horizontal') {
const paneElement = document.createElement('div');
paneElement.className = 'split-pane';
parentElement.appendChild(paneElement);
const termView = new TerminalView({
container: paneElement,
onFocus: (term) => {
this.setActiveTerminal(term);
},
onTitleChange: (title) => {
if (this.activeTerminal === termView) {
this.onTitleChange(title);
}
},
onExit: () => {
this.closeTerminal(termView.id);
},
});
this.terminals.set(termView.id, {
termView,
paneElement,
parentElement,
});
paneElement.addEventListener('click', () => {
termView.focus();
});
this.setActiveTerminal(termView);
return termView;
}
splitCurrent(direction = 'horizontal') {
if (!this.activeTerminal) return;
const currentInfo = this.terminals.get(this.activeTerminal.id);
if (!currentInfo) return;
const currentPane = currentInfo.paneElement;
const parent = currentPane.parentElement;
// 创建一个新的 split-container 容器替换原有 pane,实现嵌套分屏
const splitWrapper = document.createElement('div');
splitWrapper.className = `split-container ${direction}`;
parent.replaceChild(splitWrapper, currentPane);
splitWrapper.appendChild(currentPane);
// 添加分割线
const divider = document.createElement('div');
divider.className = 'split-divider';
splitWrapper.appendChild(divider);
// 添加新窗格
const newTerm = this.addPane(splitWrapper, direction);
// 刷新自适应尺寸
setTimeout(() => {
this.fitAll();
newTerm.focus();
}, 50);
}
setActiveTerminal(termView) {
this.activeTerminal = termView;
// 清除所有聚焦样式
for (const info of this.terminals.values()) {
info.paneElement.classList.remove('focused');
}
const currentInfo = this.terminals.get(termView.id);
if (currentInfo) {
currentInfo.paneElement.classList.add('focused');
}
this.onActiveTerminalChange(termView);
this.onTitleChange(termView.title);
}
closeTerminal(termId) {
const info = this.terminals.get(termId);
if (!info) return;
info.termView.destroy();
const pane = info.paneElement;
const parent = pane.parentElement;
this.terminals.delete(termId);
pane.remove();
if (this.terminals.size === 0) {
this.onEmpty();
return;
}
// 焦点转移给另一个终端
const nextTerm = this.terminals.values().next().value.termView;
this.setActiveTerminal(nextTerm);
nextTerm.focus();
this.fitAll();
}
fitAll() {
for (const info of this.terminals.values()) {
info.termView.fit();
}
}
destroy() {
for (const info of this.terminals.values()) {
info.termView.destroy();
}
this.terminals.clear();
this.rootElement.remove();
}
}
+148
View File
@@ -0,0 +1,148 @@
import { TabSplitTree } from './split-manager.js';
export class TabManager {
constructor(options = {}) {
this.tabBar = options.tabBar;
this.viewport = options.viewport;
this.onActiveChange = options.onActiveChange || (() => {});
this.tabs = new Map(); // tabId -> { tabElement, viewElement, splitTree, title }
this.activeTabId = null;
}
createTab(title = 'bash') {
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
// 1. 创建标签页 DOM
const tabElement = document.createElement('div');
tabElement.className = 'tab-item';
tabElement.innerHTML = `
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span class="tab-title">${title}</span>
<span class="tab-close">&times;</span>
`;
// 2. 创建视口 DOM
const viewElement = document.createElement('div');
viewElement.className = 'tab-view';
this.viewport.appendChild(viewElement);
// 3. 创建分屏树
const splitTree = new TabSplitTree({
container: viewElement,
tabId,
onActiveTerminalChange: (term) => {
if (this.activeTabId === tabId) {
this.onActiveChange(term);
}
},
onTitleChange: (newTitle) => {
this.updateTabTitle(tabId, newTitle);
},
onEmpty: () => {
this.closeTab(tabId);
},
});
// 4. 标签页点击与关闭事件
tabElement.addEventListener('click', (e) => {
if (e.target.classList.contains('tab-close')) {
this.closeTab(tabId);
} else {
this.switchTab(tabId);
}
});
this.tabBar.appendChild(tabElement);
this.tabs.set(tabId, {
tabId,
tabElement,
viewElement,
splitTree,
title,
});
this.switchTab(tabId);
return tabId;
}
switchTab(tabId) {
if (!this.tabs.has(tabId)) return;
for (const [id, tab] of this.tabs.entries()) {
const isActive = id === tabId;
tab.tabElement.classList.toggle('active', isActive);
tab.viewElement.classList.toggle('active', isActive);
if (isActive) {
this.activeTabId = tabId;
setTimeout(() => {
tab.splitTree.fitAll();
if (tab.splitTree.activeTerminal) {
tab.splitTree.activeTerminal.focus();
this.onActiveChange(tab.splitTree.activeTerminal);
}
}, 30);
}
}
}
closeTab(tabId) {
const tab = this.tabs.get(tabId);
if (!tab) return;
tab.splitTree.destroy();
tab.tabElement.remove();
tab.viewElement.remove();
this.tabs.delete(tabId);
// 如果还有其他标签页,切换到上一个或下一个
if (this.tabs.size > 0) {
const remainingIds = Array.from(this.tabs.keys());
const nextId = remainingIds[remainingIds.length - 1];
this.switchTab(nextId);
} else {
// 没有任何标签页时自动新建一个
this.createTab();
}
}
updateTabTitle(tabId, newTitle) {
const tab = this.tabs.get(tabId);
if (tab && newTitle) {
tab.title = newTitle;
const titleEl = tab.tabElement.querySelector('.tab-title');
if (titleEl) {
titleEl.textContent = newTitle;
}
}
}
getActiveTab() {
return this.tabs.get(this.activeTabId);
}
getActiveTerminal() {
const activeTab = this.getActiveTab();
return activeTab ? activeTab.splitTree.activeTerminal : null;
}
nextTab() {
const ids = Array.from(this.tabs.keys());
if (ids.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId);
const nextIndex = (currentIndex + 1) % ids.length;
this.switchTab(ids[nextIndex]);
}
prevTab() {
const ids = Array.from(this.tabs.keys());
if (ids.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId);
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
this.switchTab(ids[prevIndex]);
}
}
+162
View File
@@ -0,0 +1,162 @@
import { Terminal } from '../../../node_modules/@xterm/xterm/lib/xterm.mjs';
import { FitAddon } from '../../../node_modules/@xterm/addon-fit/lib/addon-fit.mjs';
import { WebglAddon } from '../../../node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs';
import { WebLinksAddon } from '../../../node_modules/@xterm/addon-web-links/lib/addon-web-links.mjs';
import { SearchAddon } from '../../../node_modules/@xterm/addon-search/lib/addon-search.mjs';
import { themeManager } from './theme-manager.js';
export class TerminalView {
constructor(options = {}) {
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
this.container = options.container;
this.cwd = options.cwd || '';
this.onTitleChange = options.onTitleChange || (() => {});
this.onFocus = options.onFocus || (() => {});
this.onExit = options.onExit || (() => {});
this.title = 'bash';
this.isFocused = false;
this.initTerminal();
}
initTerminal() {
const currentTheme = themeManager.getCurrentTheme();
const config = JSON.parse(localStorage.getItem('next_term:settings') || '{}');
const fontSize = config.fontSize || 14;
const fontFamily = config.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace";
const cursorStyle = config.cursorStyle || 'block';
const cursorBlink = config.cursorBlink !== false;
this.xterm = new Terminal({
cursorBlink,
cursorStyle,
fontSize,
fontFamily,
lineHeight: 1.22,
letterSpacing: 0,
scrollback: 10000,
allowTransparency: true,
theme: currentTheme.xterm,
macOptionIsMeta: true,
rightClickSelectsWord: true,
convertEol: true,
});
// 插件初始化
this.fitAddon = new FitAddon();
this.xterm.loadAddon(this.fitAddon);
this.searchAddon = new SearchAddon();
this.xterm.loadAddon(this.searchAddon);
this.webLinksAddon = new WebLinksAddon();
this.xterm.loadAddon(this.webLinksAddon);
// 挂载 DOM
this.terminalWrapper = document.createElement('div');
this.terminalWrapper.className = 'terminal-wrapper';
this.container.appendChild(this.terminalWrapper);
this.xterm.open(this.terminalWrapper);
// 启用 WebGL 硬件加速 (GPU 渲染)
try {
this.webglAddon = new WebglAddon();
this.webglAddon.onContextLoss(() => {
console.warn('WebGL context lost, disposing webgl addon');
this.webglAddon.dispose();
});
this.xterm.loadAddon(this.webglAddon);
} catch (e) {
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
}
// 监听输入
this.xterm.onData((data) => {
window.terminalAPI.writePty(this.id, data);
});
// 监听标题变更 (OSC 0/2)
this.xterm.onTitleChange((title) => {
if (title) {
this.title = title;
this.onTitleChange(title);
}
});
// 监听终端聚焦
this.xterm.textarea?.addEventListener('focus', () => {
this.isFocused = true;
this.onFocus(this);
});
// 主题监听
this.unsubscribeTheme = themeManager.onThemeChange((theme) => {
this.xterm.options.theme = theme.xterm;
});
// 自适应尺寸监听
this.resizeObserver = new ResizeObserver(() => {
this.fit();
});
this.resizeObserver.observe(this.terminalWrapper);
// 启动 PTY
this.fit();
const cols = this.xterm.cols || 80;
const rows = this.xterm.rows || 24;
window.terminalAPI.createPty({
id: this.id,
cols,
rows,
cwd: this.cwd,
}).then((res) => {
if (res && res.shell) {
this.title = res.shell.split('/').pop() || 'bash';
this.onTitleChange(this.title);
}
});
// 聚焦
setTimeout(() => this.focus(), 100);
}
fit() {
if (!this.terminalWrapper || this.terminalWrapper.clientWidth === 0) return;
try {
this.fitAddon.fit();
if (this.xterm.cols && this.xterm.rows) {
window.terminalAPI.resizePty(this.id, this.xterm.cols, this.xterm.rows);
}
} catch (err) {
// 容器不可见时可能会静默捕获
}
}
focus() {
this.xterm.focus();
this.isFocused = true;
this.onFocus(this);
}
updateSettings(settings) {
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
if (settings.cursorStyle) this.xterm.options.cursorStyle = settings.cursorStyle;
if (settings.cursorBlink !== undefined) this.xterm.options.cursorBlink = settings.cursorBlink;
this.fit();
}
destroy() {
if (this.unsubscribeTheme) this.unsubscribeTheme();
if (this.resizeObserver) this.resizeObserver.disconnect();
window.terminalAPI.killPty(this.id);
this.xterm.dispose();
if (this.terminalWrapper && this.terminalWrapper.parentNode) {
this.terminalWrapper.parentNode.removeChild(this.terminalWrapper);
}
}
}
+179
View File
@@ -0,0 +1,179 @@
// 现代高颜值终端主题库
export const THEMES = {
'tokyo-night': {
name: 'Tokyo Night',
themeName: 'tokyo-night',
xterm: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
selectionForeground: '#ffffff',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
},
'catppuccin': {
name: 'Catppuccin Mocha',
themeName: 'catppuccin',
xterm: {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#585b70',
selectionForeground: '#cdd6f4',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
},
},
'dracula': {
name: 'Dracula Dark',
themeName: 'dracula',
xterm: {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#282a36',
selectionBackground: '#44475a',
selectionForeground: '#ffffff',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
},
},
'one-dark': {
name: 'One Dark Pro',
themeName: 'one-dark',
xterm: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
cursorAccent: '#282c34',
selectionBackground: '#3e4451',
selectionForeground: '#ffffff',
black: '#1e2127',
red: '#e06c75',
green: '#98c379',
yellow: '#d19a66',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#be5046',
brightGreen: '#98c379',
brightYellow: '#d19a66',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
},
'cyberpunk': {
name: 'Cyberpunk Neon',
themeName: 'cyberpunk',
xterm: {
background: '#0d121e',
foreground: '#e2f0fb',
cursor: '#00ffcc',
cursorAccent: '#0d121e',
selectionBackground: '#ff007f',
selectionForeground: '#ffffff',
black: '#1b2234',
red: '#ff0055',
green: '#00ff9f',
yellow: '#ffe600',
blue: '#00b8ff',
magenta: '#ff007f',
cyan: '#00ffcc',
white: '#e2f0fb',
brightBlack: '#38435d',
brightRed: '#ff3377',
brightGreen: '#33ffb2',
brightYellow: '#ffeb33',
brightBlue: '#33c6ff',
brightMagenta: '#ff3399',
brightCyan: '#33ffd6',
brightWhite: '#ffffff',
},
},
};
class ThemeManager {
constructor() {
this.currentThemeKey = localStorage.getItem('next_term:theme') || 'tokyo-night';
this.listeners = new Set();
}
init() {
this.applyTheme(this.currentThemeKey);
}
getCurrentTheme() {
return THEMES[this.currentThemeKey] || THEMES['tokyo-night'];
}
applyTheme(themeKey) {
if (!THEMES[themeKey]) themeKey = 'tokyo-night';
this.currentThemeKey = themeKey;
localStorage.setItem('next_term:theme', themeKey);
const theme = THEMES[themeKey];
document.documentElement.setAttribute('data-theme', theme.themeName);
// 通知所有监听者(xterm 实例)
for (const callback of this.listeners) {
callback(theme);
}
}
onThemeChange(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
export const themeManager = new ThemeManager();