feat: add collapsible workspace sidebar supporting Local PC, SSH, and Serial Dev Boards

This commit is contained in:
2026-09-24 01:24:43 +08:00
parent 2191ef79d3
commit 5421af1bb0
9 changed files with 1263 additions and 40 deletions
+12 -3
View File
@@ -1,5 +1,6 @@
const pty = require('node-pty'); const pty = require('node-pty');
const os = require('os'); const os = require('os');
const path = require('path');
class PtyManager { class PtyManager {
constructor(mainWindow) { constructor(mainWindow) {
@@ -8,13 +9,21 @@ class PtyManager {
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash'); this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
} }
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd() }) { create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd(), shell, args = [] }) {
if (this.ptys.has(id)) { if (this.ptys.has(id)) {
this.kill({ id }); this.kill({ id });
} }
try { try {
const ptyProcess = pty.spawn(this.defaultShell, [], { let resolvedCwd = cwd || process.env.HOME || process.cwd();
if (typeof resolvedCwd === 'string' && resolvedCwd.startsWith('~')) {
resolvedCwd = path.join(os.homedir(), resolvedCwd.slice(1));
}
const targetShell = shell || this.defaultShell;
const targetArgs = Array.isArray(args) ? args : [];
const ptyProcess = pty.spawn(targetShell, targetArgs, {
name: 'xterm-256color', name: 'xterm-256color',
cols, cols,
rows, rows,
@@ -40,7 +49,7 @@ class PtyManager {
}); });
this.ptys.set(id, ptyProcess); this.ptys.set(id, ptyProcess);
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell }; return { success: true, pid: ptyProcess.pid, shell: targetShell };
} catch (err) { } catch (err) {
console.error(`Failed to spawn pty [${id}]:`, err); console.error(`Failed to spawn pty [${id}]:`, err);
return { success: false, error: err.message }; return { success: false, error: err.message };
+372
View File
@@ -0,0 +1,372 @@
/* 工作区侧边栏样式 */
.app-body {
flex: 1;
min-height: 0;
display: flex;
position: relative;
overflow: hidden;
height: calc(100vh - var(--titlebar-height) - var(--statusbar-height));
}
.workspace-sidebar {
width: 250px;
min-width: 250px;
background: var(--bg-titlebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
z-index: 40;
overflow: hidden;
user-select: none;
}
.workspace-sidebar.collapsed {
width: 0 !important;
min-width: 0 !important;
border-right: none;
opacity: 0;
pointer-events: none;
}
.sidebar-header {
height: 38px;
min-height: 38px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
}
.sidebar-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
}
.sidebar-title-icon {
width: 14px;
height: 14px;
color: var(--accent-color);
}
.sidebar-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.sidebar-workspaces {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 工作区卡片 */
.workspace-card {
background: var(--bg-tab-inactive);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 10px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
display: flex;
flex-direction: column;
}
.workspace-card:hover {
background: var(--bg-tab-hover);
border-color: rgba(255, 255, 255, 0.15);
}
.workspace-card.active {
background: var(--bg-tab-active);
border-color: var(--border-active);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), 0 0 12px var(--accent-glow);
}
.ws-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.ws-card-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
overflow: hidden;
}
.ws-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
min-width: 28px;
border-radius: var(--border-radius-sm);
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
color: var(--accent-color);
font-size: 13px;
}
.ws-meta {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.ws-name {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
background: var(--kbd-bg, rgba(0, 0, 0, 0.2));
color: var(--text-muted);
align-self: flex-start;
margin-top: 2px;
}
.ws-actions {
display: none;
align-items: center;
gap: 2px;
}
.workspace-card:hover .ws-actions {
display: flex;
}
.ws-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 6px;
padding-left: 36px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 工作区展开的标签页列表 */
.ws-tabs-container {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 3px;
}
.ws-tabs-title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 2px;
padding: 0 4px;
}
.ws-tab-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 8px;
border-radius: var(--border-radius-sm);
font-size: 12px;
color: var(--text-muted);
transition: all 0.1s ease;
cursor: pointer;
}
.ws-tab-item:hover {
background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright);
}
.ws-tab-item.active {
background: var(--btn-hover, rgba(255, 255, 255, 0.12));
color: var(--accent-color);
font-weight: 600;
}
.ws-tab-left {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
}
.ws-tab-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.ws-tab-close {
opacity: 0.6;
font-size: 14px;
line-height: 1;
padding: 2px 4px;
border-radius: 2px;
}
.ws-tab-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.15);
color: #ff79c6;
}
.ws-add-tab-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 5px 8px;
border-radius: var(--border-radius-sm);
font-size: 11px;
color: var(--text-muted);
background: transparent;
border: 1px dashed var(--border-color);
cursor: pointer;
transition: all 0.15s ease;
margin-top: 4px;
}
.ws-add-tab-btn:hover {
background: var(--btn-hover, rgba(255, 255, 255, 0.06));
border-color: var(--accent-color);
color: var(--accent-color);
}
/* 侧边栏底部添加按钮 */
.sidebar-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color);
}
.btn-new-workspace {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
background: transparent;
border: 1px dashed var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-new-workspace:hover {
border-color: var(--accent-color);
color: var(--accent-color);
background: var(--btn-hover, rgba(255, 255, 255, 0.06));
}
/* 工作区配置模态框细节 */
.workspace-modal {
width: 500px;
}
.type-selector-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 4px;
}
.type-option-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
border: 1px solid var(--border-color);
background: var(--bg-tab-inactive);
border-radius: var(--border-radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
}
.type-option-btn:hover {
background: var(--bg-tab-hover);
color: var(--text-bright);
}
.type-option-btn.selected {
border-color: var(--border-active);
background: var(--bg-tab-active);
color: var(--accent-color);
font-weight: 600;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--bg-titlebar);
}
.btn-primary {
background: var(--accent-color);
color: var(--accent-text, #ffffff);
border: none;
padding: 6px 16px;
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: var(--bg-tab-inactive);
color: var(--text-bright);
border: 1px solid var(--border-color);
padding: 6px 14px;
border-radius: var(--border-radius-sm);
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-secondary:hover {
background: var(--bg-tab-hover);
}
+68 -2
View File
@@ -13,12 +13,21 @@
<link rel="stylesheet" href="css/tabs.css"> <link rel="stylesheet" href="css/tabs.css">
<link rel="stylesheet" href="css/split.css"> <link rel="stylesheet" href="css/split.css">
<link rel="stylesheet" href="css/components.css"> <link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/sidebar.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<!-- 顶部现代拖拽式标题栏 --> <!-- 顶部现代拖拽式标题栏 -->
<header class="titlebar"> <header class="titlebar">
<div class="titlebar-left"> <div class="titlebar-left">
<!-- 侧边栏折叠/展开按钮 -->
<button class="btn-icon" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
</button>
<div class="app-brand"> <div class="app-brand">
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="4 17 10 11 4 5"></polyline> <polyline points="4 17 10 11 4 5"></polyline>
@@ -89,8 +98,50 @@
</div> </div>
</header> </header>
<!-- 终端主视口 --> <!-- 应用主体容器 (含左侧工作区侧边栏与主视口) -->
<main class="main-viewport" id="main-viewport"></main> <div class="app-body">
<!-- 左侧工作区侧边栏 -->
<aside class="workspace-sidebar" id="workspace-sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<svg class="sidebar-title-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
<span>工作区</span>
</div>
<div class="sidebar-header-actions">
<button class="btn-icon" id="btn-add-workspace" title="新建工作区 (PC / SSH / 开发板)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button class="btn-icon" id="btn-collapse-sidebar" title="折叠侧边栏 (Ctrl+B)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
</div>
</div>
<div class="sidebar-workspaces" id="sidebar-workspace-list"></div>
<div class="sidebar-footer">
<button class="btn-new-workspace" id="btn-footer-add-workspace">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>添加工作区 (PC/SSH/开发板)...</span>
</button>
</div>
</aside>
<!-- 终端主视口 -->
<main class="main-viewport" id="main-viewport"></main>
</div>
<!-- 底部状态栏 --> <!-- 底部状态栏 -->
<footer class="statusbar"> <footer class="statusbar">
@@ -140,6 +191,21 @@
<div class="modal-overlay" id="settings-overlay"> <div class="modal-overlay" id="settings-overlay">
<div class="settings-modal" id="settings-modal"></div> <div class="settings-modal" id="settings-modal"></div>
</div> </div>
<!-- 工作区创建/编辑模态框 -->
<div class="modal-overlay" id="workspace-modal-overlay">
<div class="settings-modal workspace-modal">
<div class="settings-header">
<h3 id="workspace-modal-title">新建工作区</h3>
<button class="btn-icon" id="btn-close-workspace-modal">&times;</button>
</div>
<div class="settings-body" id="workspace-modal-body"></div>
<div class="modal-footer">
<button class="btn-secondary" id="btn-cancel-workspace">取消</button>
<button class="btn-primary" id="btn-save-workspace">保存并进入</button>
</div>
</div>
</div>
</div> </div>
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js"></script>
+35 -6
View File
@@ -1,5 +1,6 @@
import { themeManager } from './theme-manager.js'; import { themeManager } from './theme-manager.js';
import { TabManager } from './tab-manager.js'; import { TabManager } from './tab-manager.js';
import { WorkspaceManager } from './workspace-manager.js';
import { CommandPalette } from './command-palette.js'; import { CommandPalette } from './command-palette.js';
import { SettingsModal } from './settings.js'; import { SettingsModal } from './settings.js';
@@ -8,15 +9,13 @@ class TerminalApp {
this.initElements(); this.initElements();
this.initTheme(); this.initTheme();
this.initTabManager(); this.initTabManager();
this.initWorkspaceManager();
this.initModals(); this.initModals();
this.initSearch(); this.initSearch();
this.initWindowControls(); this.initWindowControls();
this.initIpcListeners(); this.initIpcListeners();
this.initKeyboardShortcuts(); this.initKeyboardShortcuts();
this.initStatusBar(); this.initStatusBar();
// 创建初始标签页
this.tabManager.createTab();
} }
initElements() { initElements() {
@@ -63,7 +62,21 @@ class TerminalApp {
}); });
this.newTabBtn.addEventListener('click', () => { this.newTabBtn.addEventListener('click', () => {
this.tabManager.createTab(); 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,
});
});
}
initWorkspaceManager() {
this.workspaceManager = new WorkspaceManager({
tabManager: this.tabManager,
}); });
} }
@@ -73,6 +86,7 @@ class TerminalApp {
input: this.commandPaletteInput, input: this.commandPaletteInput,
list: this.commandPaletteList, list: this.commandPaletteList,
tabManager: this.tabManager, tabManager: this.tabManager,
workspaceManager: this.workspaceManager,
onOpenSettings: () => this.settings.open(), onOpenSettings: () => this.settings.open(),
onToggleSearch: () => this.toggleSearch(), onToggleSearch: () => this.toggleSearch(),
}); });
@@ -211,10 +225,25 @@ class TerminalApp {
return; return;
} }
// Ctrl+Shift+T: 新建标签 // Ctrl+B: 切换左侧工作区侧边栏
if (e.ctrlKey && e.key.toLowerCase() === 'b') {
e.preventDefault();
this.workspaceManager?.toggleSidebar();
return;
}
// Ctrl+Shift+T: 新建当前工作区标签页
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
e.preventDefault(); e.preventDefault();
this.tabManager.createTab(); 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; return;
} }
+26 -1
View File
@@ -6,6 +6,7 @@ export class CommandPalette {
this.input = options.input; this.input = options.input;
this.list = options.list; this.list = options.list;
this.tabManager = options.tabManager; this.tabManager = options.tabManager;
this.workspaceManager = options.workspaceManager;
this.onOpenSettings = options.onOpenSettings || (() => {}); this.onOpenSettings = options.onOpenSettings || (() => {});
this.onToggleSearch = options.onToggleSearch || (() => {}); this.onToggleSearch = options.onToggleSearch || (() => {});
@@ -19,12 +20,36 @@ export class CommandPalette {
initCommands() { initCommands() {
this.baseCommands = [ this.baseCommands = [
{
id: 'toggle-sidebar',
title: '切换工作区侧边栏 (Toggle Workspace Sidebar)',
shortcut: 'Ctrl+B',
icon: 'layout',
action: () => this.workspaceManager?.toggleSidebar(),
},
{
id: 'new-workspace',
title: '新建工作区: 本地PC / SSH远程 / 开发板 (New Workspace)',
shortcut: '',
icon: 'plus',
action: () => this.workspaceManager?.openCreateModal(),
},
{ {
id: 'new-tab', id: 'new-tab',
title: '新建标签页 (New Tab)', title: '新建标签页 (New Tab)',
shortcut: 'Ctrl+Shift+T', shortcut: 'Ctrl+Shift+T',
icon: 'plus', icon: 'plus',
action: () => this.tabManager.createTab(), action: () => {
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,
});
},
}, },
{ {
id: 'split-horizontal', id: 'split-horizontal',
+7 -3
View File
@@ -4,6 +4,7 @@ export class TabSplitTree {
constructor(options = {}) { constructor(options = {}) {
this.container = options.container; this.container = options.container;
this.tabId = options.tabId; this.tabId = options.tabId;
this.spawnOptions = options.spawnOptions || {};
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {}); this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
this.onEmpty = options.onEmpty || (() => {}); this.onEmpty = options.onEmpty || (() => {});
this.onTitleChange = options.onTitleChange || (() => {}); this.onTitleChange = options.onTitleChange || (() => {});
@@ -16,16 +17,18 @@ export class TabSplitTree {
this.container.appendChild(this.rootElement); this.container.appendChild(this.rootElement);
// 创建初始窗格 // 创建初始窗格
this.addPane(this.rootElement); this.addPane(this.rootElement, 'horizontal', this.spawnOptions);
} }
addPane(parentElement, orientation = 'horizontal') { addPane(parentElement, orientation = 'horizontal', paneSpawnOptions = {}) {
const paneElement = document.createElement('div'); const paneElement = document.createElement('div');
paneElement.className = 'split-pane'; paneElement.className = 'split-pane';
parentElement.appendChild(paneElement); parentElement.appendChild(paneElement);
const termView = new TerminalView({ const termView = new TerminalView({
...this.spawnOptions,
...paneSpawnOptions,
container: paneElement, container: paneElement,
onFocus: (term) => { onFocus: (term) => {
this.setActiveTerminal(term); this.setActiveTerminal(term);
@@ -79,7 +82,8 @@ export class TabSplitTree {
splitWrapper.appendChild(divider); splitWrapper.appendChild(divider);
// 添加新窗格 // 添加新窗格
const newTerm = this.addPane(splitWrapper, direction); const currentSpawn = currentInfo ? { cwd: currentInfo.termView.cwd, shell: currentInfo.termView.shell, args: currentInfo.termView.args } : this.spawnOptions;
const newTerm = this.addPane(splitWrapper, direction, currentSpawn);
const newPane = this.terminals.get(newTerm.id).paneElement; const newPane = this.terminals.get(newTerm.id).paneElement;
newPane.style.flex = '1 1 0%'; newPane.style.flex = '1 1 0%';
+107 -22
View File
@@ -5,12 +5,56 @@ export class TabManager {
this.tabBar = options.tabBar; this.tabBar = options.tabBar;
this.viewport = options.viewport; this.viewport = options.viewport;
this.onActiveChange = options.onActiveChange || (() => {}); this.onActiveChange = options.onActiveChange || (() => {});
this.onTabListChange = options.onTabListChange || (() => {});
this.tabs = new Map(); // tabId -> { tabElement, viewElement, splitTree, title } this.tabs = new Map(); // tabId -> { tabId, workspaceId, tabElement, viewElement, splitTree, title }
this.activeTabId = null; this.activeTabId = null;
this.activeWorkspaceId = 'default';
this.workspaceLastTab = new Map(); // workspaceId -> lastActiveTabId
} }
createTab(title = 'bash') { setActiveWorkspace(workspaceId, defaultSpawnOptions = {}) {
this.activeWorkspaceId = workspaceId;
// 更新各标签在顶部标签栏中的可见性
for (const [id, tab] of this.tabs.entries()) {
const match = tab.workspaceId === workspaceId;
tab.tabElement.style.display = match ? '' : 'none';
if (!match) {
tab.tabElement.classList.remove('active');
tab.viewElement.classList.remove('active');
}
}
// 确定此工作区要激活的标签页
const workspaceTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === workspaceId);
if (workspaceTabs.length === 0) {
// 当前工作区无标签页时自动创建
this.createTab({
workspaceId,
title: defaultSpawnOptions.title || 'bash',
cwd: defaultSpawnOptions.cwd,
shell: defaultSpawnOptions.shell,
args: defaultSpawnOptions.args,
});
} else {
let targetId = this.workspaceLastTab.get(workspaceId);
if (!targetId || !this.tabs.has(targetId)) {
targetId = workspaceTabs[workspaceTabs.length - 1].tabId;
}
this.switchTab(targetId);
}
this.onTabListChange();
}
createTab(options = {}) {
const title = typeof options === 'string' ? options : (options.title || 'bash');
const workspaceId = typeof options === 'object' && options.workspaceId ? options.workspaceId : this.activeWorkspaceId;
const spawnOptions = typeof options === 'object' && options.spawnOptions ? options.spawnOptions : (
typeof options === 'object' ? { cwd: options.cwd, shell: options.shell, args: options.args } : {}
);
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9); const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
// 1. 创建标签页 DOM // 1. 创建标签页 DOM
@@ -34,6 +78,7 @@ export class TabManager {
const splitTree = new TabSplitTree({ const splitTree = new TabSplitTree({
container: viewElement, container: viewElement,
tabId, tabId,
spawnOptions,
onActiveTerminalChange: (term) => { onActiveTerminalChange: (term) => {
if (this.activeTabId === tabId) { if (this.activeTabId === tabId) {
this.onActiveChange(term); this.onActiveChange(term);
@@ -56,22 +101,44 @@ export class TabManager {
} }
}); });
// 根据当前工作区控制是否在顶部标签栏可见
if (workspaceId !== this.activeWorkspaceId) {
tabElement.style.display = 'none';
}
this.tabBar.appendChild(tabElement); this.tabBar.appendChild(tabElement);
this.tabs.set(tabId, { this.tabs.set(tabId, {
tabId, tabId,
workspaceId,
tabElement, tabElement,
viewElement, viewElement,
splitTree, splitTree,
title, title,
}); });
this.switchTab(tabId); if (workspaceId === this.activeWorkspaceId) {
this.switchTab(tabId);
}
this.onTabListChange();
return tabId; return tabId;
} }
switchTab(tabId) { switchTab(tabId) {
if (!this.tabs.has(tabId)) return; const targetTab = this.tabs.get(tabId);
if (!targetTab) return;
// 如果该标签属于不同工作区,同步更新 activeWorkspaceId
if (targetTab.workspaceId !== this.activeWorkspaceId) {
this.activeWorkspaceId = targetTab.workspaceId;
for (const [id, tab] of this.tabs.entries()) {
const match = tab.workspaceId === targetTab.workspaceId;
tab.tabElement.style.display = match ? '' : 'none';
}
}
this.workspaceLastTab.set(targetTab.workspaceId, tabId);
for (const [id, tab] of this.tabs.entries()) { for (const [id, tab] of this.tabs.entries()) {
const isActive = id === tabId; const isActive = id === tabId;
@@ -88,26 +155,33 @@ export class TabManager {
}, 30); }, 30);
} }
} }
this.onTabListChange();
} }
closeTab(tabId) { closeTab(tabId) {
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (!tab) return; if (!tab) return;
const wsId = tab.workspaceId;
tab.splitTree.destroy(); tab.splitTree.destroy();
tab.tabElement.remove(); tab.tabElement.remove();
tab.viewElement.remove(); tab.viewElement.remove();
this.tabs.delete(tabId); this.tabs.delete(tabId);
// 如果还有其他标签页,切换到上一个或下一个 // 寻找同一工作区的其他标签
if (this.tabs.size > 0) { const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId);
const remainingIds = Array.from(this.tabs.keys()); if (this.activeTabId === tabId) {
const nextId = remainingIds[remainingIds.length - 1]; if (remainingWsTabs.length > 0) {
this.switchTab(nextId); const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId;
} else { this.switchTab(nextId);
// 没有任何标签页时自动新建一个 } else {
this.createTab(); // 当前工作区没有其他标签页了,自动新建一个
this.createTab({ workspaceId: wsId });
}
} }
this.onTabListChange();
} }
updateTabTitle(tabId, newTitle) { updateTabTitle(tabId, newTitle) {
@@ -118,6 +192,7 @@ export class TabManager {
if (titleEl) { if (titleEl) {
titleEl.textContent = newTitle; titleEl.textContent = newTitle;
} }
this.onTabListChange();
} }
} }
@@ -130,19 +205,29 @@ export class TabManager {
return activeTab ? activeTab.splitTree.activeTerminal : null; return activeTab ? activeTab.splitTree.activeTerminal : null;
} }
getTabsForWorkspace(workspaceId) {
return Array.from(this.tabs.values())
.filter((t) => t.workspaceId === workspaceId)
.map((t) => ({
tabId: t.tabId,
title: t.title,
isActive: t.tabId === this.activeTabId,
}));
}
nextTab() { nextTab() {
const ids = Array.from(this.tabs.keys()); const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
if (ids.length <= 1) return; if (wsTabs.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId); const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
const nextIndex = (currentIndex + 1) % ids.length; const nextIndex = (currentIndex + 1) % wsTabs.length;
this.switchTab(ids[nextIndex]); this.switchTab(wsTabs[nextIndex].tabId);
} }
prevTab() { prevTab() {
const ids = Array.from(this.tabs.keys()); const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
if (ids.length <= 1) return; if (wsTabs.length <= 1) return;
const currentIndex = ids.indexOf(this.activeTabId); const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
const prevIndex = (currentIndex - 1 + ids.length) % ids.length; const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
this.switchTab(ids[prevIndex]); this.switchTab(wsTabs[prevIndex].tabId);
} }
} }
+9 -3
View File
@@ -10,11 +10,13 @@ export class TerminalView {
this.id = 'term_' + Math.random().toString(36).substring(2, 9); this.id = 'term_' + Math.random().toString(36).substring(2, 9);
this.container = options.container; this.container = options.container;
this.cwd = options.cwd || ''; this.cwd = options.cwd || '';
this.shell = options.shell || undefined;
this.args = options.args || [];
this.title = options.title || 'bash';
this.onTitleChange = options.onTitleChange || (() => {}); this.onTitleChange = options.onTitleChange || (() => {});
this.onFocus = options.onFocus || (() => {}); this.onFocus = options.onFocus || (() => {});
this.onExit = options.onExit || (() => {}); this.onExit = options.onExit || (() => {});
this.title = 'bash';
this.isFocused = false; this.isFocused = false;
this.initTerminal(); this.initTerminal();
@@ -113,10 +115,14 @@ export class TerminalView {
cols, cols,
rows, rows,
cwd: this.cwd, cwd: this.cwd,
shell: this.shell,
args: this.args,
}).then((res) => { }).then((res) => {
if (res && res.shell) { if (res && res.shell) {
this.title = res.shell.split('/').pop() || 'bash'; if (!this.title || this.title === 'bash') {
this.onTitleChange(this.title); this.title = res.shell.split('/').pop() || 'bash';
this.onTitleChange(this.title);
}
} }
}); });
+627
View File
@@ -0,0 +1,627 @@
/**
* 工作区管理器 (WorkspaceManager)
* 支持管理多个工作环境(本地 PC、SSH 远程开发机、串口/开发板)
* 每个工作区拥有独立的标签页会话集合,并提供可展开/收起的高性能左侧栏
*/
export const WORKSPACE_TYPES = {
LOCAL: 'local',
SSH: 'ssh',
SERIAL: 'serial',
};
const ICONS = {
local: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>`,
ssh: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>`,
serial: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>`,
};
const DEFAULT_WORKSPACES = [
{
id: 'ws_local',
name: '本机电脑 (Local PC)',
type: WORKSPACE_TYPES.LOCAL,
desc: '本地 Linux 环境与项目开发',
cwd: '~',
shell: '',
args: [],
},
{
id: 'ws_ssh',
name: '开发服务器 (SSH)',
type: WORKSPACE_TYPES.SSH,
desc: '远程 Linux 开发机',
host: '192.168.1.100',
user: 'dev',
port: 22,
extraArgs: '',
},
{
id: 'ws_serial',
name: '嵌入式开发板 (Serial)',
type: WORKSPACE_TYPES.SERIAL,
desc: '串口终端 /dev/ttyUSB0 (115200)',
device: '/dev/ttyUSB0',
baudRate: 115200,
tool: 'picocom',
},
];
export class WorkspaceManager {
constructor(options = {}) {
this.tabManager = options.tabManager;
this.sidebar = document.getElementById('workspace-sidebar');
this.listContainer = document.getElementById('sidebar-workspace-list');
this.modalOverlay = document.getElementById('workspace-modal-overlay');
this.workspaces = [];
this.activeWorkspaceId = 'ws_local';
this.isCollapsed = false;
this.editingWorkspaceId = null;
this.init();
}
init() {
this.loadState();
this.initEventListeners();
this.renderSidebar();
// 订阅标签页状态变更,以便实时刷新侧边栏标签列表
this.tabManager.onTabListChange = () => {
this.renderSidebar();
};
// 激活初始工作区
this.switchWorkspace(this.activeWorkspaceId, true);
}
loadState() {
try {
const saved = localStorage.getItem('hatty:workspaces');
if (saved) {
this.workspaces = JSON.parse(saved);
} else {
this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES));
this.saveWorkspaces();
}
const savedActive = localStorage.getItem('hatty:active-workspace');
if (savedActive && this.workspaces.some((w) => w.id === savedActive)) {
this.activeWorkspaceId = savedActive;
} else if (this.workspaces.length > 0) {
this.activeWorkspaceId = this.workspaces[0].id;
}
this.isCollapsed = localStorage.getItem('hatty:sidebar-collapsed') === 'true';
if (this.isCollapsed) {
this.sidebar?.classList.add('collapsed');
}
} catch (e) {
console.warn('Failed to load workspace state:', e);
this.workspaces = JSON.parse(JSON.stringify(DEFAULT_WORKSPACES));
}
}
saveWorkspaces() {
try {
localStorage.setItem('hatty:workspaces', JSON.stringify(this.workspaces));
localStorage.setItem('hatty:active-workspace', this.activeWorkspaceId);
localStorage.setItem('hatty:sidebar-collapsed', String(this.isCollapsed));
} catch (e) {
console.warn('Failed to save workspaces:', e);
}
}
initEventListeners() {
// 侧边栏折叠/展开按钮
document.getElementById('btn-toggle-sidebar')?.addEventListener('click', () => {
this.toggleSidebar();
});
document.getElementById('btn-collapse-sidebar')?.addEventListener('click', () => {
this.setCollapsed(true);
});
// 新建工作区按钮
document.getElementById('btn-add-workspace')?.addEventListener('click', () => {
this.openCreateModal();
});
document.getElementById('btn-footer-add-workspace')?.addEventListener('click', () => {
this.openCreateModal();
});
// 模态框按钮
document.getElementById('btn-close-workspace-modal')?.addEventListener('click', () => {
this.closeModal();
});
document.getElementById('btn-cancel-workspace')?.addEventListener('click', () => {
this.closeModal();
});
document.getElementById('btn-save-workspace')?.addEventListener('click', () => {
this.saveModalWorkspace();
});
this.modalOverlay?.addEventListener('click', (e) => {
if (e.target === this.modalOverlay) {
this.closeModal();
}
});
}
toggleSidebar() {
this.setCollapsed(!this.isCollapsed);
}
setCollapsed(collapsed) {
this.isCollapsed = collapsed;
if (this.sidebar) {
this.sidebar.classList.toggle('collapsed', collapsed);
}
this.saveWorkspaces();
// 触发当前终端自适应尺寸
setTimeout(() => {
const activeTerm = this.tabManager.getActiveTerminal();
if (activeTerm) {
activeTerm.fit();
}
}, 250);
}
getActiveWorkspace() {
return this.workspaces.find((w) => w.id === this.activeWorkspaceId) || this.workspaces[0];
}
getSpawnOptions(workspaceId) {
const ws = this.workspaces.find((w) => w.id === workspaceId) || this.getActiveWorkspace();
if (!ws) return {};
if (ws.type === WORKSPACE_TYPES.SSH) {
const targetHost = ws.user ? `${ws.user}@${ws.host}` : ws.host;
const portArgs = ws.port && Number(ws.port) !== 22 ? ['-p', String(ws.port)] : [];
const extra = ws.extraArgs ? ws.extraArgs.split(' ').filter(Boolean) : [];
return {
shell: 'ssh',
args: [...portArgs, ...extra, targetHost],
title: `ssh: ${ws.name || targetHost}`,
};
}
if (ws.type === WORKSPACE_TYPES.SERIAL) {
const baud = String(ws.baudRate || 115200);
const dev = ws.device || '/dev/ttyUSB0';
return {
shell: 'picocom',
args: ['-b', baud, dev],
title: `serial: ${dev}`,
};
}
// 本地主机
return {
cwd: ws.cwd || '~',
shell: ws.shell || undefined,
args: ws.args || [],
title: ws.name || 'bash',
};
}
switchWorkspace(workspaceId, isInit = false) {
const ws = this.workspaces.find((w) => w.id === workspaceId);
if (!ws) return;
this.activeWorkspaceId = workspaceId;
this.saveWorkspaces();
// 切换 TabManager 的活跃工作区
const spawnOpts = this.getSpawnOptions(workspaceId);
this.tabManager.setActiveWorkspace(workspaceId, spawnOpts);
this.renderSidebar();
// 更新底部状态栏信息
const statusHost = document.getElementById('status-host');
if (statusHost) {
if (ws.type === WORKSPACE_TYPES.SSH) {
statusHost.textContent = `SSH: ${ws.user ? ws.user + '@' : ''}${ws.host}`;
} else if (ws.type === WORKSPACE_TYPES.SERIAL) {
statusHost.textContent = `Serial: ${ws.device} (${ws.baudRate})`;
} else {
statusHost.textContent = `Local: ${ws.name}`;
}
}
}
renderSidebar() {
if (!this.listContainer) return;
this.listContainer.innerHTML = '';
for (const ws of this.workspaces) {
const isActive = ws.id === this.activeWorkspaceId;
const card = document.createElement('div');
card.className = `workspace-card ${isActive ? 'active' : ''}`;
const iconSvg = ICONS[ws.type] || ICONS.local;
const tabs = this.tabManager.getTabsForWorkspace(ws.id);
const tabCountText = `${tabs.length} 标签`;
let subText = '';
if (ws.type === WORKSPACE_TYPES.SSH) {
subText = `${ws.user ? ws.user + '@' : ''}${ws.host}:${ws.port || 22}`;
} else if (ws.type === WORKSPACE_TYPES.SERIAL) {
subText = `${ws.device} @ ${ws.baudRate}`;
} else {
subText = ws.cwd || '~';
}
card.innerHTML = `
<div class="ws-card-header">
<div class="ws-card-info">
<div class="ws-icon">${iconSvg}</div>
<div class="ws-meta">
<span class="ws-name" title="${ws.name}">${ws.name}</span>
<span class="ws-badge">${tabCountText}</span>
</div>
</div>
<div class="ws-actions">
<button class="btn-icon ws-btn-edit" title="编辑工作区">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
${
this.workspaces.length > 1
? `<button class="btn-icon ws-btn-del" title="删除工作区">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>`
: ''
}
</div>
</div>
<div class="ws-desc" title="${subText}">${subText}</div>
`;
// 绑定编辑和删除按钮
card.querySelector('.ws-btn-edit')?.addEventListener('click', (e) => {
e.stopPropagation();
this.openEditModal(ws.id);
});
card.querySelector('.ws-btn-del')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteWorkspace(ws.id);
});
// 点击工作区卡片切换工作区
card.addEventListener('click', () => {
if (ws.id !== this.activeWorkspaceId) {
this.switchWorkspace(ws.id);
}
});
// 如果是当前选中的活跃工作区,展开展示其中的标签页列表
if (isActive) {
const tabsContainer = document.createElement('div');
tabsContainer.className = 'ws-tabs-container';
tabsContainer.innerHTML = `<div class="ws-tabs-title">已开启会话 (${tabs.length})</div>`;
for (const tab of tabs) {
const tabItem = document.createElement('div');
tabItem.className = `ws-tab-item ${tab.isActive ? 'active' : ''}`;
tabItem.innerHTML = `
<div class="ws-tab-left">
<svg width="10" height="10" 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="ws-tab-title" title="${tab.title}">${tab.title}</span>
</div>
<span class="ws-tab-close" title="关闭">&times;</span>
`;
tabItem.addEventListener('click', (e) => {
if (e.target.classList.contains('ws-tab-close')) {
e.stopPropagation();
this.tabManager.closeTab(tab.tabId);
} else {
this.tabManager.switchTab(tab.tabId);
}
});
tabsContainer.appendChild(tabItem);
}
const addTabBtn = document.createElement('button');
addTabBtn.className = 'ws-add-tab-btn';
addTabBtn.innerHTML = `
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建 ${ws.name} 标签页</span>
`;
addTabBtn.addEventListener('click', (e) => {
e.stopPropagation();
const spawnOpts = this.getSpawnOptions(ws.id);
this.tabManager.createTab({
workspaceId: ws.id,
title: spawnOpts.title,
cwd: spawnOpts.cwd,
shell: spawnOpts.shell,
args: spawnOpts.args,
});
});
tabsContainer.appendChild(addTabBtn);
card.appendChild(tabsContainer);
}
this.listContainer.appendChild(card);
}
}
openCreateModal() {
this.editingWorkspaceId = null;
const titleEl = document.getElementById('workspace-modal-title');
if (titleEl) titleEl.textContent = '新建工作区 (PC / SSH / 开发板)';
this.renderModalForm({
name: '',
type: WORKSPACE_TYPES.LOCAL,
cwd: '~',
host: '',
user: '',
port: 22,
device: '/dev/ttyUSB0',
baudRate: 115200,
});
this.modalOverlay?.classList.add('active');
}
openEditModal(workspaceId) {
const ws = this.workspaces.find((w) => w.id === workspaceId);
if (!ws) return;
this.editingWorkspaceId = workspaceId;
const titleEl = document.getElementById('workspace-modal-title');
if (titleEl) titleEl.textContent = `编辑工作区 - ${ws.name}`;
this.renderModalForm(ws);
this.modalOverlay?.classList.add('active');
}
closeModal() {
this.modalOverlay?.classList.remove('active');
this.editingWorkspaceId = null;
}
renderModalForm(data) {
const body = document.getElementById('workspace-modal-body');
if (!body) return;
body.innerHTML = `
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">工作区类型</span>
<span class="setting-desc">选择此工作区的连接目标与运行环境</span>
</div>
</div>
<div class="type-selector-group" id="ws-type-group">
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.LOCAL ? 'selected' : ''}" data-type="local">
${ICONS.local}
<span>本机电脑 (Local)</span>
</div>
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.SSH ? 'selected' : ''}" data-type="ssh">
${ICONS.ssh}
<span>SSH 远程机</span>
</div>
<div class="type-option-btn ${data.type === WORKSPACE_TYPES.SERIAL ? 'selected' : ''}" data-type="serial">
${ICONS.serial}
<span>串口 / 开发板</span>
</div>
</div>
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">工作区名称</span>
<span class="setting-desc">自定义显示名称</span>
</div>
<div class="setting-control">
<input type="text" id="ws-input-name" value="${data.name || ''}" placeholder="例如:Ubuntu-Server 或 ESP32" style="width: 220px;">
</div>
</div>
<!-- 本地特定配置 -->
<div id="ws-config-local" style="display: ${data.type === WORKSPACE_TYPES.LOCAL ? 'block' : 'none'};">
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">工作目录 (CWD)</span>
<span class="setting-desc">启动终端时的起始目录</span>
</div>
<div class="setting-control">
<input type="text" id="ws-input-cwd" value="${data.cwd || '~'}" style="width: 220px;">
</div>
</div>
</div>
<!-- SSH 特定配置 -->
<div id="ws-config-ssh" style="display: ${data.type === WORKSPACE_TYPES.SSH ? 'block' : 'none'};">
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">主机地址 (Host)</span>
<span class="setting-desc">IP 或域名</span>
</div>
<div class="setting-control">
<input type="text" id="ws-input-host" value="${data.host || ''}" placeholder="192.168.1.100" style="width: 220px;">
</div>
</div>
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">用户名 (Username)</span>
<span class="setting-desc">SSH 登录账号</span>
</div>
<div class="setting-control">
<input type="text" id="ws-input-user" value="${data.user || ''}" placeholder="root" style="width: 220px;">
</div>
</div>
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">端口号 (Port)</span>
<span class="setting-desc">默认 22</span>
</div>
<div class="setting-control">
<input type="number" id="ws-input-port" value="${data.port || 22}" style="width: 100px;">
</div>
</div>
</div>
<!-- 串口开发板特定配置 -->
<div id="ws-config-serial" style="display: ${data.type === WORKSPACE_TYPES.SERIAL ? 'block' : 'none'};">
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">串口设备路径 (Device)</span>
<span class="setting-desc">Linux 串口设备路径</span>
</div>
<div class="setting-control">
<input type="text" id="ws-input-device" value="${data.device || '/dev/ttyUSB0'}" placeholder="/dev/ttyUSB0" style="width: 220px;">
</div>
</div>
<div class="setting-row" style="margin-top: 12px;">
<div class="setting-info">
<span class="setting-label">波特率 (Baud Rate)</span>
<span class="setting-desc">串口通信波特率</span>
</div>
<div class="setting-control">
<select id="ws-input-baud" style="width: 130px;">
${[9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1500000]
.map((b) => `<option value="${b}" ${Number(data.baudRate) === b ? 'selected' : ''}>${b}</option>`)
.join('')}
</select>
</div>
</div>
</div>
`;
// 绑定类型切换点击事件
let currentType = data.type || WORKSPACE_TYPES.LOCAL;
const typeBtns = body.querySelectorAll('.type-option-btn');
typeBtns.forEach((btn) => {
btn.addEventListener('click', () => {
typeBtns.forEach((b) => b.classList.remove('selected'));
btn.classList.add('selected');
currentType = btn.dataset.type;
document.getElementById('ws-config-local').style.display = currentType === WORKSPACE_TYPES.LOCAL ? 'block' : 'none';
document.getElementById('ws-config-ssh').style.display = currentType === WORKSPACE_TYPES.SSH ? 'block' : 'none';
document.getElementById('ws-config-serial').style.display = currentType === WORKSPACE_TYPES.SERIAL ? 'block' : 'none';
// 默认名字补全
const nameInput = document.getElementById('ws-input-name');
if (!nameInput.value) {
if (currentType === WORKSPACE_TYPES.SSH) nameInput.value = '远程服务器';
else if (currentType === WORKSPACE_TYPES.SERIAL) nameInput.value = '开发板终端';
else nameInput.value = '本机电脑';
}
});
});
}
saveModalWorkspace() {
const nameInput = document.getElementById('ws-input-name');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) {
alert('请输入工作区名称');
return;
}
const selectedTypeBtn = document.querySelector('.type-option-btn.selected');
const type = selectedTypeBtn ? selectedTypeBtn.dataset.type : WORKSPACE_TYPES.LOCAL;
const wsData = {
name,
type,
};
if (type === WORKSPACE_TYPES.LOCAL) {
wsData.cwd = document.getElementById('ws-input-cwd')?.value.trim() || '~';
} else if (type === WORKSPACE_TYPES.SSH) {
wsData.host = document.getElementById('ws-input-host')?.value.trim() || 'localhost';
wsData.user = document.getElementById('ws-input-user')?.value.trim() || '';
wsData.port = parseInt(document.getElementById('ws-input-port')?.value, 10) || 22;
} else if (type === WORKSPACE_TYPES.SERIAL) {
wsData.device = document.getElementById('ws-input-device')?.value.trim() || '/dev/ttyUSB0';
wsData.baudRate = parseInt(document.getElementById('ws-input-baud')?.value, 10) || 115200;
}
if (this.editingWorkspaceId) {
const idx = this.workspaces.findIndex((w) => w.id === this.editingWorkspaceId);
if (idx !== -1) {
this.workspaces[idx] = { ...this.workspaces[idx], ...wsData };
}
} else {
const id = 'ws_' + Math.random().toString(36).substring(2, 9);
const newWs = { id, ...wsData };
this.workspaces.push(newWs);
this.activeWorkspaceId = id;
}
this.saveWorkspaces();
this.closeModal();
this.switchWorkspace(this.activeWorkspaceId);
}
deleteWorkspace(workspaceId) {
if (this.workspaces.length <= 1) {
alert('至少需要保留一个工作区。');
return;
}
const ws = this.workspaces.find((w) => w.id === workspaceId);
if (!confirm(`确定要删除工作区「${ws ? ws.name : workspaceId}」吗?`)) {
return;
}
// 关闭属于该工作区的所有标签页
const tabs = this.tabManager.getTabsForWorkspace(workspaceId);
for (const t of tabs) {
this.tabManager.closeTab(t.tabId);
}
this.workspaces = this.workspaces.filter((w) => w.id !== workspaceId);
if (this.activeWorkspaceId === workspaceId) {
this.activeWorkspaceId = this.workspaces[0].id;
}
this.saveWorkspaces();
this.switchWorkspace(this.activeWorkspaceId);
}
}