@jacksontian/mwt 1.0.0 → 1.1.0
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/lib/ring-buffer.js +1 -1
- package/lib/server.js +148 -148
- package/package.json +7 -2
- package/public/css/style.css +263 -3
- package/public/index.html +57 -2
- package/public/js/app.js +107 -14
- package/public/js/font-manager.js +43 -0
- package/public/js/layout-manager.js +94 -13
- package/public/js/terminal-manager.js +136 -24
- package/public/js/theme-manager.js +5 -5
- package/public/js/ws-client.js +4 -4
|
@@ -5,10 +5,12 @@ import { WebLinksAddon } from '/vendor/xterm/addon-web-links.mjs';
|
|
|
5
5
|
const { Terminal } = globalThis;
|
|
6
6
|
|
|
7
7
|
export class TerminalManager {
|
|
8
|
-
constructor(wsClient, container, themeManager) {
|
|
8
|
+
constructor(wsClient, container, themeManager, cmdKey = 'Ctrl', fontManager = null) {
|
|
9
9
|
this.wsClient = wsClient;
|
|
10
10
|
this.container = container;
|
|
11
11
|
this.themeManager = themeManager;
|
|
12
|
+
this.fontManager = fontManager;
|
|
13
|
+
this.cmdKey = cmdKey;
|
|
12
14
|
this.terminals = new Map(); // id -> { term, fitAddon, element, resizeObserver }
|
|
13
15
|
this.counter = 0;
|
|
14
16
|
this.activeId = null;
|
|
@@ -25,6 +27,17 @@ export class TerminalManager {
|
|
|
25
27
|
}
|
|
26
28
|
});
|
|
27
29
|
}
|
|
30
|
+
|
|
31
|
+
// Update all terminals when font changes
|
|
32
|
+
if (fontManager) {
|
|
33
|
+
fontManager.onChange(({ fontSize, fontFamily }) => {
|
|
34
|
+
for (const [, entry] of this.terminals) {
|
|
35
|
+
entry.term.options.fontSize = fontSize;
|
|
36
|
+
entry.term.options.fontFamily = fontFamily;
|
|
37
|
+
entry.fitAddon.fit();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
_buildTerminal(id) {
|
|
@@ -42,12 +55,43 @@ export class TerminalManager {
|
|
|
42
55
|
title.className = 'pane-title';
|
|
43
56
|
title.textContent = `Terminal ${num}`;
|
|
44
57
|
|
|
58
|
+
// Drag to swap
|
|
59
|
+
header.draggable = true;
|
|
60
|
+
header.addEventListener('dragstart', (e) => {
|
|
61
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
62
|
+
e.dataTransfer.setData('text/plain', id);
|
|
63
|
+
header.classList.add('dragging-source');
|
|
64
|
+
});
|
|
65
|
+
header.addEventListener('dragend', () => {
|
|
66
|
+
header.classList.remove('dragging-source');
|
|
67
|
+
// Clean up any lingering drag-over highlights
|
|
68
|
+
this.container.querySelectorAll('.terminal-pane.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
69
|
+
});
|
|
70
|
+
pane.addEventListener('dragover', (e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
e.dataTransfer.dropEffect = 'move';
|
|
73
|
+
pane.classList.add('drag-over');
|
|
74
|
+
});
|
|
75
|
+
pane.addEventListener('dragleave', (e) => {
|
|
76
|
+
if (!pane.contains(e.relatedTarget)) {
|
|
77
|
+
pane.classList.remove('drag-over');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
pane.addEventListener('drop', (e) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
pane.classList.remove('drag-over');
|
|
83
|
+
const sourceId = e.dataTransfer.getData('text/plain');
|
|
84
|
+
if (sourceId && sourceId !== id) {
|
|
85
|
+
this.swapTerminals(sourceId, id);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
45
89
|
const headerActions = document.createElement('div');
|
|
46
90
|
headerActions.className = 'pane-actions';
|
|
47
91
|
|
|
48
92
|
const maximizeBtn = document.createElement('button');
|
|
49
93
|
maximizeBtn.className = 'pane-maximize';
|
|
50
|
-
maximizeBtn.title =
|
|
94
|
+
maximizeBtn.title = `Maximize terminal (${this.cmdKey}+Shift+M)`;
|
|
51
95
|
maximizeBtn.innerHTML = '<svg class="icon-maximize" width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="12" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg>'
|
|
52
96
|
+ '<svg class="icon-restore" width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="4" y="1" width="11" height="11" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M4 5H2.5A1.5 1.5 0 001 6.5v8A1.5 1.5 0 002.5 16h8a1.5 1.5 0 001.5-1.5V13" stroke="currentColor" stroke-width="1.5"/></svg>';
|
|
53
97
|
maximizeBtn.addEventListener('click', (e) => {
|
|
@@ -58,7 +102,7 @@ export class TerminalManager {
|
|
|
58
102
|
const closeBtn = document.createElement('button');
|
|
59
103
|
closeBtn.className = 'pane-close';
|
|
60
104
|
closeBtn.textContent = '\u00d7';
|
|
61
|
-
closeBtn.title =
|
|
105
|
+
closeBtn.title = `Close terminal (${this.cmdKey}+Shift+\`)`;
|
|
62
106
|
closeBtn.addEventListener('click', (e) => {
|
|
63
107
|
e.stopPropagation();
|
|
64
108
|
this.closeTerminal(id);
|
|
@@ -80,9 +124,10 @@ export class TerminalManager {
|
|
|
80
124
|
// Create xterm instance
|
|
81
125
|
const term = new Terminal({
|
|
82
126
|
cursorBlink: true,
|
|
83
|
-
fontSize: 14,
|
|
84
|
-
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
|
127
|
+
fontSize: this.fontManager ? this.fontManager.fontSize : 14,
|
|
128
|
+
fontFamily: this.fontManager ? this.fontManager.fontFamily : "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
|
85
129
|
lineHeight: 1.15,
|
|
130
|
+
minimumContrastRatio: 4.5,
|
|
86
131
|
theme: this.themeManager ? this.themeManager.getTerminalTheme() : {},
|
|
87
132
|
allowProposedApi: true,
|
|
88
133
|
});
|
|
@@ -111,7 +156,7 @@ export class TerminalManager {
|
|
|
111
156
|
// Wire xterm -> server (muted during buffer replay to suppress escape sequence responses)
|
|
112
157
|
let muted = false;
|
|
113
158
|
term.onData((data) => {
|
|
114
|
-
if (!muted) this.wsClient.sendData(id, data);
|
|
159
|
+
if (!muted) {this.wsClient.sendData(id, data);}
|
|
115
160
|
});
|
|
116
161
|
|
|
117
162
|
term.onResize(({ cols, rows }) => {
|
|
@@ -139,13 +184,13 @@ export class TerminalManager {
|
|
|
139
184
|
});
|
|
140
185
|
|
|
141
186
|
// Track active terminal on focus and clear activity
|
|
142
|
-
pane.addEventListener('mousedown', () => { this.
|
|
143
|
-
term.textarea?.addEventListener('focus', () => { this.
|
|
187
|
+
pane.addEventListener('mousedown', () => { this.setActiveTerminal(id); this.clearActivity(id); });
|
|
188
|
+
term.textarea?.addEventListener('focus', () => { this.setActiveTerminal(id); this.clearActivity(id); });
|
|
144
189
|
|
|
145
190
|
const setMuted = (v) => { muted = v; };
|
|
146
191
|
this.terminals.set(id, { term, fitAddon, element: pane, resizeObserver, setMuted });
|
|
147
192
|
this._notifyChange({ type: 'add', id });
|
|
148
|
-
this.
|
|
193
|
+
this.setActiveTerminal(id);
|
|
149
194
|
term.focus();
|
|
150
195
|
|
|
151
196
|
return { term, fitAddon, element: pane, resizeObserver, setMuted };
|
|
@@ -165,7 +210,7 @@ export class TerminalManager {
|
|
|
165
210
|
restoreTerminal(id) {
|
|
166
211
|
// Update counter to avoid future ID collisions
|
|
167
212
|
const num = parseInt(id.replace('term-', ''), 10);
|
|
168
|
-
if (num > this.counter) this.counter = num;
|
|
213
|
+
if (num > this.counter) {this.counter = num;}
|
|
169
214
|
|
|
170
215
|
const { fitAddon, setMuted } = this._buildTerminal(id);
|
|
171
216
|
|
|
@@ -183,7 +228,7 @@ export class TerminalManager {
|
|
|
183
228
|
|
|
184
229
|
unmuteTerminal(id) {
|
|
185
230
|
const entry = this.terminals.get(id);
|
|
186
|
-
if (entry) entry.setMuted(false);
|
|
231
|
+
if (entry) {entry.setMuted(false);}
|
|
187
232
|
}
|
|
188
233
|
|
|
189
234
|
unmuteAll() {
|
|
@@ -201,11 +246,15 @@ export class TerminalManager {
|
|
|
201
246
|
}
|
|
202
247
|
this.terminals.clear();
|
|
203
248
|
this.counter = 0;
|
|
249
|
+
this.activeId = null;
|
|
204
250
|
}
|
|
205
251
|
|
|
206
252
|
closeTerminal(id) {
|
|
207
253
|
const entry = this.terminals.get(id);
|
|
208
|
-
if (!entry) return;
|
|
254
|
+
if (!entry) {return;}
|
|
255
|
+
|
|
256
|
+
const idsBefore = this.getIds();
|
|
257
|
+
const idx = idsBefore.indexOf(id);
|
|
209
258
|
|
|
210
259
|
// If closing a maximized terminal, restore others first
|
|
211
260
|
if (entry.element.classList.contains('maximized')) {
|
|
@@ -221,12 +270,51 @@ export class TerminalManager {
|
|
|
221
270
|
entry.element.remove();
|
|
222
271
|
this.terminals.delete(id);
|
|
223
272
|
|
|
273
|
+
if (this.activeId === id) {
|
|
274
|
+
let nextId = null;
|
|
275
|
+
if (idx >= 0) {
|
|
276
|
+
if (idx < idsBefore.length - 1) {
|
|
277
|
+
nextId = idsBefore[idx + 1];
|
|
278
|
+
} else if (idx > 0) {
|
|
279
|
+
nextId = idsBefore[idx - 1];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
this.activeId = nextId;
|
|
283
|
+
}
|
|
284
|
+
this._syncPaneFocus();
|
|
285
|
+
|
|
286
|
+
const focusId = this.activeId;
|
|
287
|
+
if (focusId) {
|
|
288
|
+
requestAnimationFrame(() => {
|
|
289
|
+
if (this.terminals.has(focusId)) {
|
|
290
|
+
this.focusTerminal(focusId);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
224
295
|
this._notifyChange({ type: 'remove', id });
|
|
225
296
|
}
|
|
226
297
|
|
|
298
|
+
/** Updates active terminal state and pane-focused styling (call before focus when switching programmatically). */
|
|
299
|
+
setActiveTerminal(id) {
|
|
300
|
+
if (!this.terminals.has(id)) {return;}
|
|
301
|
+
this.activeId = id;
|
|
302
|
+
this._syncPaneFocus();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_syncPaneFocus() {
|
|
306
|
+
const active = this.activeId;
|
|
307
|
+
for (const [tid, entry] of this.terminals) {
|
|
308
|
+
entry.element.classList.toggle('pane-focused', tid === active);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
227
312
|
focusTerminal(id) {
|
|
228
313
|
const entry = this.terminals.get(id);
|
|
229
|
-
if (entry)
|
|
314
|
+
if (entry) {
|
|
315
|
+
this.setActiveTerminal(id);
|
|
316
|
+
entry.term.focus();
|
|
317
|
+
}
|
|
230
318
|
}
|
|
231
319
|
|
|
232
320
|
fitAll() {
|
|
@@ -248,7 +336,7 @@ export class TerminalManager {
|
|
|
248
336
|
|
|
249
337
|
toggleMaximize(id) {
|
|
250
338
|
const entry = this.terminals.get(id);
|
|
251
|
-
if (!entry) return;
|
|
339
|
+
if (!entry) {return;}
|
|
252
340
|
|
|
253
341
|
const pane = entry.element;
|
|
254
342
|
const isMaximized = pane.classList.contains('maximized');
|
|
@@ -276,7 +364,7 @@ export class TerminalManager {
|
|
|
276
364
|
|
|
277
365
|
getMaximizedId() {
|
|
278
366
|
for (const [id, entry] of this.terminals) {
|
|
279
|
-
if (entry.element.classList.contains('maximized')) return id;
|
|
367
|
+
if (entry.element.classList.contains('maximized')) {return id;}
|
|
280
368
|
}
|
|
281
369
|
return null;
|
|
282
370
|
}
|
|
@@ -293,7 +381,7 @@ export class TerminalManager {
|
|
|
293
381
|
const entry = this.terminals.get(id);
|
|
294
382
|
if (entry) {
|
|
295
383
|
const paneTitle = entry.element.querySelector('.pane-title');
|
|
296
|
-
if (paneTitle) return paneTitle.textContent;
|
|
384
|
+
if (paneTitle) {return paneTitle.textContent;}
|
|
297
385
|
}
|
|
298
386
|
const idx = id.replace('term-', '');
|
|
299
387
|
return `Terminal ${idx}`;
|
|
@@ -301,32 +389,30 @@ export class TerminalManager {
|
|
|
301
389
|
|
|
302
390
|
focusNext() {
|
|
303
391
|
const ids = this.getIds();
|
|
304
|
-
if (ids.length <= 1) return;
|
|
392
|
+
if (ids.length <= 1) {return;}
|
|
305
393
|
const idx = ids.indexOf(this.activeId);
|
|
306
394
|
const nextId = ids[(idx + 1) % ids.length];
|
|
307
|
-
this.activeId = nextId;
|
|
308
395
|
this.focusTerminal(nextId);
|
|
309
396
|
return nextId;
|
|
310
397
|
}
|
|
311
398
|
|
|
312
399
|
focusPrev() {
|
|
313
400
|
const ids = this.getIds();
|
|
314
|
-
if (ids.length <= 1) return;
|
|
401
|
+
if (ids.length <= 1) {return;}
|
|
315
402
|
const idx = ids.indexOf(this.activeId);
|
|
316
403
|
const prevId = ids[(idx - 1 + ids.length) % ids.length];
|
|
317
|
-
this.activeId = prevId;
|
|
318
404
|
this.focusTerminal(prevId);
|
|
319
405
|
return prevId;
|
|
320
406
|
}
|
|
321
407
|
|
|
322
408
|
closeActiveTerminal() {
|
|
323
409
|
const id = this.activeId || this.getIds()[0];
|
|
324
|
-
if (id) this.closeTerminal(id);
|
|
410
|
+
if (id) {this.closeTerminal(id);}
|
|
325
411
|
}
|
|
326
412
|
|
|
327
413
|
toggleMaximizeActive() {
|
|
328
414
|
const id = this.activeId || this.getIds()[0];
|
|
329
|
-
if (id) this.toggleMaximize(id);
|
|
415
|
+
if (id) {this.toggleMaximize(id);}
|
|
330
416
|
}
|
|
331
417
|
|
|
332
418
|
onChange(callback) {
|
|
@@ -344,10 +430,36 @@ export class TerminalManager {
|
|
|
344
430
|
}
|
|
345
431
|
|
|
346
432
|
_notifyChange(event) {
|
|
347
|
-
for (const cb of this.changeCallbacks) cb(event);
|
|
433
|
+
for (const cb of this.changeCallbacks) {cb(event);}
|
|
348
434
|
}
|
|
349
435
|
|
|
350
436
|
_notifyActivity(id, cleared = false) {
|
|
351
|
-
for (const cb of this.activityCallbacks) cb(id, cleared);
|
|
437
|
+
for (const cb of this.activityCallbacks) {cb(id, cleared);}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
swapTerminals(id1, id2) {
|
|
441
|
+
const entry1 = this.terminals.get(id1);
|
|
442
|
+
const entry2 = this.terminals.get(id2);
|
|
443
|
+
if (!entry1 || !entry2) {return;}
|
|
444
|
+
|
|
445
|
+
const el1 = entry1.element;
|
|
446
|
+
const el2 = entry2.element;
|
|
447
|
+
|
|
448
|
+
// Swap DOM positions using a placeholder
|
|
449
|
+
const placeholder = document.createComment('swap');
|
|
450
|
+
el1.replaceWith(placeholder);
|
|
451
|
+
el2.replaceWith(el1);
|
|
452
|
+
placeholder.replaceWith(el2);
|
|
453
|
+
|
|
454
|
+
// Swap Map entries to keep iteration order consistent
|
|
455
|
+
const keys = [...this.terminals.keys()];
|
|
456
|
+
const idx1 = keys.indexOf(id1);
|
|
457
|
+
const idx2 = keys.indexOf(id2);
|
|
458
|
+
const entries = [...this.terminals.entries()];
|
|
459
|
+
[entries[idx1], entries[idx2]] = [entries[idx2], entries[idx1]];
|
|
460
|
+
this.terminals = new Map(entries);
|
|
461
|
+
|
|
462
|
+
this._notifyChange({ type: 'swap', id1, id2 });
|
|
463
|
+
requestAnimationFrame(() => this.fitAll());
|
|
352
464
|
}
|
|
353
465
|
}
|
|
@@ -2,20 +2,20 @@ const STORAGE_KEY = 'myterminal-theme';
|
|
|
2
2
|
|
|
3
3
|
const DARK_TERMINAL_THEME = {
|
|
4
4
|
background: '#0a0c12',
|
|
5
|
-
foreground: '#
|
|
5
|
+
foreground: '#e4e7f2',
|
|
6
6
|
cursor: '#6c8cff',
|
|
7
7
|
cursorAccent: '#0a0c12',
|
|
8
8
|
selectionBackground: '#264f78',
|
|
9
9
|
selectionForeground: '#ffffff',
|
|
10
|
-
black: '#
|
|
10
|
+
black: '#5c657c',
|
|
11
11
|
red: '#e05560',
|
|
12
12
|
green: '#7ec699',
|
|
13
13
|
yellow: '#e6c07b',
|
|
14
14
|
blue: '#6c8cff',
|
|
15
15
|
magenta: '#c678dd',
|
|
16
16
|
cyan: '#56b6c2',
|
|
17
|
-
white: '#
|
|
18
|
-
brightBlack: '#
|
|
17
|
+
white: '#e4e7f2',
|
|
18
|
+
brightBlack: '#9aa4ba',
|
|
19
19
|
brightRed: '#ff6b76',
|
|
20
20
|
brightGreen: '#98e6b3',
|
|
21
21
|
brightYellow: '#ffd68a',
|
|
@@ -91,6 +91,6 @@ export class ThemeManager {
|
|
|
91
91
|
|
|
92
92
|
_apply(theme) {
|
|
93
93
|
document.documentElement.dataset.theme = theme;
|
|
94
|
-
for (const cb of this._listeners) cb(theme);
|
|
94
|
+
for (const cb of this._listeners) {cb(theme);}
|
|
95
95
|
}
|
|
96
96
|
}
|
package/public/js/ws-client.js
CHANGED
|
@@ -65,7 +65,7 @@ export class WSClient {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const listener = this.listeners.get(msg.id);
|
|
68
|
-
if (!listener) return;
|
|
68
|
+
if (!listener) {return;}
|
|
69
69
|
|
|
70
70
|
if (msg.type === 'data' && listener.onData) {
|
|
71
71
|
listener.onData(msg.data);
|
|
@@ -78,7 +78,7 @@ export class WSClient {
|
|
|
78
78
|
setTimeout(() => {
|
|
79
79
|
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 10000);
|
|
80
80
|
this.connect();
|
|
81
|
-
for (const cb of this.onReconnectCallbacks) cb();
|
|
81
|
+
for (const cb of this.onReconnectCallbacks) {cb();}
|
|
82
82
|
}, this.reconnectDelay);
|
|
83
83
|
};
|
|
84
84
|
}
|
|
@@ -109,12 +109,12 @@ export class WSClient {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
onTerminalData(id, callback) {
|
|
112
|
-
if (!this.listeners.has(id)) this.listeners.set(id, {});
|
|
112
|
+
if (!this.listeners.has(id)) {this.listeners.set(id, {});}
|
|
113
113
|
this.listeners.get(id).onData = callback;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
onTerminalExit(id, callback) {
|
|
117
|
-
if (!this.listeners.has(id)) this.listeners.set(id, {});
|
|
117
|
+
if (!this.listeners.has(id)) {this.listeners.set(id, {});}
|
|
118
118
|
this.listeners.get(id).onExit = callback;
|
|
119
119
|
}
|
|
120
120
|
|