@jacksontian/mwt 1.0.0 → 1.2.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/README.md +24 -6
- package/lib/pty-manager.js +40 -0
- package/lib/ring-buffer.js +1 -1
- package/lib/server.js +145 -159
- package/lib/session-manager.js +66 -0
- package/package.json +8 -3
- package/public/css/style.css +378 -13
- package/public/index.html +77 -12
- package/public/js/app.js +203 -95
- package/public/js/drag-manager.js +42 -0
- package/public/js/font-manager.js +43 -0
- package/public/js/i18n.js +176 -0
- package/public/js/layout-manager.js +99 -15
- package/public/js/shortcut-manager.js +102 -0
- package/public/js/terminal-manager.js +149 -26
- package/public/js/theme-manager.js +6 -6
- package/public/js/ws-client.js +27 -6
|
@@ -2,16 +2,24 @@ export class LayoutManager {
|
|
|
2
2
|
constructor(container, tabBar) {
|
|
3
3
|
this.container = container;
|
|
4
4
|
this.tabBar = tabBar;
|
|
5
|
-
this.currentLayout = '
|
|
5
|
+
this.currentLayout = 'columns';
|
|
6
6
|
this.activeTabId = null;
|
|
7
|
+
/** @type {((id: string) => void) | null} Called after tab becomes active (focus PTY). */
|
|
8
|
+
this.onTabActivate = null;
|
|
9
|
+
/** @type {((layout: string) => void) | null} After refit when layout mode changes (focus PTY). */
|
|
10
|
+
this.onLayoutApplied = null;
|
|
7
11
|
// These will be set by app.js after construction
|
|
8
12
|
this.fitAll = () => {};
|
|
9
13
|
this.closeTerminal = () => {};
|
|
14
|
+
this.swapTerminals = null;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
setLayout(mode) {
|
|
18
|
+
// Clear maximized state before switching — layout takes priority
|
|
19
|
+
this._clearMaximizedState();
|
|
20
|
+
|
|
13
21
|
// Remove all layout classes
|
|
14
|
-
this.container.classList.remove('layout-
|
|
22
|
+
this.container.classList.remove('layout-columns', 'layout-rows', 'layout-grid', 'layout-tabs');
|
|
15
23
|
this.container.classList.add(`layout-${mode}`);
|
|
16
24
|
this.currentLayout = mode;
|
|
17
25
|
|
|
@@ -37,8 +45,15 @@ export class LayoutManager {
|
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
// Refit after layout change
|
|
41
|
-
requestAnimationFrame(() =>
|
|
48
|
+
// Refit after layout change, then restore keyboard focus to the active terminal
|
|
49
|
+
requestAnimationFrame(() => {
|
|
50
|
+
this.fitAll();
|
|
51
|
+
requestAnimationFrame(() => {
|
|
52
|
+
if (this.onLayoutApplied) {
|
|
53
|
+
this.onLayoutApplied(this.currentLayout);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
onTerminalAdded(id) {
|
|
@@ -53,14 +68,29 @@ export class LayoutManager {
|
|
|
53
68
|
|
|
54
69
|
onTerminalRemoved(id) {
|
|
55
70
|
if (this.currentLayout === 'tabs') {
|
|
71
|
+
let nextTabId = null;
|
|
72
|
+
if (this.activeTabId === id) {
|
|
73
|
+
const tabs = [...this.tabBar.querySelectorAll('.tab')];
|
|
74
|
+
const tidx = tabs.findIndex(t => t.dataset.id === id);
|
|
75
|
+
if (tidx >= 0) {
|
|
76
|
+
if (tidx < tabs.length - 1) {
|
|
77
|
+
nextTabId = tabs[tidx + 1].dataset.id;
|
|
78
|
+
} else if (tidx > 0) {
|
|
79
|
+
nextTabId = tabs[tidx - 1].dataset.id;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
56
83
|
this._removeTab(id);
|
|
57
84
|
if (this.activeTabId === id) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (firstTab) {
|
|
61
|
-
this.activateTab(firstTab.dataset.id);
|
|
85
|
+
if (nextTabId) {
|
|
86
|
+
this.activateTab(nextTabId);
|
|
62
87
|
} else {
|
|
63
|
-
|
|
88
|
+
const firstTab = this.tabBar.querySelector('.tab');
|
|
89
|
+
if (firstTab) {
|
|
90
|
+
this.activateTab(firstTab.dataset.id);
|
|
91
|
+
} else {
|
|
92
|
+
this.activeTabId = null;
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
}
|
|
66
96
|
} else if (this.currentLayout === 'grid') {
|
|
@@ -85,21 +115,25 @@ export class LayoutManager {
|
|
|
85
115
|
// Clear activity indicator for the activated tab
|
|
86
116
|
this.clearActivity(id);
|
|
87
117
|
|
|
118
|
+
if (this.onTabActivate) {
|
|
119
|
+
this.onTabActivate(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
88
122
|
requestAnimationFrame(() => this.fitAll());
|
|
89
123
|
}
|
|
90
124
|
|
|
91
125
|
markActivity(id) {
|
|
92
126
|
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
93
|
-
if (tab) tab.classList.add('has-activity');
|
|
127
|
+
if (tab) {tab.classList.add('has-activity');}
|
|
94
128
|
const pane = this.container.querySelector(`.terminal-pane[data-id="${id}"]`);
|
|
95
|
-
if (pane) pane.classList.add('has-activity');
|
|
129
|
+
if (pane) {pane.classList.add('has-activity');}
|
|
96
130
|
}
|
|
97
131
|
|
|
98
132
|
clearActivity(id) {
|
|
99
133
|
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
100
|
-
if (tab) tab.classList.remove('has-activity');
|
|
134
|
+
if (tab) {tab.classList.remove('has-activity');}
|
|
101
135
|
const pane = this.container.querySelector(`.terminal-pane[data-id="${id}"]`);
|
|
102
|
-
if (pane) pane.classList.remove('has-activity');
|
|
136
|
+
if (pane) {pane.classList.remove('has-activity');}
|
|
103
137
|
}
|
|
104
138
|
|
|
105
139
|
_rebuildTabBar() {
|
|
@@ -115,6 +149,7 @@ export class LayoutManager {
|
|
|
115
149
|
const tab = document.createElement('div');
|
|
116
150
|
tab.className = 'tab';
|
|
117
151
|
tab.dataset.id = id;
|
|
152
|
+
tab.draggable = true;
|
|
118
153
|
|
|
119
154
|
const title = document.createElement('span');
|
|
120
155
|
title.className = 'tab-title';
|
|
@@ -136,6 +171,35 @@ export class LayoutManager {
|
|
|
136
171
|
this.activateTab(id);
|
|
137
172
|
});
|
|
138
173
|
|
|
174
|
+
tab.addEventListener('dragstart', (e) => {
|
|
175
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
176
|
+
e.dataTransfer.setData('text/plain', id);
|
|
177
|
+
});
|
|
178
|
+
tab.addEventListener('dragover', (e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
e.dataTransfer.dropEffect = 'move';
|
|
181
|
+
tab.classList.add('drag-over');
|
|
182
|
+
});
|
|
183
|
+
tab.addEventListener('dragleave', () => {
|
|
184
|
+
tab.classList.remove('drag-over');
|
|
185
|
+
});
|
|
186
|
+
tab.addEventListener('drop', (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
tab.classList.remove('drag-over');
|
|
189
|
+
const sourceId = e.dataTransfer.getData('text/plain');
|
|
190
|
+
if (sourceId && sourceId !== id && this.swapTerminals) {
|
|
191
|
+
this.swapTerminals(sourceId, id);
|
|
192
|
+
// Reorder tab DOM to match
|
|
193
|
+
const sourceTab = this.tabBar.querySelector(`.tab[data-id="${sourceId}"]`);
|
|
194
|
+
if (sourceTab) {
|
|
195
|
+
const placeholder = document.createComment('tab-swap');
|
|
196
|
+
sourceTab.replaceWith(placeholder);
|
|
197
|
+
tab.replaceWith(sourceTab);
|
|
198
|
+
placeholder.replaceWith(tab);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
139
203
|
this.tabBar.appendChild(tab);
|
|
140
204
|
}
|
|
141
205
|
|
|
@@ -149,12 +213,32 @@ export class LayoutManager {
|
|
|
149
213
|
|
|
150
214
|
_removeTab(id) {
|
|
151
215
|
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
152
|
-
if (tab) tab.remove();
|
|
216
|
+
if (tab) {tab.remove();}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_clearMaximizedState() {
|
|
220
|
+
this.container.querySelectorAll('.terminal-pane.maximized').forEach(el => el.classList.remove('maximized'));
|
|
221
|
+
this.container.querySelectorAll('.terminal-pane.hidden-by-maximize').forEach(el => el.classList.remove('hidden-by-maximize'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onTerminalMaximized() {
|
|
225
|
+
if (this.currentLayout === 'grid') {
|
|
226
|
+
this.container.style.gridTemplateColumns = '1fr';
|
|
227
|
+
this.container.style.gridTemplateRows = '1fr';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
onTerminalRestored() {
|
|
232
|
+
if (this.currentLayout === 'grid') {
|
|
233
|
+
this._updateGridColumns();
|
|
234
|
+
}
|
|
153
235
|
}
|
|
154
236
|
|
|
155
237
|
_updateGridColumns() {
|
|
156
238
|
const count = this.container.querySelectorAll('.terminal-pane').length;
|
|
157
|
-
const cols = count
|
|
239
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
240
|
+
const rows = Math.ceil(count / cols);
|
|
158
241
|
this.container.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
242
|
+
this.container.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
|
159
243
|
}
|
|
160
244
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export class ShortcutManager {
|
|
2
|
+
/**
|
|
3
|
+
* @param {{ terminalManager, layoutManager, cmdKey: string }} opts
|
|
4
|
+
*/
|
|
5
|
+
constructor({ terminalManager, layoutManager, cmdKey }) {
|
|
6
|
+
this._tm = terminalManager;
|
|
7
|
+
this._lm = layoutManager;
|
|
8
|
+
this._cmdKey = cmdKey;
|
|
9
|
+
this._handler = this._onKeyDown.bind(this);
|
|
10
|
+
document.addEventListener('keydown', this._handler, true);
|
|
11
|
+
terminalManager.setShortcutManager(this);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Called from document capture phase
|
|
15
|
+
_onKeyDown(e) {
|
|
16
|
+
if (this.matchKeyDown(e)) {
|
|
17
|
+
e._shortcutHandled = true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns true if the event matched a shortcut (used by xterm handler to block key)
|
|
22
|
+
matchKeyDown(e) {
|
|
23
|
+
const ctrl = e.ctrlKey;
|
|
24
|
+
const alt = e.altKey;
|
|
25
|
+
const tm = this._tm;
|
|
26
|
+
const lm = this._lm;
|
|
27
|
+
|
|
28
|
+
// Ctrl+Alt+N: new terminal
|
|
29
|
+
if (ctrl && alt && e.code === 'KeyN') {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
tm.createTerminal();
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ctrl+Alt+W: close active terminal
|
|
36
|
+
if (ctrl && alt && e.code === 'KeyW') {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
tm.closeActiveTerminal();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ctrl+Alt+ArrowRight: next terminal
|
|
43
|
+
if (ctrl && alt && e.code === 'ArrowRight') {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
const nextId = tm.focusNext();
|
|
46
|
+
if (nextId) {
|
|
47
|
+
tm.clearActivity(nextId);
|
|
48
|
+
if (lm.currentLayout === 'tabs') { lm.activateTab(nextId); }
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ctrl+Alt+ArrowLeft: previous terminal
|
|
54
|
+
if (ctrl && alt && e.code === 'ArrowLeft') {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const prevId = tm.focusPrev();
|
|
57
|
+
if (prevId) {
|
|
58
|
+
tm.clearActivity(prevId);
|
|
59
|
+
if (lm.currentLayout === 'tabs') { lm.activateTab(prevId); }
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Alt+1~9: switch to terminal N
|
|
65
|
+
if (alt && !ctrl && e.code >= 'Digit1' && e.code <= 'Digit9') {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
const ids = tm.getIds();
|
|
68
|
+
const idx = parseInt(e.code[5], 10) - 1;
|
|
69
|
+
if (idx < ids.length) {
|
|
70
|
+
const targetId = ids[idx];
|
|
71
|
+
tm.focusTerminal(targetId);
|
|
72
|
+
tm.clearActivity(targetId);
|
|
73
|
+
if (lm.currentLayout === 'tabs') { lm.activateTab(targetId); }
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ctrl+Alt+M: maximize/restore active terminal
|
|
79
|
+
if (ctrl && alt && e.code === 'KeyM') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
tm.toggleMaximizeActive();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// F11: toggle fullscreen
|
|
86
|
+
if (e.key === 'F11') {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
if (!document.fullscreenElement) {
|
|
89
|
+
document.documentElement.requestFullscreen();
|
|
90
|
+
} else {
|
|
91
|
+
document.exitFullscreen();
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
destroy() {
|
|
100
|
+
document.removeEventListener('keydown', this._handler, true);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { FitAddon } from '/vendor/xterm/addon-fit.mjs';
|
|
2
2
|
import { WebLinksAddon } from '/vendor/xterm/addon-web-links.mjs';
|
|
3
|
+
import { DragManager } from './drag-manager.js';
|
|
3
4
|
|
|
4
5
|
// xterm.js is loaded as UMD via <script> tag, access from global
|
|
5
6
|
const { Terminal } = globalThis;
|
|
6
7
|
|
|
7
8
|
export class TerminalManager {
|
|
8
|
-
constructor(wsClient, container, themeManager) {
|
|
9
|
+
constructor(wsClient, container, themeManager, cmdKey = 'Ctrl', fontManager = null, i18n = null) {
|
|
9
10
|
this.wsClient = wsClient;
|
|
10
11
|
this.container = container;
|
|
11
12
|
this.themeManager = themeManager;
|
|
13
|
+
this.fontManager = fontManager;
|
|
14
|
+
this.cmdKey = cmdKey;
|
|
15
|
+
this.i18n = i18n;
|
|
12
16
|
this.terminals = new Map(); // id -> { term, fitAddon, element, resizeObserver }
|
|
13
17
|
this.counter = 0;
|
|
14
18
|
this.activeId = null;
|
|
15
19
|
this.changeCallbacks = [];
|
|
16
20
|
this.activityCallbacks = [];
|
|
21
|
+
this._dragManager = new DragManager((id1, id2) => this.swapTerminals(id1, id2));
|
|
17
22
|
this.activitySet = new Set(); // terminal IDs with unread activity
|
|
23
|
+
this._shortcutManager = null;
|
|
18
24
|
|
|
19
25
|
// Update all terminals when theme changes
|
|
20
26
|
if (themeManager) {
|
|
@@ -25,6 +31,17 @@ export class TerminalManager {
|
|
|
25
31
|
}
|
|
26
32
|
});
|
|
27
33
|
}
|
|
34
|
+
|
|
35
|
+
// Update all terminals when font changes
|
|
36
|
+
if (fontManager) {
|
|
37
|
+
fontManager.onChange(({ fontSize, fontFamily }) => {
|
|
38
|
+
for (const [, entry] of this.terminals) {
|
|
39
|
+
entry.term.options.fontSize = fontSize;
|
|
40
|
+
entry.term.options.fontFamily = fontFamily;
|
|
41
|
+
entry.fitAddon.fit();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
_buildTerminal(id) {
|
|
@@ -40,14 +57,17 @@ export class TerminalManager {
|
|
|
40
57
|
|
|
41
58
|
const title = document.createElement('span');
|
|
42
59
|
title.className = 'pane-title';
|
|
43
|
-
title.textContent = `Terminal ${num}`;
|
|
60
|
+
title.textContent = this.i18n ? this.i18n.t('terminalTitle', num) : `Terminal ${num}`;
|
|
61
|
+
|
|
62
|
+
// Drag to swap
|
|
63
|
+
this._dragManager.attach(pane, header, id);
|
|
44
64
|
|
|
45
65
|
const headerActions = document.createElement('div');
|
|
46
66
|
headerActions.className = 'pane-actions';
|
|
47
67
|
|
|
48
68
|
const maximizeBtn = document.createElement('button');
|
|
49
69
|
maximizeBtn.className = 'pane-maximize';
|
|
50
|
-
maximizeBtn.title = 'Maximize terminal
|
|
70
|
+
maximizeBtn.title = this.i18n ? this.i18n.t('maximizeTerminal', this.cmdKey) : `Maximize terminal (${this.cmdKey}+Shift+M)`;
|
|
51
71
|
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
72
|
+ '<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
73
|
maximizeBtn.addEventListener('click', (e) => {
|
|
@@ -58,7 +78,7 @@ export class TerminalManager {
|
|
|
58
78
|
const closeBtn = document.createElement('button');
|
|
59
79
|
closeBtn.className = 'pane-close';
|
|
60
80
|
closeBtn.textContent = '\u00d7';
|
|
61
|
-
closeBtn.title = 'Close terminal
|
|
81
|
+
closeBtn.title = this.i18n ? this.i18n.t('closeTerminalBtn', this.cmdKey) : `Close terminal (${this.cmdKey}+Shift+\`)`;
|
|
62
82
|
closeBtn.addEventListener('click', (e) => {
|
|
63
83
|
e.stopPropagation();
|
|
64
84
|
this.closeTerminal(id);
|
|
@@ -80,9 +100,10 @@ export class TerminalManager {
|
|
|
80
100
|
// Create xterm instance
|
|
81
101
|
const term = new Terminal({
|
|
82
102
|
cursorBlink: true,
|
|
83
|
-
fontSize: 14,
|
|
84
|
-
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
|
103
|
+
fontSize: this.fontManager ? this.fontManager.fontSize : 14,
|
|
104
|
+
fontFamily: this.fontManager ? this.fontManager.fontFamily : "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
|
|
85
105
|
lineHeight: 1.15,
|
|
106
|
+
minimumContrastRatio: 4.5,
|
|
86
107
|
theme: this.themeManager ? this.themeManager.getTerminalTheme() : {},
|
|
87
108
|
allowProposedApi: true,
|
|
88
109
|
});
|
|
@@ -93,6 +114,19 @@ export class TerminalManager {
|
|
|
93
114
|
term.loadAddon(webLinksAddon);
|
|
94
115
|
term.open(body);
|
|
95
116
|
|
|
117
|
+
// Block xterm from sending app shortcut keys to the terminal process
|
|
118
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
119
|
+
if (e.type !== 'keydown') return true;
|
|
120
|
+
// Already handled by document capture phase — block xterm
|
|
121
|
+
if (e._shortcutHandled) return false;
|
|
122
|
+
// Focus is in xterm and capture phase didn't fire — try shortcut manager directly
|
|
123
|
+
if (this._shortcutManager) {
|
|
124
|
+
e._shortcutHandled = true;
|
|
125
|
+
if (this._shortcutManager.matchKeyDown(e)) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
|
|
96
130
|
// Fit after layout settles
|
|
97
131
|
requestAnimationFrame(() => {
|
|
98
132
|
try { fitAddon.fit(); term.scrollToBottom(); } catch { /* ignore if not visible */ }
|
|
@@ -111,7 +145,7 @@ export class TerminalManager {
|
|
|
111
145
|
// Wire xterm -> server (muted during buffer replay to suppress escape sequence responses)
|
|
112
146
|
let muted = false;
|
|
113
147
|
term.onData((data) => {
|
|
114
|
-
if (!muted) this.wsClient.sendData(id, data);
|
|
148
|
+
if (!muted) {this.wsClient.sendData(id, data);}
|
|
115
149
|
});
|
|
116
150
|
|
|
117
151
|
term.onResize(({ cols, rows }) => {
|
|
@@ -139,18 +173,22 @@ export class TerminalManager {
|
|
|
139
173
|
});
|
|
140
174
|
|
|
141
175
|
// Track active terminal on focus and clear activity
|
|
142
|
-
pane.addEventListener('mousedown', () => { this.
|
|
143
|
-
term.textarea?.addEventListener('focus', () => { this.
|
|
176
|
+
pane.addEventListener('mousedown', () => { this.setActiveTerminal(id); this.clearActivity(id); });
|
|
177
|
+
term.textarea?.addEventListener('focus', () => { this.setActiveTerminal(id); this.clearActivity(id); });
|
|
144
178
|
|
|
145
179
|
const setMuted = (v) => { muted = v; };
|
|
146
180
|
this.terminals.set(id, { term, fitAddon, element: pane, resizeObserver, setMuted });
|
|
147
181
|
this._notifyChange({ type: 'add', id });
|
|
148
|
-
this.
|
|
182
|
+
this.setActiveTerminal(id);
|
|
149
183
|
term.focus();
|
|
150
184
|
|
|
151
185
|
return { term, fitAddon, element: pane, resizeObserver, setMuted };
|
|
152
186
|
}
|
|
153
187
|
|
|
188
|
+
setShortcutManager(sm) {
|
|
189
|
+
this._shortcutManager = sm;
|
|
190
|
+
}
|
|
191
|
+
|
|
154
192
|
createTerminal() {
|
|
155
193
|
const id = `term-${++this.counter}`;
|
|
156
194
|
const { fitAddon } = this._buildTerminal(id);
|
|
@@ -165,7 +203,7 @@ export class TerminalManager {
|
|
|
165
203
|
restoreTerminal(id) {
|
|
166
204
|
// Update counter to avoid future ID collisions
|
|
167
205
|
const num = parseInt(id.replace('term-', ''), 10);
|
|
168
|
-
if (num > this.counter) this.counter = num;
|
|
206
|
+
if (num > this.counter) {this.counter = num;}
|
|
169
207
|
|
|
170
208
|
const { fitAddon, setMuted } = this._buildTerminal(id);
|
|
171
209
|
|
|
@@ -183,7 +221,7 @@ export class TerminalManager {
|
|
|
183
221
|
|
|
184
222
|
unmuteTerminal(id) {
|
|
185
223
|
const entry = this.terminals.get(id);
|
|
186
|
-
if (entry) entry.setMuted(false);
|
|
224
|
+
if (entry) {entry.setMuted(false);}
|
|
187
225
|
}
|
|
188
226
|
|
|
189
227
|
unmuteAll() {
|
|
@@ -201,11 +239,15 @@ export class TerminalManager {
|
|
|
201
239
|
}
|
|
202
240
|
this.terminals.clear();
|
|
203
241
|
this.counter = 0;
|
|
242
|
+
this.activeId = null;
|
|
204
243
|
}
|
|
205
244
|
|
|
206
245
|
closeTerminal(id) {
|
|
207
246
|
const entry = this.terminals.get(id);
|
|
208
|
-
if (!entry) return;
|
|
247
|
+
if (!entry) {return;}
|
|
248
|
+
|
|
249
|
+
const idsBefore = this.getIds();
|
|
250
|
+
const idx = idsBefore.indexOf(id);
|
|
209
251
|
|
|
210
252
|
// If closing a maximized terminal, restore others first
|
|
211
253
|
if (entry.element.classList.contains('maximized')) {
|
|
@@ -221,12 +263,51 @@ export class TerminalManager {
|
|
|
221
263
|
entry.element.remove();
|
|
222
264
|
this.terminals.delete(id);
|
|
223
265
|
|
|
266
|
+
if (this.activeId === id) {
|
|
267
|
+
let nextId = null;
|
|
268
|
+
if (idx >= 0) {
|
|
269
|
+
if (idx < idsBefore.length - 1) {
|
|
270
|
+
nextId = idsBefore[idx + 1];
|
|
271
|
+
} else if (idx > 0) {
|
|
272
|
+
nextId = idsBefore[idx - 1];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
this.activeId = nextId;
|
|
276
|
+
}
|
|
277
|
+
this._syncPaneFocus();
|
|
278
|
+
|
|
279
|
+
const focusId = this.activeId;
|
|
280
|
+
if (focusId) {
|
|
281
|
+
requestAnimationFrame(() => {
|
|
282
|
+
if (this.terminals.has(focusId)) {
|
|
283
|
+
this.focusTerminal(focusId);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
224
288
|
this._notifyChange({ type: 'remove', id });
|
|
225
289
|
}
|
|
226
290
|
|
|
291
|
+
/** Updates active terminal state and pane-focused styling (call before focus when switching programmatically). */
|
|
292
|
+
setActiveTerminal(id) {
|
|
293
|
+
if (!this.terminals.has(id)) {return;}
|
|
294
|
+
this.activeId = id;
|
|
295
|
+
this._syncPaneFocus();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_syncPaneFocus() {
|
|
299
|
+
const active = this.activeId;
|
|
300
|
+
for (const [tid, entry] of this.terminals) {
|
|
301
|
+
entry.element.classList.toggle('pane-focused', tid === active);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
227
305
|
focusTerminal(id) {
|
|
228
306
|
const entry = this.terminals.get(id);
|
|
229
|
-
if (entry)
|
|
307
|
+
if (entry) {
|
|
308
|
+
this.setActiveTerminal(id);
|
|
309
|
+
entry.term.focus();
|
|
310
|
+
}
|
|
230
311
|
}
|
|
231
312
|
|
|
232
313
|
fitAll() {
|
|
@@ -248,7 +329,7 @@ export class TerminalManager {
|
|
|
248
329
|
|
|
249
330
|
toggleMaximize(id) {
|
|
250
331
|
const entry = this.terminals.get(id);
|
|
251
|
-
if (!entry) return;
|
|
332
|
+
if (!entry) {return;}
|
|
252
333
|
|
|
253
334
|
const pane = entry.element;
|
|
254
335
|
const isMaximized = pane.classList.contains('maximized');
|
|
@@ -276,7 +357,7 @@ export class TerminalManager {
|
|
|
276
357
|
|
|
277
358
|
getMaximizedId() {
|
|
278
359
|
for (const [id, entry] of this.terminals) {
|
|
279
|
-
if (entry.element.classList.contains('maximized')) return id;
|
|
360
|
+
if (entry.element.classList.contains('maximized')) {return id;}
|
|
280
361
|
}
|
|
281
362
|
return null;
|
|
282
363
|
}
|
|
@@ -289,44 +370,60 @@ export class TerminalManager {
|
|
|
289
370
|
return [...this.terminals.keys()];
|
|
290
371
|
}
|
|
291
372
|
|
|
373
|
+
updateI18n(i18n, cmdKey) {
|
|
374
|
+
this.i18n = i18n;
|
|
375
|
+
if (cmdKey) this.cmdKey = cmdKey;
|
|
376
|
+
for (const [id, entry] of this.terminals) {
|
|
377
|
+
const pane = entry.element;
|
|
378
|
+
const maximizeBtn = pane.querySelector('.pane-maximize');
|
|
379
|
+
const closeBtn = pane.querySelector('.pane-close');
|
|
380
|
+
if (maximizeBtn) maximizeBtn.title = i18n.t('maximizeTerminal', this.cmdKey);
|
|
381
|
+
if (closeBtn) closeBtn.title = i18n.t('closeTerminalBtn', this.cmdKey);
|
|
382
|
+
// Only update title if it hasn't been overridden by the shell (OSC)
|
|
383
|
+
const titleEl = pane.querySelector('.pane-title');
|
|
384
|
+
if (titleEl && !titleEl.title) {
|
|
385
|
+
const num = id.replace('term-', '');
|
|
386
|
+
titleEl.textContent = i18n.t('terminalTitle', num);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
292
391
|
getTitle(id) {
|
|
293
392
|
const entry = this.terminals.get(id);
|
|
294
393
|
if (entry) {
|
|
295
394
|
const paneTitle = entry.element.querySelector('.pane-title');
|
|
296
|
-
if (paneTitle) return paneTitle.textContent;
|
|
395
|
+
if (paneTitle) {return paneTitle.textContent;}
|
|
297
396
|
}
|
|
298
397
|
const idx = id.replace('term-', '');
|
|
299
|
-
return `Terminal ${idx}`;
|
|
398
|
+
return this.i18n ? this.i18n.t('terminalTitle', idx) : `Terminal ${idx}`;
|
|
300
399
|
}
|
|
301
400
|
|
|
302
401
|
focusNext() {
|
|
303
402
|
const ids = this.getIds();
|
|
304
|
-
if (ids.length <= 1) return;
|
|
403
|
+
if (ids.length <= 1) {return;}
|
|
305
404
|
const idx = ids.indexOf(this.activeId);
|
|
306
405
|
const nextId = ids[(idx + 1) % ids.length];
|
|
307
|
-
this.activeId = nextId;
|
|
308
406
|
this.focusTerminal(nextId);
|
|
309
407
|
return nextId;
|
|
310
408
|
}
|
|
311
409
|
|
|
312
410
|
focusPrev() {
|
|
313
411
|
const ids = this.getIds();
|
|
314
|
-
if (ids.length <= 1) return;
|
|
412
|
+
if (ids.length <= 1) {return;}
|
|
315
413
|
const idx = ids.indexOf(this.activeId);
|
|
316
414
|
const prevId = ids[(idx - 1 + ids.length) % ids.length];
|
|
317
|
-
this.activeId = prevId;
|
|
318
415
|
this.focusTerminal(prevId);
|
|
319
416
|
return prevId;
|
|
320
417
|
}
|
|
321
418
|
|
|
322
419
|
closeActiveTerminal() {
|
|
323
420
|
const id = this.activeId || this.getIds()[0];
|
|
324
|
-
if (id) this.closeTerminal(id);
|
|
421
|
+
if (id) {this.closeTerminal(id);}
|
|
325
422
|
}
|
|
326
423
|
|
|
327
424
|
toggleMaximizeActive() {
|
|
328
425
|
const id = this.activeId || this.getIds()[0];
|
|
329
|
-
if (id) this.toggleMaximize(id);
|
|
426
|
+
if (id) {this.toggleMaximize(id);}
|
|
330
427
|
}
|
|
331
428
|
|
|
332
429
|
onChange(callback) {
|
|
@@ -344,10 +441,36 @@ export class TerminalManager {
|
|
|
344
441
|
}
|
|
345
442
|
|
|
346
443
|
_notifyChange(event) {
|
|
347
|
-
for (const cb of this.changeCallbacks) cb(event);
|
|
444
|
+
for (const cb of this.changeCallbacks) {cb(event);}
|
|
348
445
|
}
|
|
349
446
|
|
|
350
447
|
_notifyActivity(id, cleared = false) {
|
|
351
|
-
for (const cb of this.activityCallbacks) cb(id, cleared);
|
|
448
|
+
for (const cb of this.activityCallbacks) {cb(id, cleared);}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
swapTerminals(id1, id2) {
|
|
452
|
+
const entry1 = this.terminals.get(id1);
|
|
453
|
+
const entry2 = this.terminals.get(id2);
|
|
454
|
+
if (!entry1 || !entry2) {return;}
|
|
455
|
+
|
|
456
|
+
const el1 = entry1.element;
|
|
457
|
+
const el2 = entry2.element;
|
|
458
|
+
|
|
459
|
+
// Swap DOM positions using a placeholder
|
|
460
|
+
const placeholder = document.createComment('swap');
|
|
461
|
+
el1.replaceWith(placeholder);
|
|
462
|
+
el2.replaceWith(el1);
|
|
463
|
+
placeholder.replaceWith(el2);
|
|
464
|
+
|
|
465
|
+
// Swap Map entries to keep iteration order consistent
|
|
466
|
+
const keys = [...this.terminals.keys()];
|
|
467
|
+
const idx1 = keys.indexOf(id1);
|
|
468
|
+
const idx2 = keys.indexOf(id2);
|
|
469
|
+
const entries = [...this.terminals.entries()];
|
|
470
|
+
[entries[idx1], entries[idx2]] = [entries[idx2], entries[idx1]];
|
|
471
|
+
this.terminals = new Map(entries);
|
|
472
|
+
|
|
473
|
+
this._notifyChange({ type: 'swap', id1, id2 });
|
|
474
|
+
requestAnimationFrame(() => this.fitAll());
|
|
352
475
|
}
|
|
353
476
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
const STORAGE_KEY = '
|
|
1
|
+
const STORAGE_KEY = 'mwt-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
|
}
|