feat: add collapsible workspace sidebar supporting Local PC, SSH, and Serial Dev Boards
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
const pty = require('node-pty');
|
const pty = require('node-pty');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
class PtyManager {
|
class PtyManager {
|
||||||
constructor(mainWindow) {
|
constructor(mainWindow) {
|
||||||
@@ -8,13 +9,21 @@ class PtyManager {
|
|||||||
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
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)) {
|
if (this.ptys.has(id)) {
|
||||||
this.kill({ id });
|
this.kill({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
name: 'xterm-256color',
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
@@ -40,7 +49,7 @@ class PtyManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.ptys.set(id, ptyProcess);
|
this.ptys.set(id, ptyProcess);
|
||||||
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell };
|
return { success: true, pid: ptyProcess.pid, shell: targetShell };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to spawn pty [${id}]:`, err);
|
console.error(`Failed to spawn pty [${id}]:`, err);
|
||||||
return { success: false, error: err.message };
|
return { success: false, error: err.message };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+68
-2
@@ -13,12 +13,21 @@
|
|||||||
<link rel="stylesheet" href="css/tabs.css">
|
<link rel="stylesheet" href="css/tabs.css">
|
||||||
<link rel="stylesheet" href="css/split.css">
|
<link rel="stylesheet" href="css/split.css">
|
||||||
<link rel="stylesheet" href="css/components.css">
|
<link rel="stylesheet" href="css/components.css">
|
||||||
|
<link rel="stylesheet" href="css/sidebar.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- 顶部现代拖拽式标题栏 -->
|
<!-- 顶部现代拖拽式标题栏 -->
|
||||||
<header class="titlebar">
|
<header class="titlebar">
|
||||||
<div class="titlebar-left">
|
<div class="titlebar-left">
|
||||||
|
<!-- 侧边栏折叠/展开按钮 -->
|
||||||
|
<button class="btn-icon" id="btn-toggle-sidebar" title="切换工作区侧边栏 (Ctrl+B)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<polyline points="4 17 10 11 4 5"></polyline>
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
@@ -89,8 +98,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 终端主视口 -->
|
<!-- 应用主体容器 (含左侧工作区侧边栏与主视口) -->
|
||||||
<main class="main-viewport" id="main-viewport"></main>
|
<div class="app-body">
|
||||||
|
<!-- 左侧工作区侧边栏 -->
|
||||||
|
<aside class="workspace-sidebar" id="workspace-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-title">
|
||||||
|
<svg class="sidebar-title-icon" 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>
|
||||||
|
<span>工作区</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-header-actions">
|
||||||
|
<button class="btn-icon" id="btn-add-workspace" title="新建工作区 (PC / SSH / 开发板)">
|
||||||
|
<svg width="13" height="13" 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>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" id="btn-collapse-sidebar" title="折叠侧边栏 (Ctrl+B)">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-workspaces" id="sidebar-workspace-list"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="btn-new-workspace" id="btn-footer-add-workspace">
|
||||||
|
<svg width="13" height="13" 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>添加工作区 (PC/SSH/开发板)...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 终端主视口 -->
|
||||||
|
<main class="main-viewport" id="main-viewport"></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 底部状态栏 -->
|
<!-- 底部状态栏 -->
|
||||||
<footer class="statusbar">
|
<footer class="statusbar">
|
||||||
@@ -140,6 +191,21 @@
|
|||||||
<div class="modal-overlay" id="settings-overlay">
|
<div class="modal-overlay" id="settings-overlay">
|
||||||
<div class="settings-modal" id="settings-modal"></div>
|
<div class="settings-modal" id="settings-modal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 工作区创建/编辑模态框 -->
|
||||||
|
<div class="modal-overlay" id="workspace-modal-overlay">
|
||||||
|
<div class="settings-modal workspace-modal">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h3 id="workspace-modal-title">新建工作区</h3>
|
||||||
|
<button class="btn-icon" id="btn-close-workspace-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body" id="workspace-modal-body"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" id="btn-cancel-workspace">取消</button>
|
||||||
|
<button class="btn-primary" id="btn-save-workspace">保存并进入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
|
|||||||
+35
-6
@@ -1,5 +1,6 @@
|
|||||||
import { themeManager } from './theme-manager.js';
|
import { themeManager } from './theme-manager.js';
|
||||||
import { TabManager } from './tab-manager.js';
|
import { TabManager } from './tab-manager.js';
|
||||||
|
import { WorkspaceManager } from './workspace-manager.js';
|
||||||
import { CommandPalette } from './command-palette.js';
|
import { CommandPalette } from './command-palette.js';
|
||||||
import { SettingsModal } from './settings.js';
|
import { SettingsModal } from './settings.js';
|
||||||
|
|
||||||
@@ -8,15 +9,13 @@ class TerminalApp {
|
|||||||
this.initElements();
|
this.initElements();
|
||||||
this.initTheme();
|
this.initTheme();
|
||||||
this.initTabManager();
|
this.initTabManager();
|
||||||
|
this.initWorkspaceManager();
|
||||||
this.initModals();
|
this.initModals();
|
||||||
this.initSearch();
|
this.initSearch();
|
||||||
this.initWindowControls();
|
this.initWindowControls();
|
||||||
this.initIpcListeners();
|
this.initIpcListeners();
|
||||||
this.initKeyboardShortcuts();
|
this.initKeyboardShortcuts();
|
||||||
this.initStatusBar();
|
this.initStatusBar();
|
||||||
|
|
||||||
// 创建初始标签页
|
|
||||||
this.tabManager.createTab();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initElements() {
|
initElements() {
|
||||||
@@ -63,7 +62,21 @@ class TerminalApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.newTabBtn.addEventListener('click', () => {
|
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,
|
input: this.commandPaletteInput,
|
||||||
list: this.commandPaletteList,
|
list: this.commandPaletteList,
|
||||||
tabManager: this.tabManager,
|
tabManager: this.tabManager,
|
||||||
|
workspaceManager: this.workspaceManager,
|
||||||
onOpenSettings: () => this.settings.open(),
|
onOpenSettings: () => this.settings.open(),
|
||||||
onToggleSearch: () => this.toggleSearch(),
|
onToggleSearch: () => this.toggleSearch(),
|
||||||
});
|
});
|
||||||
@@ -211,10 +225,25 @@ class TerminalApp {
|
|||||||
return;
|
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') {
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||||
e.preventDefault();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export class CommandPalette {
|
|||||||
this.input = options.input;
|
this.input = options.input;
|
||||||
this.list = options.list;
|
this.list = options.list;
|
||||||
this.tabManager = options.tabManager;
|
this.tabManager = options.tabManager;
|
||||||
|
this.workspaceManager = options.workspaceManager;
|
||||||
this.onOpenSettings = options.onOpenSettings || (() => {});
|
this.onOpenSettings = options.onOpenSettings || (() => {});
|
||||||
this.onToggleSearch = options.onToggleSearch || (() => {});
|
this.onToggleSearch = options.onToggleSearch || (() => {});
|
||||||
|
|
||||||
@@ -19,12 +20,36 @@ export class CommandPalette {
|
|||||||
|
|
||||||
initCommands() {
|
initCommands() {
|
||||||
this.baseCommands = [
|
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',
|
id: 'new-tab',
|
||||||
title: '新建标签页 (New Tab)',
|
title: '新建标签页 (New Tab)',
|
||||||
shortcut: 'Ctrl+Shift+T',
|
shortcut: 'Ctrl+Shift+T',
|
||||||
icon: 'plus',
|
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',
|
id: 'split-horizontal',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export class TabSplitTree {
|
|||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
this.tabId = options.tabId;
|
this.tabId = options.tabId;
|
||||||
|
this.spawnOptions = options.spawnOptions || {};
|
||||||
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
||||||
this.onEmpty = options.onEmpty || (() => {});
|
this.onEmpty = options.onEmpty || (() => {});
|
||||||
this.onTitleChange = options.onTitleChange || (() => {});
|
this.onTitleChange = options.onTitleChange || (() => {});
|
||||||
@@ -16,16 +17,18 @@ export class TabSplitTree {
|
|||||||
this.container.appendChild(this.rootElement);
|
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');
|
const paneElement = document.createElement('div');
|
||||||
paneElement.className = 'split-pane';
|
paneElement.className = 'split-pane';
|
||||||
|
|
||||||
parentElement.appendChild(paneElement);
|
parentElement.appendChild(paneElement);
|
||||||
|
|
||||||
const termView = new TerminalView({
|
const termView = new TerminalView({
|
||||||
|
...this.spawnOptions,
|
||||||
|
...paneSpawnOptions,
|
||||||
container: paneElement,
|
container: paneElement,
|
||||||
onFocus: (term) => {
|
onFocus: (term) => {
|
||||||
this.setActiveTerminal(term);
|
this.setActiveTerminal(term);
|
||||||
@@ -79,7 +82,8 @@ export class TabSplitTree {
|
|||||||
splitWrapper.appendChild(divider);
|
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;
|
const newPane = this.terminals.get(newTerm.id).paneElement;
|
||||||
newPane.style.flex = '1 1 0%';
|
newPane.style.flex = '1 1 0%';
|
||||||
|
|
||||||
|
|||||||
+107
-22
@@ -5,12 +5,56 @@ export class TabManager {
|
|||||||
this.tabBar = options.tabBar;
|
this.tabBar = options.tabBar;
|
||||||
this.viewport = options.viewport;
|
this.viewport = options.viewport;
|
||||||
this.onActiveChange = options.onActiveChange || (() => {});
|
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.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);
|
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
|
||||||
|
|
||||||
// 1. 创建标签页 DOM
|
// 1. 创建标签页 DOM
|
||||||
@@ -34,6 +78,7 @@ export class TabManager {
|
|||||||
const splitTree = new TabSplitTree({
|
const splitTree = new TabSplitTree({
|
||||||
container: viewElement,
|
container: viewElement,
|
||||||
tabId,
|
tabId,
|
||||||
|
spawnOptions,
|
||||||
onActiveTerminalChange: (term) => {
|
onActiveTerminalChange: (term) => {
|
||||||
if (this.activeTabId === tabId) {
|
if (this.activeTabId === tabId) {
|
||||||
this.onActiveChange(term);
|
this.onActiveChange(term);
|
||||||
@@ -56,22 +101,44 @@ export class TabManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 根据当前工作区控制是否在顶部标签栏可见
|
||||||
|
if (workspaceId !== this.activeWorkspaceId) {
|
||||||
|
tabElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
this.tabBar.appendChild(tabElement);
|
this.tabBar.appendChild(tabElement);
|
||||||
|
|
||||||
this.tabs.set(tabId, {
|
this.tabs.set(tabId, {
|
||||||
tabId,
|
tabId,
|
||||||
|
workspaceId,
|
||||||
tabElement,
|
tabElement,
|
||||||
viewElement,
|
viewElement,
|
||||||
splitTree,
|
splitTree,
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.switchTab(tabId);
|
if (workspaceId === this.activeWorkspaceId) {
|
||||||
|
this.switchTab(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
return tabId;
|
return tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
switchTab(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()) {
|
for (const [id, tab] of this.tabs.entries()) {
|
||||||
const isActive = id === tabId;
|
const isActive = id === tabId;
|
||||||
@@ -88,26 +155,33 @@ export class TabManager {
|
|||||||
}, 30);
|
}, 30);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeTab(tabId) {
|
closeTab(tabId) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
|
|
||||||
|
const wsId = tab.workspaceId;
|
||||||
tab.splitTree.destroy();
|
tab.splitTree.destroy();
|
||||||
tab.tabElement.remove();
|
tab.tabElement.remove();
|
||||||
tab.viewElement.remove();
|
tab.viewElement.remove();
|
||||||
this.tabs.delete(tabId);
|
this.tabs.delete(tabId);
|
||||||
|
|
||||||
// 如果还有其他标签页,切换到上一个或下一个
|
// 寻找同一工作区的其他标签
|
||||||
if (this.tabs.size > 0) {
|
const remainingWsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === wsId);
|
||||||
const remainingIds = Array.from(this.tabs.keys());
|
if (this.activeTabId === tabId) {
|
||||||
const nextId = remainingIds[remainingIds.length - 1];
|
if (remainingWsTabs.length > 0) {
|
||||||
this.switchTab(nextId);
|
const nextId = remainingWsTabs[remainingWsTabs.length - 1].tabId;
|
||||||
} else {
|
this.switchTab(nextId);
|
||||||
// 没有任何标签页时自动新建一个
|
} else {
|
||||||
this.createTab();
|
// 当前工作区没有其他标签页了,自动新建一个
|
||||||
|
this.createTab({ workspaceId: wsId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTabTitle(tabId, newTitle) {
|
updateTabTitle(tabId, newTitle) {
|
||||||
@@ -118,6 +192,7 @@ export class TabManager {
|
|||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.textContent = newTitle;
|
titleEl.textContent = newTitle;
|
||||||
}
|
}
|
||||||
|
this.onTabListChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,19 +205,29 @@ export class TabManager {
|
|||||||
return activeTab ? activeTab.splitTree.activeTerminal : null;
|
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() {
|
nextTab() {
|
||||||
const ids = Array.from(this.tabs.keys());
|
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||||
if (ids.length <= 1) return;
|
if (wsTabs.length <= 1) return;
|
||||||
const currentIndex = ids.indexOf(this.activeTabId);
|
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||||
const nextIndex = (currentIndex + 1) % ids.length;
|
const nextIndex = (currentIndex + 1) % wsTabs.length;
|
||||||
this.switchTab(ids[nextIndex]);
|
this.switchTab(wsTabs[nextIndex].tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
prevTab() {
|
prevTab() {
|
||||||
const ids = Array.from(this.tabs.keys());
|
const wsTabs = Array.from(this.tabs.values()).filter((t) => t.workspaceId === this.activeWorkspaceId);
|
||||||
if (ids.length <= 1) return;
|
if (wsTabs.length <= 1) return;
|
||||||
const currentIndex = ids.indexOf(this.activeTabId);
|
const currentIndex = wsTabs.findIndex((t) => t.tabId === this.activeTabId);
|
||||||
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
const prevIndex = (currentIndex - 1 + wsTabs.length) % wsTabs.length;
|
||||||
this.switchTab(ids[prevIndex]);
|
this.switchTab(wsTabs[prevIndex].tabId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ export class TerminalView {
|
|||||||
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
|
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
|
||||||
this.container = options.container;
|
this.container = options.container;
|
||||||
this.cwd = options.cwd || '';
|
this.cwd = options.cwd || '';
|
||||||
|
this.shell = options.shell || undefined;
|
||||||
|
this.args = options.args || [];
|
||||||
|
this.title = options.title || 'bash';
|
||||||
this.onTitleChange = options.onTitleChange || (() => {});
|
this.onTitleChange = options.onTitleChange || (() => {});
|
||||||
this.onFocus = options.onFocus || (() => {});
|
this.onFocus = options.onFocus || (() => {});
|
||||||
this.onExit = options.onExit || (() => {});
|
this.onExit = options.onExit || (() => {});
|
||||||
|
|
||||||
this.title = 'bash';
|
|
||||||
this.isFocused = false;
|
this.isFocused = false;
|
||||||
|
|
||||||
this.initTerminal();
|
this.initTerminal();
|
||||||
@@ -113,10 +115,14 @@ export class TerminalView {
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
cwd: this.cwd,
|
cwd: this.cwd,
|
||||||
|
shell: this.shell,
|
||||||
|
args: this.args,
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
if (res && res.shell) {
|
if (res && res.shell) {
|
||||||
this.title = res.shell.split('/').pop() || 'bash';
|
if (!this.title || this.title === 'bash') {
|
||||||
this.onTitleChange(this.title);
|
this.title = res.shell.split('/').pop() || 'bash';
|
||||||
|
this.onTitleChange(this.title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
/**
|
||||||
|
* 工作区管理器 (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();
|
||||||
|
|
||||||
|
// 订阅标签页状态变更,以便实时刷新侧边栏标签列表
|
||||||
|
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 = `
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user