- 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
660 lines
23 KiB
JavaScript
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="关闭">×</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);
|
|
}
|
|
}
|