feat: initial commit of next_term modern linux terminal
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">×</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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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">×</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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user