/** * 工作区管理器 (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(); const toggleBtn = document.getElementById('btn-toggle-sidebar'); if (toggleBtn) { toggleBtn.classList.toggle('active', !this.isCollapsed); } const appBody = document.querySelector('.app-body'); if (appBody && this.isCollapsed) { appBody.classList.add('sidebar-collapsed'); } // 订阅标签页状态变更,以便实时刷新侧边栏标签列表 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', (e) => { e.stopPropagation(); this.toggleSidebar(); }); document.getElementById('btn-collapse-sidebar')?.addEventListener('click', (e) => { e.stopPropagation(); this.setCollapsed(true); }); // 浮动快捷呼出按钮 document.getElementById('sidebar-floating-toggle')?.addEventListener('click', (e) => { e.stopPropagation(); this.setCollapsed(false); }); // 新建工作区按钮 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); } const appBody = document.querySelector('.app-body'); if (appBody) { appBody.classList.toggle('sidebar-collapsed', collapsed); } const toggleBtn = document.getElementById('btn-toggle-sidebar'); if (toggleBtn) { toggleBtn.classList.toggle('active', !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) : []; const sshCmd = `ssh ${[...portArgs, ...extra, targetHost].join(' ')}`; return { shell: '/bin/bash', args: ['-c', `${sshCmd} || (echo -e "\\n\\x1b[33m[SSH 会话断开或连接失败,已保留本地交互终端]\\x1b[0m" && exec bash)`], title: `ssh: ${ws.name || targetHost}`, }; } if (ws.type === WORKSPACE_TYPES.SERIAL) { const baud = String(ws.baudRate || 115200); const dev = ws.device || '/dev/ttyUSB0'; const tool = ws.tool || 'picocom'; return { shell: '/bin/bash', args: ['-c', `which ${tool} >/dev/null 2>&1 && ${tool} -b ${baud} ${dev} || (echo -e "\\n\\x1b[33m[串口工具 ${tool} 未安装或 ${dev} 无法连接,已保留本地交互终端]\\x1b[0m" && exec bash)`], 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 titlebarWsName = document.getElementById('titlebar-ws-name'); if (titlebarWsName) { titlebarWsName.textContent = ws.name; } 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 = `