@vincentbanana/bananacli 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/terminal.js +305 -0
- package/dist/server/ui-server.js +64 -100
- package/dist/ui/app.js +482 -147
- package/dist/ui/index.html +22 -19
- package/dist/ui/style.css +254 -70
- package/dist/ui/vendor/addon-fit.js +2 -0
- package/dist/ui/vendor/highlight.min.js +1262 -0
- package/dist/ui/vendor/xterm.css +285 -0
- package/dist/ui/vendor/xterm.js +2 -0
- package/package.json +9 -1
package/dist/ui/app.js
CHANGED
|
@@ -64,12 +64,13 @@ function markdown(source) {
|
|
|
64
64
|
while (i < lines.length) {
|
|
65
65
|
const line = lines[i];
|
|
66
66
|
if (/^```/.test(line)) {
|
|
67
|
-
const lang = line.slice(3).trim();
|
|
67
|
+
const lang = line.slice(3).trim().replace(/[^\w+-]/g, '');
|
|
68
68
|
const body = [];
|
|
69
69
|
i += 1;
|
|
70
70
|
while (i < lines.length && !/^```/.test(lines[i])) { body.push(lines[i]); i += 1; }
|
|
71
71
|
i += 1;
|
|
72
|
-
|
|
72
|
+
const codeClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
73
|
+
out.push(`<pre class="code" data-lang="${escapeHtml(lang)}"><code${codeClass}>${escapeHtml(body.join('\n'))}</code></pre>`);
|
|
73
74
|
continue;
|
|
74
75
|
}
|
|
75
76
|
if (/^\s*$/.test(line)) { i += 1; continue; }
|
|
@@ -105,11 +106,67 @@ function markdown(source) {
|
|
|
105
106
|
return out.join('');
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// ---------------- 语法高亮 ----------------
|
|
110
|
+
const LANG_BY_EXT = {
|
|
111
|
+
ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
|
|
112
|
+
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
113
|
+
json: 'json', jsonc: 'json', md: 'markdown', markdown: 'markdown',
|
|
114
|
+
py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin',
|
|
115
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
116
|
+
sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell', psm1: 'powershell',
|
|
117
|
+
html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml',
|
|
118
|
+
css: 'css', scss: 'scss', less: 'less', sql: 'sql', yml: 'yaml', yaml: 'yaml',
|
|
119
|
+
toml: 'ini', ini: 'ini', diff: 'diff', patch: 'diff', dockerfile: 'dockerfile',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function highlightAll(root = document) {
|
|
123
|
+
if (!window.hljs) return;
|
|
124
|
+
for (const node of root.querySelectorAll('pre code')) {
|
|
125
|
+
if (node.dataset.highlighted === 'yes' || !node.textContent.trim()) continue;
|
|
126
|
+
try {
|
|
127
|
+
window.hljs.highlightElement(node);
|
|
128
|
+
} catch {
|
|
129
|
+
/* 单个代码块失败不影响整页 */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 文件预览:按扩展名挑语言;未知扩展名保持纯文本。 */
|
|
135
|
+
function highlightPreview(element, path, code) {
|
|
136
|
+
const ext = (path.split('.').pop() ?? '').toLowerCase();
|
|
137
|
+
const lang = LANG_BY_EXT[ext];
|
|
138
|
+
if (!window.hljs || !lang || code.length > 300_000) {
|
|
139
|
+
element.textContent = code;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
element.innerHTML = `<code class="language-${lang}"></code>`;
|
|
143
|
+
const node = element.firstElementChild;
|
|
144
|
+
node.textContent = code;
|
|
145
|
+
try {
|
|
146
|
+
window.hljs.highlightElement(node);
|
|
147
|
+
} catch {
|
|
148
|
+
element.textContent = code;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
108
154
|
// ---------------- 状态 ----------------
|
|
109
155
|
const state = { info: null, sessions: [], attachments: [], busy: false, pendingApproval: null };
|
|
110
156
|
|
|
111
157
|
const THREAD = $('thread');
|
|
112
158
|
|
|
159
|
+
// 历史消息 / 新回复渲染完成后统一高亮,避免流式过程中反复解析
|
|
160
|
+
let highlightFrame = 0;
|
|
161
|
+
const highlightObserver = new MutationObserver(() => {
|
|
162
|
+
if (highlightFrame) return;
|
|
163
|
+
highlightFrame = requestAnimationFrame(() => {
|
|
164
|
+
highlightFrame = 0;
|
|
165
|
+
highlightAll(THREAD);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
highlightObserver.observe(THREAD, { childList: true, subtree: true });
|
|
169
|
+
|
|
113
170
|
// ---------------- 渲染 ----------------
|
|
114
171
|
function hydrate(root = document) {
|
|
115
172
|
root.querySelectorAll('[data-icon]').forEach((node) => {
|
|
@@ -165,10 +222,11 @@ async function switchSession(ref) {
|
|
|
165
222
|
function renderSidebar() {
|
|
166
223
|
const list = $('sessionList');
|
|
167
224
|
list.innerHTML = '';
|
|
168
|
-
|
|
225
|
+
state.sessions.forEach((item, index) => {
|
|
169
226
|
const active = item.id === state.info?.session?.id ? ' is-active' : '';
|
|
170
227
|
const node = document.createElement('div');
|
|
171
228
|
node.className = `session-item${active}`;
|
|
229
|
+
node.style.setProperty('--i', String(Math.min(index, 12)));
|
|
172
230
|
node.innerHTML =
|
|
173
231
|
`<span class="s-actions">` +
|
|
174
232
|
`<span class="s-act" data-menu="${item.id}" title="更多">${svg('dots')}</span>` +
|
|
@@ -192,7 +250,7 @@ function renderSidebar() {
|
|
|
192
250
|
openSessionMenu(item, node);
|
|
193
251
|
});
|
|
194
252
|
list.appendChild(node);
|
|
195
|
-
}
|
|
253
|
+
});
|
|
196
254
|
}
|
|
197
255
|
|
|
198
256
|
/** 会话操作菜单:切换 / 重命名 / 导出 Markdown / 导出 JSON */
|
|
@@ -673,6 +731,7 @@ function applyTheme(theme) {
|
|
|
673
731
|
button.innerHTML = svg(THEME_ICON[theme]);
|
|
674
732
|
button.title = `主题:${THEME_LABEL[theme]}(点击切换)`;
|
|
675
733
|
}
|
|
734
|
+
document.dispatchEvent(new CustomEvent('banana:theme'));
|
|
676
735
|
}
|
|
677
736
|
|
|
678
737
|
function loadTheme() {
|
|
@@ -976,12 +1035,20 @@ function renderSettings() {
|
|
|
976
1035
|
const terminalField = document.createElement('div');
|
|
977
1036
|
terminalField.className = 'field';
|
|
978
1037
|
terminalField.innerHTML =
|
|
979
|
-
'<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button
|
|
980
|
-
'<
|
|
1038
|
+
'<label>终端</label><div class="row"><button class="btn">在界面里打开终端</button>' +
|
|
1039
|
+
'<select class="tiny-select" aria-label="终端字号">' +
|
|
1040
|
+
[12, 13, 14, 15, 16].map((size) => `<option value="${size}" ${size === term.fontSize ? 'selected' : ''}>${size}px</option>`).join('') +
|
|
1041
|
+
'</select></div>' +
|
|
1042
|
+
'<div class="hint">真实 shell(cmd / PowerShell / Git Bash / WSL),支持颜色和交互;停靠位置可在终端右上角切换,Ctrl+` 开关。</div>';
|
|
981
1043
|
terminalField.querySelector('button').addEventListener('click', () => {
|
|
982
1044
|
closeSettings();
|
|
983
1045
|
termOpen();
|
|
984
1046
|
});
|
|
1047
|
+
terminalField.querySelector('select').addEventListener('change', (event) => {
|
|
1048
|
+
term.fontSize = Number(event.target.value) || 13;
|
|
1049
|
+
localStorage.setItem(TERM_STORAGE.fontSize, String(term.fontSize));
|
|
1050
|
+
termRestyle();
|
|
1051
|
+
});
|
|
985
1052
|
body.appendChild(terminalField);
|
|
986
1053
|
|
|
987
1054
|
const permField = document.createElement('div');
|
|
@@ -1109,9 +1176,10 @@ async function loadTree(path = '.', container = $('filesTree')) {
|
|
|
1109
1176
|
container.innerHTML = '<div class="hint" style="padding:8px 10px">(空目录)</div>';
|
|
1110
1177
|
return;
|
|
1111
1178
|
}
|
|
1112
|
-
|
|
1179
|
+
entries.forEach((entry, index) => {
|
|
1113
1180
|
const row = document.createElement('button');
|
|
1114
1181
|
row.className = 'file-row';
|
|
1182
|
+
row.style.setProperty('--i', String(Math.min(index, 14)));
|
|
1115
1183
|
row.innerHTML =
|
|
1116
1184
|
`<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
|
|
1117
1185
|
`<span>${svg(FILE_ICON[entry.type])}</span>` +
|
|
@@ -1134,7 +1202,7 @@ async function loadTree(path = '.', container = $('filesTree')) {
|
|
|
1134
1202
|
}
|
|
1135
1203
|
});
|
|
1136
1204
|
container.appendChild(row);
|
|
1137
|
-
}
|
|
1205
|
+
});
|
|
1138
1206
|
} catch (error) {
|
|
1139
1207
|
container.innerHTML = `<div class="hint" style="padding:8px 10px">${escapeHtml(error.message)}</div>`;
|
|
1140
1208
|
}
|
|
@@ -1151,7 +1219,7 @@ async function openFile(path) {
|
|
|
1151
1219
|
$('insertPath').dataset.path = path;
|
|
1152
1220
|
if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
|
|
1153
1221
|
else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
|
|
1154
|
-
else body
|
|
1222
|
+
else highlightPreview(body, path, data.content ?? '');
|
|
1155
1223
|
document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
|
|
1156
1224
|
} catch (error) {
|
|
1157
1225
|
body.textContent = error.message;
|
|
@@ -1194,72 +1262,343 @@ function closeSettings() {
|
|
|
1194
1262
|
setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
|
|
1195
1263
|
}
|
|
1196
1264
|
|
|
1197
|
-
// ----------------
|
|
1198
|
-
const
|
|
1265
|
+
// ---------------- 内嵌终端(xterm + PTY) ----------------
|
|
1266
|
+
const TERM_FONT = '"Cascadia Mono", "JetBrains Mono", "SF Mono", Consolas, "Microsoft YaHei", monospace';
|
|
1267
|
+
const TERM_STORAGE = {
|
|
1268
|
+
dock: 'banana.term.dock',
|
|
1269
|
+
size: 'banana.term.size',
|
|
1270
|
+
pos: 'banana.term.pos',
|
|
1271
|
+
fontSize: 'banana.term.fontSize',
|
|
1272
|
+
shell: 'banana.term.shell',
|
|
1273
|
+
};
|
|
1199
1274
|
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1275
|
+
const term = {
|
|
1276
|
+
open: false,
|
|
1277
|
+
dock: localStorage.getItem(TERM_STORAGE.dock) || 'bottom',
|
|
1278
|
+
size: Number(localStorage.getItem(TERM_STORAGE.size)) || 280,
|
|
1279
|
+
fontSize: Number(localStorage.getItem(TERM_STORAGE.fontSize)) || 13,
|
|
1280
|
+
shells: [],
|
|
1281
|
+
defaultShell: '',
|
|
1282
|
+
tabs: [],
|
|
1283
|
+
activeId: null,
|
|
1284
|
+
counter: 0,
|
|
1285
|
+
loadedShells: false,
|
|
1205
1286
|
};
|
|
1206
1287
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1288
|
+
function termIsDark() {
|
|
1289
|
+
const explicit = document.documentElement.dataset.theme;
|
|
1290
|
+
if (explicit === 'dark') return true;
|
|
1291
|
+
if (explicit === 'light') return false;
|
|
1292
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/** 柔和配色:不用纯红纯绿,降低饱和度,长时间看不刺眼。 */
|
|
1296
|
+
function termTheme() {
|
|
1297
|
+
const dark = termIsDark();
|
|
1298
|
+
return {
|
|
1299
|
+
background: dark ? '#101013' : '#fbfbfa',
|
|
1300
|
+
foreground: dark ? '#d9d9d4' : '#26262b',
|
|
1301
|
+
cursor: dark ? '#e8e8e2' : '#16161a',
|
|
1302
|
+
cursorAccent: dark ? '#101013' : '#fbfbfa',
|
|
1303
|
+
selectionBackground: dark ? 'rgba(120, 150, 220, 0.28)' : 'rgba(70, 110, 190, 0.18)',
|
|
1304
|
+
black: dark ? '#2a2a30' : '#3a3a40',
|
|
1305
|
+
red: dark ? '#d1786e' : '#b4453a',
|
|
1306
|
+
green: dark ? '#86b98d' : '#2f7d50',
|
|
1307
|
+
yellow: dark ? '#d2b06a' : '#8f6b1f',
|
|
1308
|
+
blue: dark ? '#8aa9dc' : '#3b63b0',
|
|
1309
|
+
magenta: dark ? '#b294cd' : '#7b4d9e',
|
|
1310
|
+
cyan: dark ? '#82bdbd' : '#257f80',
|
|
1311
|
+
white: dark ? '#c9c9c4' : '#55555c',
|
|
1312
|
+
brightBlack: dark ? '#6c6c75' : '#8c8c93',
|
|
1313
|
+
brightRed: dark ? '#e29a90' : '#c9564a',
|
|
1314
|
+
brightGreen: dark ? '#a3d2a8' : '#3f9a66',
|
|
1315
|
+
brightYellow: dark ? '#e6ca8c' : '#b0852d',
|
|
1316
|
+
brightBlue: dark ? '#a9c3ee' : '#527cd1',
|
|
1317
|
+
brightMagenta: dark ? '#cbb0e2' : '#9a67c4',
|
|
1318
|
+
brightCyan: dark ? '#a2d4d4' : '#2f9a9a',
|
|
1319
|
+
brightWhite: dark ? '#f2f2ee' : '#16161a',
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function termActiveTab() {
|
|
1324
|
+
return term.tabs.find((tab) => tab.id === term.activeId) ?? null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function termUpdateCwd(cwd) {
|
|
1328
|
+
const label = $('termCwd');
|
|
1329
|
+
if (label && cwd) label.textContent = cwd;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function termPersistSize() {
|
|
1333
|
+
localStorage.setItem(TERM_STORAGE.size, String(Math.round(term.size)));
|
|
1334
|
+
localStorage.setItem(TERM_STORAGE.dock, term.dock);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function termApplyDock(dock, persist = true) {
|
|
1338
|
+
term.dock = dock;
|
|
1339
|
+
const panel = $('terminalPanel');
|
|
1340
|
+
const workspace = $('workspace');
|
|
1341
|
+
panel.dataset.dock = dock;
|
|
1342
|
+
workspace.dataset.dock = dock;
|
|
1343
|
+
panel.style.removeProperty('left');
|
|
1344
|
+
panel.style.removeProperty('top');
|
|
1345
|
+
panel.style.removeProperty('right');
|
|
1346
|
+
panel.style.removeProperty('bottom');
|
|
1347
|
+
panel.style.width = '';
|
|
1348
|
+
panel.style.height = '';
|
|
1349
|
+
if (dock === 'bottom' || dock === 'top') {
|
|
1350
|
+
panel.style.height = `${term.size}px`;
|
|
1351
|
+
} else if (dock === 'left' || dock === 'right') {
|
|
1352
|
+
panel.style.width = `${term.size}px`;
|
|
1353
|
+
} else {
|
|
1354
|
+
panel.style.width = `${Math.min(680, Math.max(360, Math.round(workspace.clientWidth * 0.62)))}px`;
|
|
1355
|
+
panel.style.height = `${Math.min(420, Math.max(220, Math.round(workspace.clientHeight * 0.55)))}px`;
|
|
1356
|
+
const saved = termReadPos();
|
|
1357
|
+
if (saved) {
|
|
1358
|
+
panel.style.left = `${saved.x}px`;
|
|
1359
|
+
panel.style.top = `${saved.y}px`;
|
|
1229
1360
|
}
|
|
1230
1361
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1362
|
+
if (persist) termPersistSize();
|
|
1363
|
+
requestAnimationFrame(() => {
|
|
1364
|
+
termActiveTab()?.fit.fit();
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function termReadPos() {
|
|
1369
|
+
try {
|
|
1370
|
+
const raw = JSON.parse(localStorage.getItem(TERM_STORAGE.pos) || 'null');
|
|
1371
|
+
return raw && Number.isFinite(raw.x) && Number.isFinite(raw.y) ? raw : null;
|
|
1372
|
+
} catch {
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function termLoadShells() {
|
|
1378
|
+
if (term.loadedShells) return;
|
|
1379
|
+
term.loadedShells = true;
|
|
1380
|
+
try {
|
|
1381
|
+
const data = await get('shells');
|
|
1382
|
+
term.shells = Array.isArray(data.shells) ? data.shells : [];
|
|
1383
|
+
term.defaultShell = data.default || term.shells[0]?.id || 'cmd';
|
|
1384
|
+
const select = $('termShell');
|
|
1385
|
+
const preferred = localStorage.getItem(TERM_STORAGE.shell);
|
|
1386
|
+
select.innerHTML = term.shells
|
|
1387
|
+
.map((shell) => `<option value="${escapeHtml(shell.id)}">${escapeHtml(shell.name)}</option>`)
|
|
1388
|
+
.join('');
|
|
1389
|
+
select.value = term.shells.some((shell) => shell.id === preferred) ? preferred : term.defaultShell;
|
|
1390
|
+
select.addEventListener('change', () => {
|
|
1391
|
+
localStorage.setItem(TERM_STORAGE.shell, select.value);
|
|
1392
|
+
termCreateTab(select.value);
|
|
1393
|
+
});
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
term.shells = [{ id: 'cmd', name: '终端' }];
|
|
1396
|
+
term.defaultShell = 'cmd';
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function termRenderTabs() {
|
|
1401
|
+
const holder = $('termTabs');
|
|
1402
|
+
holder.innerHTML = '';
|
|
1403
|
+
for (const tab of term.tabs) {
|
|
1404
|
+
const node = document.createElement('div');
|
|
1405
|
+
node.className = 'term-tab' + (tab.id === term.activeId ? ' active' : '');
|
|
1406
|
+
node.innerHTML = `<span class="term-tab-name">${escapeHtml(tab.name)}</span><button class="term-tab-close" title="关闭标签" aria-label="关闭标签"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>`;
|
|
1407
|
+
node.addEventListener('click', (event) => {
|
|
1408
|
+
if (event.target.closest('.term-tab-close')) {
|
|
1409
|
+
event.stopPropagation();
|
|
1410
|
+
termCloseTab(tab.id);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
termActivate(tab.id);
|
|
1414
|
+
});
|
|
1415
|
+
holder.appendChild(node);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function termActivate(id) {
|
|
1420
|
+
term.activeId = id;
|
|
1421
|
+
for (const tab of term.tabs) {
|
|
1422
|
+
const active = tab.id === id;
|
|
1423
|
+
tab.pane.classList.toggle('active', active);
|
|
1424
|
+
if (active) {
|
|
1425
|
+
requestAnimationFrame(() => {
|
|
1426
|
+
try {
|
|
1427
|
+
tab.fit.fit();
|
|
1428
|
+
} catch {
|
|
1429
|
+
/* 面板还没显示 */
|
|
1430
|
+
}
|
|
1431
|
+
tab.terminal.focus();
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
termRenderTabs();
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function termConnect(tab) {
|
|
1439
|
+
const url = new URL(API + 'terminal/ws', location.href);
|
|
1440
|
+
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1441
|
+
url.searchParams.set('shell', tab.shell || term.defaultShell || 'cmd');
|
|
1442
|
+
url.searchParams.set('cwd', state.info?.workspace ?? '');
|
|
1443
|
+
url.searchParams.set('cols', String(tab.terminal.cols || 100));
|
|
1444
|
+
url.searchParams.set('rows', String(tab.terminal.rows || 28));
|
|
1445
|
+
if (tab.termId) url.searchParams.set('id', tab.termId);
|
|
1446
|
+
|
|
1447
|
+
let ws;
|
|
1448
|
+
try {
|
|
1449
|
+
ws = new WebSocket(url);
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
tab.terminal.write(`\r\n\x1b[31m连接终端失败:${error?.message ?? error}\x1b[0m\r\n`);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
tab.ws = ws;
|
|
1455
|
+
|
|
1456
|
+
ws.addEventListener('open', () => {
|
|
1457
|
+
requestAnimationFrame(() => {
|
|
1458
|
+
try {
|
|
1459
|
+
tab.fit.fit();
|
|
1460
|
+
} catch {
|
|
1461
|
+
/* 忽略 */
|
|
1462
|
+
}
|
|
1463
|
+
tab.sendResize();
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
ws.addEventListener('message', (event) => {
|
|
1467
|
+
let message;
|
|
1468
|
+
try {
|
|
1469
|
+
message = JSON.parse(event.data);
|
|
1470
|
+
} catch {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (message.type === 'id' || message.type === 'ready') {
|
|
1474
|
+
if (message.id) tab.termId = message.id;
|
|
1475
|
+
if (message.cwd) termUpdateCwd(message.cwd);
|
|
1476
|
+
} else if (message.type === 'data') {
|
|
1477
|
+
tab.terminal.write(message.data);
|
|
1478
|
+
} else if (message.type === 'exit') {
|
|
1479
|
+
tab.terminal.write(`\r\n\x1b[90m[进程已退出,代码 ${message.code ?? 0}]\x1b[0m\r\n`);
|
|
1480
|
+
} else if (message.type === 'error') {
|
|
1481
|
+
tab.terminal.write(`\r\n\x1b[31m${message.message}\x1b[0m\r\n`);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
ws.addEventListener('close', () => {
|
|
1485
|
+
if (!tab.alive || !term.open) return;
|
|
1486
|
+
// 服务重启或网络抖动时自动重连;服务端会按 tab.termId 把会话接回来
|
|
1487
|
+
setTimeout(() => {
|
|
1488
|
+
if (tab.alive && term.open && (!tab.ws || tab.ws.readyState > 1)) termConnect(tab);
|
|
1489
|
+
}, 900);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
tab.sendResize = () => {
|
|
1493
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: tab.terminal.cols, rows: tab.terminal.rows }));
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function termCreateTab(shellId) {
|
|
1498
|
+
if (!term.open) {
|
|
1499
|
+
termOpen();
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
termLoadShells();
|
|
1503
|
+
const shell = term.shells.find((item) => item.id === shellId) ?? term.shells[0];
|
|
1504
|
+
const name = shell?.name ?? '终端';
|
|
1505
|
+
const pane = document.createElement('div');
|
|
1506
|
+
pane.className = 'term-pane';
|
|
1507
|
+
$('termBody').appendChild(pane);
|
|
1508
|
+
|
|
1509
|
+
const terminal = new window.Terminal({
|
|
1510
|
+
cursorBlink: true,
|
|
1511
|
+
cursorStyle: 'bar',
|
|
1512
|
+
fontFamily: TERM_FONT,
|
|
1513
|
+
fontSize: term.fontSize,
|
|
1514
|
+
lineHeight: 1.25,
|
|
1515
|
+
letterSpacing: 0,
|
|
1516
|
+
scrollback: 8000,
|
|
1517
|
+
allowTransparency: true,
|
|
1518
|
+
theme: termTheme(),
|
|
1519
|
+
rightClickSelectsWord: true,
|
|
1520
|
+
});
|
|
1521
|
+
const fit = new window.FitAddon.FitAddon();
|
|
1522
|
+
terminal.loadAddon(fit);
|
|
1523
|
+
terminal.open(pane);
|
|
1524
|
+
|
|
1525
|
+
const tab = {
|
|
1526
|
+
id: `tab${++term.counter}`,
|
|
1527
|
+
name,
|
|
1528
|
+
shell: shell?.id ?? '',
|
|
1529
|
+
terminal,
|
|
1530
|
+
fit,
|
|
1531
|
+
pane,
|
|
1532
|
+
ws: null,
|
|
1533
|
+
termId: null,
|
|
1534
|
+
alive: true,
|
|
1535
|
+
sendResize: () => {},
|
|
1536
|
+
};
|
|
1537
|
+
term.tabs.push(tab);
|
|
1538
|
+
term.activeId = tab.id;
|
|
1539
|
+
// 输入监听只注册一次;重连只换 WebSocket,不能重复绑定,否则一个按键发两遍
|
|
1540
|
+
terminal.onData((data) => {
|
|
1541
|
+
if (tab.ws?.readyState === 1) tab.ws.send(JSON.stringify({ type: 'input', data }));
|
|
1542
|
+
});
|
|
1543
|
+
terminal.onResize(() => tab.sendResize());
|
|
1544
|
+
termRenderTabs();
|
|
1545
|
+
termActivate(tab.id);
|
|
1546
|
+
requestAnimationFrame(() => {
|
|
1547
|
+
try {
|
|
1548
|
+
fit.fit();
|
|
1549
|
+
} catch {
|
|
1550
|
+
/* 忽略 */
|
|
1551
|
+
}
|
|
1552
|
+
termConnect(tab);
|
|
1553
|
+
});
|
|
1554
|
+
return tab;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function termCloseTab(id) {
|
|
1558
|
+
const index = term.tabs.findIndex((tab) => tab.id === id);
|
|
1559
|
+
if (index < 0) return;
|
|
1560
|
+
const tab = term.tabs[index];
|
|
1561
|
+
tab.alive = false;
|
|
1562
|
+
try {
|
|
1563
|
+
if (tab.ws?.readyState === 1) tab.ws.send(JSON.stringify({ type: 'close' }));
|
|
1564
|
+
tab.ws?.close();
|
|
1565
|
+
} catch {
|
|
1566
|
+
/* 已经断开 */
|
|
1567
|
+
}
|
|
1568
|
+
try {
|
|
1569
|
+
tab.terminal.dispose();
|
|
1570
|
+
} catch {
|
|
1571
|
+
/* 忽略 */
|
|
1572
|
+
}
|
|
1573
|
+
tab.pane.remove();
|
|
1574
|
+
term.tabs.splice(index, 1);
|
|
1575
|
+
if (term.tabs.length === 0) {
|
|
1576
|
+
termClose();
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (term.activeId === id) {
|
|
1580
|
+
const next = term.tabs[Math.max(0, index - 1)] ?? term.tabs[0];
|
|
1581
|
+
termActivate(next.id);
|
|
1582
|
+
} else {
|
|
1583
|
+
termRenderTabs();
|
|
1247
1584
|
}
|
|
1248
|
-
body.scrollTop = body.scrollHeight;
|
|
1249
1585
|
}
|
|
1250
1586
|
|
|
1251
1587
|
function termOpen() {
|
|
1252
1588
|
const panel = $('terminalPanel');
|
|
1589
|
+
termApplyDock(term.dock, false);
|
|
1253
1590
|
if (!term.open) {
|
|
1254
1591
|
term.open = true;
|
|
1255
1592
|
panel.hidden = false;
|
|
1256
1593
|
requestAnimationFrame(() => panel.classList.add('show'));
|
|
1257
|
-
if (!term.size) {
|
|
1258
|
-
termWrite('在当前工作区执行命令;cd 会记住目录,clear 清屏。\n', 'input');
|
|
1259
|
-
}
|
|
1260
1594
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1595
|
+
const ensure = () => {
|
|
1596
|
+
if (term.tabs.length === 0) termCreateTab($('termShell')?.value || term.defaultShell);
|
|
1597
|
+
else termActivate(term.activeId);
|
|
1598
|
+
};
|
|
1599
|
+
if (term.loadedShells) ensure();
|
|
1600
|
+
else termLoadShells().then(ensure);
|
|
1601
|
+
termUpdateCwd(state.info?.workspace ?? '');
|
|
1263
1602
|
}
|
|
1264
1603
|
|
|
1265
1604
|
function termClose() {
|
|
@@ -1271,7 +1610,7 @@ function termClose() {
|
|
|
1271
1610
|
if (term.open) return;
|
|
1272
1611
|
panel.hidden = true;
|
|
1273
1612
|
panel.classList.remove('closing');
|
|
1274
|
-
},
|
|
1613
|
+
}, 180);
|
|
1275
1614
|
}
|
|
1276
1615
|
|
|
1277
1616
|
function termToggle() {
|
|
@@ -1279,112 +1618,108 @@ function termToggle() {
|
|
|
1279
1618
|
else termOpen();
|
|
1280
1619
|
}
|
|
1281
1620
|
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
$('termSend').disabled = true;
|
|
1293
|
-
termWrite(`\n> ${cmd}\n`, 'input');
|
|
1294
|
-
|
|
1295
|
-
const history = term.history;
|
|
1296
|
-
if (history[history.length - 1] !== cmd) history.push(cmd);
|
|
1297
|
-
term.cursor = history.length;
|
|
1298
|
-
|
|
1299
|
-
term.controller = new AbortController();
|
|
1300
|
-
try {
|
|
1301
|
-
const response = await fetch(API + 'terminal', {
|
|
1302
|
-
method: 'POST',
|
|
1303
|
-
headers: { 'content-type': 'application/json' },
|
|
1304
|
-
body: JSON.stringify({ command: cmd }),
|
|
1305
|
-
signal: term.controller.signal,
|
|
1306
|
-
});
|
|
1307
|
-
if (!response.ok) {
|
|
1308
|
-
const payload = await response.json().catch(() => ({}));
|
|
1309
|
-
termWrite((payload.error ?? `终端启动失败(${response.status})`) + '\n', 'stderr');
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
const reader = response.body.getReader();
|
|
1313
|
-
const decoder = new TextDecoder();
|
|
1314
|
-
let buffer = '';
|
|
1315
|
-
for (;;) {
|
|
1316
|
-
const { done, value } = await reader.read();
|
|
1317
|
-
if (done) break;
|
|
1318
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1319
|
-
let index;
|
|
1320
|
-
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
1321
|
-
const raw = buffer.slice(0, index);
|
|
1322
|
-
buffer = buffer.slice(index + 2);
|
|
1323
|
-
const line = raw.startsWith('data: ') ? raw.slice(6) : raw;
|
|
1324
|
-
if (!line) continue;
|
|
1325
|
-
let event;
|
|
1326
|
-
try { event = JSON.parse(line); } catch { continue; }
|
|
1327
|
-
if (event.type === 'out') termWrite(event.data, event.stream);
|
|
1328
|
-
else if (event.type === 'start' || event.type === 'exit') {
|
|
1329
|
-
if (event.cwd) $('termCwd').textContent = event.cwd;
|
|
1330
|
-
if (event.error) termWrite(event.error + '\n', 'stderr');
|
|
1331
|
-
if (event.type === 'exit' && event.code) termWrite(`进程退出,代码 ${event.code}\n`, 'stderr');
|
|
1332
|
-
}
|
|
1621
|
+
function termRestyle() {
|
|
1622
|
+
const theme = termTheme();
|
|
1623
|
+
for (const tab of term.tabs) {
|
|
1624
|
+
tab.terminal.options.theme = theme;
|
|
1625
|
+
tab.terminal.options.fontSize = term.fontSize;
|
|
1626
|
+
requestAnimationFrame(() => {
|
|
1627
|
+
try {
|
|
1628
|
+
tab.fit.fit();
|
|
1629
|
+
} catch {
|
|
1630
|
+
/* 忽略 */
|
|
1333
1631
|
}
|
|
1334
|
-
}
|
|
1335
|
-
} catch (error) {
|
|
1336
|
-
if (error?.name !== 'AbortError') termWrite(`终端错误:${error?.message ?? error}\n`, 'stderr');
|
|
1337
|
-
else termWrite('已停止。\n', 'input');
|
|
1338
|
-
} finally {
|
|
1339
|
-
term.running = false;
|
|
1340
|
-
term.controller = null;
|
|
1341
|
-
$('termStop').hidden = true;
|
|
1342
|
-
$('termSend').disabled = false;
|
|
1343
|
-
if (term.open) $('termInput').focus();
|
|
1632
|
+
});
|
|
1344
1633
|
}
|
|
1345
1634
|
}
|
|
1346
1635
|
|
|
1347
1636
|
function bindTerminal() {
|
|
1348
1637
|
$('termBtn').innerHTML = svg('terminal');
|
|
1349
|
-
$('
|
|
1638
|
+
$('termAdd').innerHTML = svg('plus');
|
|
1350
1639
|
$('termClear').innerHTML = svg('trash');
|
|
1351
1640
|
$('termClose').innerHTML = svg('x');
|
|
1641
|
+
$('termDock').innerHTML = svg('panel');
|
|
1352
1642
|
$('termBtn').addEventListener('click', termToggle);
|
|
1353
1643
|
$('termClose').addEventListener('click', termClose);
|
|
1354
|
-
$('
|
|
1355
|
-
$('
|
|
1356
|
-
term.size = 0;
|
|
1644
|
+
$('termAdd').addEventListener('click', () => {
|
|
1645
|
+
termCreateTab($('termShell')?.value || term.defaultShell);
|
|
1357
1646
|
});
|
|
1358
|
-
$('
|
|
1359
|
-
$('
|
|
1647
|
+
$('termClear').addEventListener('click', () => termActiveTab()?.terminal.clear());
|
|
1648
|
+
$('termDock').addEventListener('click', () => {
|
|
1649
|
+
const order = ['bottom', 'right', 'left', 'float'];
|
|
1650
|
+
const next = order[(order.indexOf(term.dock) + 1) % order.length];
|
|
1651
|
+
termApplyDock(next);
|
|
1652
|
+
toast(`终端停靠在${next === 'bottom' ? '底部' : next === 'top' ? '顶部' : next === 'right' ? '右侧' : next === 'left' ? '左侧' : '浮动'}`);
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
// 拖动边缘调整大小(浮动态拖标题栏移动位置)
|
|
1656
|
+
const grip = $('termGrip');
|
|
1657
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
1360
1658
|
event.preventDefault();
|
|
1361
|
-
const
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1364
|
-
|
|
1659
|
+
const workspace = $('workspace');
|
|
1660
|
+
const rect = workspace.getBoundingClientRect();
|
|
1661
|
+
const move = (moveEvent) => {
|
|
1662
|
+
let size;
|
|
1663
|
+
if (term.dock === 'bottom') size = rect.bottom - moveEvent.clientY;
|
|
1664
|
+
else if (term.dock === 'top') size = moveEvent.clientY - rect.top;
|
|
1665
|
+
else if (term.dock === 'right') size = rect.right - moveEvent.clientX;
|
|
1666
|
+
else size = moveEvent.clientX - rect.left;
|
|
1667
|
+
term.size = Math.min(Math.max(160, size), (term.dock === 'left' || term.dock === 'right' ? rect.width : rect.height) - 160);
|
|
1668
|
+
if (term.dock === 'bottom' || term.dock === 'top') $('terminalPanel').style.height = `${term.size}px`;
|
|
1669
|
+
else $('terminalPanel').style.width = `${term.size}px`;
|
|
1670
|
+
termActiveTab()?.fit.fit();
|
|
1671
|
+
};
|
|
1672
|
+
const up = () => {
|
|
1673
|
+
window.removeEventListener('pointermove', move);
|
|
1674
|
+
window.removeEventListener('pointerup', up);
|
|
1675
|
+
termPersistSize();
|
|
1676
|
+
};
|
|
1677
|
+
window.addEventListener('pointermove', move);
|
|
1678
|
+
window.addEventListener('pointerup', up);
|
|
1365
1679
|
});
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1680
|
+
|
|
1681
|
+
const panel = $('terminalPanel');
|
|
1682
|
+
panel.addEventListener('pointerdown', (event) => {
|
|
1683
|
+
if (term.dock !== 'float') return;
|
|
1684
|
+
if (event.target.closest('button, select, .term-tab, .term-grip')) return;
|
|
1685
|
+
const workspace = $('workspace');
|
|
1686
|
+
const rect = workspace.getBoundingClientRect();
|
|
1687
|
+
const panelRect = panel.getBoundingClientRect();
|
|
1688
|
+
const offsetX = event.clientX - panelRect.left;
|
|
1689
|
+
const offsetY = event.clientY - panelRect.top;
|
|
1690
|
+
const move = (moveEvent) => {
|
|
1691
|
+
const x = Math.min(Math.max(0, moveEvent.clientX - rect.left - offsetX), rect.width - 240);
|
|
1692
|
+
const y = Math.min(Math.max(0, moveEvent.clientY - rect.top - offsetY), rect.height - 80);
|
|
1693
|
+
panel.style.left = `${x}px`;
|
|
1694
|
+
panel.style.top = `${y}px`;
|
|
1695
|
+
};
|
|
1696
|
+
const up = () => {
|
|
1697
|
+
window.removeEventListener('pointermove', move);
|
|
1698
|
+
window.removeEventListener('pointerup', up);
|
|
1699
|
+
const x = parseFloat(panel.style.left) || 0;
|
|
1700
|
+
const y = parseFloat(panel.style.top) || 0;
|
|
1701
|
+
localStorage.setItem(TERM_STORAGE.pos, JSON.stringify({ x, y }));
|
|
1702
|
+
};
|
|
1703
|
+
window.addEventListener('pointermove', move);
|
|
1704
|
+
window.addEventListener('pointerup', up);
|
|
1379
1705
|
});
|
|
1706
|
+
|
|
1380
1707
|
window.addEventListener('keydown', (event) => {
|
|
1381
1708
|
if (event.ctrlKey && event.key === '`') {
|
|
1382
1709
|
event.preventDefault();
|
|
1383
1710
|
termToggle();
|
|
1384
1711
|
}
|
|
1385
1712
|
});
|
|
1386
|
-
|
|
1387
|
-
|
|
1713
|
+
|
|
1714
|
+
const observer = new ResizeObserver(() => {
|
|
1715
|
+
if (term.open) termActiveTab()?.fit.fit();
|
|
1716
|
+
});
|
|
1717
|
+
observer.observe($('workspace'));
|
|
1718
|
+
|
|
1719
|
+
document.addEventListener('banana:theme', () => {
|
|
1720
|
+
if (term.tabs.length) termRestyle();
|
|
1721
|
+
});
|
|
1722
|
+
termUpdateCwd(state.info?.workspace ?? '');
|
|
1388
1723
|
}
|
|
1389
1724
|
|
|
1390
1725
|
function bind() {
|