@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.
@@ -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 = 'Maximize terminal';
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 = 'Close terminal';
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.activeId = id; this.clearActivity(id); });
143
- term.textarea?.addEventListener('focus', () => { this.activeId = id; this.clearActivity(id); });
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.activeId = id;
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) entry.term.focus();
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: '#d4d8e8',
5
+ foreground: '#e4e7f2',
6
6
  cursor: '#6c8cff',
7
7
  cursorAccent: '#0a0c12',
8
8
  selectionBackground: '#264f78',
9
9
  selectionForeground: '#ffffff',
10
- black: '#1c2035',
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: '#d4d8e8',
18
- brightBlack: '#555b72',
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
  }
@@ -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