diff --git a/src/main/pty-manager.cjs b/src/main/pty-manager.cjs index d0f8578..3ebb90a 100644 --- a/src/main/pty-manager.cjs +++ b/src/main/pty-manager.cjs @@ -1,5 +1,6 @@ const pty = require('node-pty'); const os = require('os'); +const path = require('path'); class PtyManager { constructor(mainWindow) { @@ -8,13 +9,21 @@ class PtyManager { 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)) { this.kill({ id }); } 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', cols, rows, @@ -40,7 +49,7 @@ class PtyManager { }); this.ptys.set(id, ptyProcess); - return { success: true, pid: ptyProcess.pid, shell: this.defaultShell }; + return { success: true, pid: ptyProcess.pid, shell: targetShell }; } catch (err) { console.error(`Failed to spawn pty [${id}]:`, err); return { success: false, error: err.message }; diff --git a/src/renderer/css/sidebar.css b/src/renderer/css/sidebar.css new file mode 100644 index 0000000..04ea2b0 --- /dev/null +++ b/src/renderer/css/sidebar.css @@ -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); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 108e8bb..be9f2cf 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -13,12 +13,21 @@ +
+ + +
@@ -89,8 +98,50 @@
- -
+ +
+ + + + +
+
diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js index d327c86..f38ecb3 100644 --- a/src/renderer/js/app.js +++ b/src/renderer/js/app.js @@ -1,5 +1,6 @@ import { themeManager } from './theme-manager.js'; import { TabManager } from './tab-manager.js'; +import { WorkspaceManager } from './workspace-manager.js'; import { CommandPalette } from './command-palette.js'; import { SettingsModal } from './settings.js'; @@ -8,15 +9,13 @@ class TerminalApp { this.initElements(); this.initTheme(); this.initTabManager(); + this.initWorkspaceManager(); this.initModals(); this.initSearch(); this.initWindowControls(); this.initIpcListeners(); this.initKeyboardShortcuts(); this.initStatusBar(); - - // 创建初始标签页 - this.tabManager.createTab(); } initElements() { @@ -63,7 +62,21 @@ class TerminalApp { }); 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, list: this.commandPaletteList, tabManager: this.tabManager, + workspaceManager: this.workspaceManager, onOpenSettings: () => this.settings.open(), onToggleSearch: () => this.toggleSearch(), }); @@ -211,10 +225,25 @@ class TerminalApp { 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') { 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; } diff --git a/src/renderer/js/command-palette.js b/src/renderer/js/command-palette.js index c8b3eb0..ee5f943 100644 --- a/src/renderer/js/command-palette.js +++ b/src/renderer/js/command-palette.js @@ -6,6 +6,7 @@ export class CommandPalette { this.input = options.input; this.list = options.list; this.tabManager = options.tabManager; + this.workspaceManager = options.workspaceManager; this.onOpenSettings = options.onOpenSettings || (() => {}); this.onToggleSearch = options.onToggleSearch || (() => {}); @@ -19,12 +20,36 @@ export class CommandPalette { initCommands() { 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', title: '新建标签页 (New Tab)', shortcut: 'Ctrl+Shift+T', 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', diff --git a/src/renderer/js/split-manager.js b/src/renderer/js/split-manager.js index 4d43920..881c01c 100644 --- a/src/renderer/js/split-manager.js +++ b/src/renderer/js/split-manager.js @@ -4,6 +4,7 @@ export class TabSplitTree { constructor(options = {}) { this.container = options.container; this.tabId = options.tabId; + this.spawnOptions = options.spawnOptions || {}; this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {}); this.onEmpty = options.onEmpty || (() => {}); this.onTitleChange = options.onTitleChange || (() => {}); @@ -16,16 +17,18 @@ export class TabSplitTree { 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'); paneElement.className = 'split-pane'; parentElement.appendChild(paneElement); const termView = new TerminalView({ + ...this.spawnOptions, + ...paneSpawnOptions, container: paneElement, onFocus: (term) => { this.setActiveTerminal(term); @@ -79,7 +82,8 @@ export class TabSplitTree { 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; newPane.style.flex = '1 1 0%'; diff --git a/src/renderer/js/tab-manager.js b/src/renderer/js/tab-manager.js index 958c8c4..6c88b8f 100644 --- a/src/renderer/js/tab-manager.js +++ b/src/renderer/js/tab-manager.js @@ -5,12 +5,56 @@ export class TabManager { this.tabBar = options.tabBar; this.viewport = options.viewport; 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.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); // 1. 创建标签页 DOM @@ -34,6 +78,7 @@ export class TabManager { const splitTree = new TabSplitTree({ container: viewElement, tabId, + spawnOptions, onActiveTerminalChange: (term) => { if (this.activeTabId === tabId) { this.onActiveChange(term); @@ -56,22 +101,44 @@ export class TabManager { } }); + // 根据当前工作区控制是否在顶部标签栏可见 + if (workspaceId !== this.activeWorkspaceId) { + tabElement.style.display = 'none'; + } + this.tabBar.appendChild(tabElement); this.tabs.set(tabId, { tabId, + workspaceId, tabElement, viewElement, splitTree, title, }); - this.switchTab(tabId); + if (workspaceId === this.activeWorkspaceId) { + this.switchTab(tabId); + } + + this.onTabListChange(); return 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()) { const isActive = id === tabId; @@ -88,26 +155,33 @@ export class TabManager { }, 30); } } + + this.onTabListChange(); } closeTab(tabId) { const tab = this.tabs.get(tabId); if (!tab) return; + const wsId = tab.workspaceId; 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(); + // 寻找同一工作区的其他标签 + const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId); + if (this.activeTabId === tabId) { + if (remainingWsTabs.length > 0) { + const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId; + this.switchTab(nextId); + } else { + // 当前工作区没有其他标签页了,自动新建一个 + this.createTab({ workspaceId: wsId }); + } } + + this.onTabListChange(); } updateTabTitle(tabId, newTitle) { @@ -118,6 +192,7 @@ export class TabManager { if (titleEl) { titleEl.textContent = newTitle; } + this.onTabListChange(); } } @@ -130,19 +205,29 @@ export class TabManager { 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() { - 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]); + const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId); + if (wsTabs.length <= 1) return; + const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId); + const nextIndex = (currentIndex + 1) % wsTabs.length; + this.switchTab(wsTabs[nextIndex].tabId); } 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]); + const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId); + if (wsTabs.length <= 1) return; + const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId); + const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length; + this.switchTab(wsTabs[prevIndex].tabId); } } diff --git a/src/renderer/js/terminal-view.js b/src/renderer/js/terminal-view.js index 66e6605..503df40 100644 --- a/src/renderer/js/terminal-view.js +++ b/src/renderer/js/terminal-view.js @@ -10,11 +10,13 @@ export class TerminalView { this.id = 'term_' + Math.random().toString(36).substring(2, 9); this.container = options.container; this.cwd = options.cwd || ''; + this.shell = options.shell || undefined; + this.args = options.args || []; + this.title = options.title || 'bash'; this.onTitleChange = options.onTitleChange || (() => {}); this.onFocus = options.onFocus || (() => {}); this.onExit = options.onExit || (() => {}); - this.title = 'bash'; this.isFocused = false; this.initTerminal(); @@ -113,10 +115,14 @@ export class TerminalView { cols, rows, cwd: this.cwd, + shell: this.shell, + args: this.args, }).then((res) => { if (res && res.shell) { - this.title = res.shell.split('/').pop() || 'bash'; - this.onTitleChange(this.title); + if (!this.title || this.title === 'bash') { + this.title = res.shell.split('/').pop() || 'bash'; + this.onTitleChange(this.title); + } } }); diff --git a/src/renderer/js/workspace-manager.js b/src/renderer/js/workspace-manager.js new file mode 100644 index 0000000..89c3e7e --- /dev/null +++ b/src/renderer/js/workspace-manager.js @@ -0,0 +1,627 @@ +/** + * 工作区管理器 (WorkspaceManager) + * 支持管理多个工作环境(本地 PC、SSH 远程开发机、串口/开发板) + * 每个工作区拥有独立的标签页会话集合,并提供可展开/收起的高性能左侧栏 + */ + +export const WORKSPACE_TYPES = { + LOCAL: 'local', + SSH: 'ssh', + SERIAL: 'serial', +}; + +const ICONS = { + local: ` + + + + `, + ssh: ` + + + + + `, + serial: ` + + + + + + + + + + + `, +}; + +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 = ` +
+
+
${iconSvg}
+
+ ${ws.name} + ${tabCountText} +
+
+
+ + ${ + this.workspaces.length > 1 + ? `` + : '' + } +
+
+
${subText}
+ `; + + // 绑定编辑和删除按钮 + 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 = `
已开启会话 (${tabs.length})
`; + + for (const tab of tabs) { + const tabItem = document.createElement('div'); + tabItem.className = `ws-tab-item ${tab.isActive ? 'active' : ''}`; + tabItem.innerHTML = ` +
+ + + + + ${tab.title} +
+ × + `; + + 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 = ` + + + + + 新建 ${ws.name} 标签页 + `; + 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 = ` +
+
+ 工作区类型 + 选择此工作区的连接目标与运行环境 +
+
+
+
+ ${ICONS.local} + 本机电脑 (Local) +
+
+ ${ICONS.ssh} + SSH 远程机 +
+
+ ${ICONS.serial} + 串口 / 开发板 +
+
+ +
+
+ 工作区名称 + 自定义显示名称 +
+
+ +
+
+ + +
+
+
+ 工作目录 (CWD) + 启动终端时的起始目录 +
+
+ +
+
+
+ + +
+
+
+ 主机地址 (Host) + IP 或域名 +
+
+ +
+
+
+
+ 用户名 (Username) + SSH 登录账号 +
+
+ +
+
+
+
+ 端口号 (Port) + 默认 22 +
+
+ +
+
+
+ + +
+
+
+ 串口设备路径 (Device) + Linux 串口设备路径 +
+
+ +
+
+
+
+ 波特率 (Baud Rate) + 串口通信波特率 +
+
+ +
+
+
+ `; + + // 绑定类型切换点击事件 + 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); + } +}