/** * 工作区管理器 (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 = `
${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); } }