Files
hatty/src/renderer/js/workspace-manager.js
T
zzzjy 5a032b13ec feat: align tab bar with terminal window under workspace hierarchy & add automated test suite
- Restructure UI layout: tab bar and stage actions are now housed inside .workspace-stage (.workspace-header), aligning 100% with the terminal window (#main-viewport) below it
- Establish clear workspace hierarchy: Global Titlebar -> Left Workspace Sidebar -> Active Workspace Content Stage (Tab Bar + Terminal Viewport)
- Add active workspace indicator badge to titlebar header
- Introduce comprehensive automated test suite (test/test-runner.cjs) covering PTY process lifecycle, workspace hierarchy, stage alignment, tab creation, split panes, shortcuts, and themes
- Add 'npm test' script to package.json
2026-09-24 01:54:02 +08:00

660 lines
23 KiB
JavaScript

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