@jacksontian/mwt 1.1.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/server.js +66 -80
- package/lib/session-manager.js +66 -0
- package/package.json +2 -2
- package/public/css/style.css +121 -16
- package/public/index.html +42 -32
- package/public/js/app.js +117 -102
- package/public/js/drag-manager.js +42 -0
- package/public/js/font-manager.js +3 -3
- package/public/js/i18n.js +176 -0
- package/public/js/layout-manager.js +5 -2
- package/public/js/shortcut-manager.js +102 -0
- package/public/js/terminal-manager.js +45 -34
- package/public/js/theme-manager.js +1 -1
- package/public/js/ws-client.js +23 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const LANG_KEY = 'mwt-lang';
|
|
2
|
+
|
|
3
|
+
// Display labels shown in the language menu
|
|
4
|
+
export const langLabels = {
|
|
5
|
+
en: 'English',
|
|
6
|
+
zh: '中文',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const messages = {
|
|
10
|
+
en: {
|
|
11
|
+
newTerminal: 'New Terminal',
|
|
12
|
+
columns: 'Columns',
|
|
13
|
+
rows: 'Rows',
|
|
14
|
+
grid: 'Grid',
|
|
15
|
+
tabs: 'Tabs',
|
|
16
|
+
keyboardShortcuts: 'Keyboard Shortcuts',
|
|
17
|
+
toggleFullscreen: 'Toggle fullscreen',
|
|
18
|
+
toggleTheme: 'Toggle theme',
|
|
19
|
+
settings: 'Settings',
|
|
20
|
+
|
|
21
|
+
// Settings modal
|
|
22
|
+
settingsTitle: 'Settings',
|
|
23
|
+
settingsFont: 'Font',
|
|
24
|
+
settingsFontSize: 'Size',
|
|
25
|
+
settingsFontFamily: 'Family',
|
|
26
|
+
settingsFontFamilyPlaceholder: 'e.g. Menlo, monospace',
|
|
27
|
+
|
|
28
|
+
// Shortcuts modal
|
|
29
|
+
shortcutsTitle: 'Keyboard Shortcuts',
|
|
30
|
+
shortcutsGroupTerminals: 'Terminals',
|
|
31
|
+
shortcutsNewTerminal: 'New Terminal',
|
|
32
|
+
shortcutsCloseTerminal: 'Close Terminal',
|
|
33
|
+
shortcutsMaximizeTerminal: 'Maximize / Restore Terminal',
|
|
34
|
+
shortcutsGroupNavigation: 'Navigation',
|
|
35
|
+
shortcutsNextTerminal: 'Next Terminal',
|
|
36
|
+
shortcutsPrevTerminal: 'Previous Terminal',
|
|
37
|
+
shortcutsSwitchTerminal: 'Switch to Terminal 1–9',
|
|
38
|
+
shortcutsGroupWindow: 'Window',
|
|
39
|
+
shortcutsToggleFullscreen: 'Toggle Fullscreen',
|
|
40
|
+
|
|
41
|
+
// Terminal pane
|
|
42
|
+
terminalTitle: (num) => `Terminal ${num}`,
|
|
43
|
+
maximizeTerminal: (cmdKey) => `Maximize terminal (${cmdKey}+Shift+M)`,
|
|
44
|
+
closeTerminalBtn: (cmdKey) => `Close terminal (${cmdKey}+Shift+\`)`,
|
|
45
|
+
|
|
46
|
+
// Button tooltips with shortcut
|
|
47
|
+
newTerminalTooltip: (cmdKey) => `New Terminal (${cmdKey}+\`)`,
|
|
48
|
+
fullscreenTooltip: 'Toggle fullscreen (F11)',
|
|
49
|
+
shortcutsTooltip: 'Keyboard Shortcuts',
|
|
50
|
+
|
|
51
|
+
// Notifications
|
|
52
|
+
terminalHasNewOutput: (title) => `${title} has new output`,
|
|
53
|
+
notificationBody: 'Click to switch to this terminal',
|
|
54
|
+
|
|
55
|
+
// Overlays
|
|
56
|
+
alreadyConnected: 'mwt is already open in another tab.',
|
|
57
|
+
serverDisconnected: 'Server disconnected. Please restart the server and refresh.',
|
|
58
|
+
},
|
|
59
|
+
zh: {
|
|
60
|
+
newTerminal: '新建终端',
|
|
61
|
+
columns: '并排',
|
|
62
|
+
rows: '横排',
|
|
63
|
+
grid: '网格',
|
|
64
|
+
tabs: '标签页',
|
|
65
|
+
keyboardShortcuts: '键盘快捷键',
|
|
66
|
+
toggleFullscreen: '切换全屏',
|
|
67
|
+
toggleTheme: '切换主题',
|
|
68
|
+
settings: '设置',
|
|
69
|
+
|
|
70
|
+
// Settings modal
|
|
71
|
+
settingsTitle: '设置',
|
|
72
|
+
settingsFont: '字体',
|
|
73
|
+
settingsFontSize: '大小',
|
|
74
|
+
settingsFontFamily: '字体族',
|
|
75
|
+
settingsFontFamilyPlaceholder: '例如 Menlo, monospace',
|
|
76
|
+
|
|
77
|
+
// Shortcuts modal
|
|
78
|
+
shortcutsTitle: '键盘快捷键',
|
|
79
|
+
shortcutsGroupTerminals: '终端',
|
|
80
|
+
shortcutsNewTerminal: '新建终端',
|
|
81
|
+
shortcutsCloseTerminal: '关闭终端',
|
|
82
|
+
shortcutsMaximizeTerminal: '最大化 / 还原终端',
|
|
83
|
+
shortcutsGroupNavigation: '导航',
|
|
84
|
+
shortcutsNextTerminal: '下一个终端',
|
|
85
|
+
shortcutsPrevTerminal: '上一个终端',
|
|
86
|
+
shortcutsSwitchTerminal: '切换到终端 1–9',
|
|
87
|
+
shortcutsGroupWindow: '窗口',
|
|
88
|
+
shortcutsToggleFullscreen: '切换全屏',
|
|
89
|
+
|
|
90
|
+
// Terminal pane
|
|
91
|
+
terminalTitle: (num) => `终端 ${num}`,
|
|
92
|
+
maximizeTerminal: (cmdKey) => `最大化终端 (${cmdKey}+Shift+M)`,
|
|
93
|
+
closeTerminalBtn: (cmdKey) => `关闭终端 (${cmdKey}+Shift+\`)`,
|
|
94
|
+
|
|
95
|
+
// Button tooltips with shortcut
|
|
96
|
+
newTerminalTooltip: (cmdKey) => `新建终端 (${cmdKey}+\`)`,
|
|
97
|
+
fullscreenTooltip: '切换全屏 (F11)',
|
|
98
|
+
shortcutsTooltip: '键盘快捷键',
|
|
99
|
+
|
|
100
|
+
// Notifications
|
|
101
|
+
terminalHasNewOutput: (title) => `${title} 有新输出`,
|
|
102
|
+
notificationBody: '点击切换到此终端',
|
|
103
|
+
|
|
104
|
+
// Overlays
|
|
105
|
+
alreadyConnected: 'mwt 已在另一个标签页中打开。',
|
|
106
|
+
serverDisconnected: '服务器已断开,请重启服务后刷新页面。',
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
class I18n {
|
|
111
|
+
constructor() {
|
|
112
|
+
const saved = localStorage.getItem(LANG_KEY);
|
|
113
|
+
if (saved && messages[saved]) {
|
|
114
|
+
this.lang = saved;
|
|
115
|
+
} else {
|
|
116
|
+
// Auto-detect from browser language
|
|
117
|
+
const browser = navigator.language || 'en';
|
|
118
|
+
this.lang = browser.startsWith('zh') ? 'zh' : 'en';
|
|
119
|
+
}
|
|
120
|
+
this._callbacks = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
t(key, ...args) {
|
|
124
|
+
const val = messages[this.lang][key];
|
|
125
|
+
if (typeof val === 'function') return val(...args);
|
|
126
|
+
return val ?? key;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setLang(lang) {
|
|
130
|
+
if (!messages[lang] || lang === this.lang) return;
|
|
131
|
+
this.lang = lang;
|
|
132
|
+
localStorage.setItem(LANG_KEY, lang);
|
|
133
|
+
this._applyToDOM();
|
|
134
|
+
for (const cb of this._callbacks) cb(lang);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getLangs() {
|
|
138
|
+
return Object.keys(langLabels);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onChange(cb) {
|
|
142
|
+
this._callbacks.push(cb);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Apply translations to elements with data-i18n attribute
|
|
146
|
+
_applyToDOM() {
|
|
147
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
148
|
+
const key = el.dataset.i18n;
|
|
149
|
+
const val = messages[this.lang][key];
|
|
150
|
+
if (typeof val === 'string') {
|
|
151
|
+
el.textContent = val;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
155
|
+
const key = el.dataset.i18nPlaceholder;
|
|
156
|
+
const val = messages[this.lang][key];
|
|
157
|
+
if (typeof val === 'string') {
|
|
158
|
+
el.placeholder = val;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
|
162
|
+
const key = el.dataset.i18nTitle;
|
|
163
|
+
const val = messages[this.lang][key];
|
|
164
|
+
if (typeof val === 'string') {
|
|
165
|
+
el.title = val;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Call once on page load to initialize DOM text
|
|
171
|
+
applyToDOM() {
|
|
172
|
+
this._applyToDOM();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const i18n = new I18n();
|
|
@@ -2,7 +2,7 @@ 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
7
|
/** @type {((id: string) => void) | null} Called after tab becomes active (focus PTY). */
|
|
8
8
|
this.onTabActivate = null;
|
|
@@ -19,7 +19,7 @@ export class LayoutManager {
|
|
|
19
19
|
this._clearMaximizedState();
|
|
20
20
|
|
|
21
21
|
// Remove all layout classes
|
|
22
|
-
this.container.classList.remove('layout-
|
|
22
|
+
this.container.classList.remove('layout-columns', 'layout-rows', 'layout-grid', 'layout-tabs');
|
|
23
23
|
this.container.classList.add(`layout-${mode}`);
|
|
24
24
|
this.currentLayout = mode;
|
|
25
25
|
|
|
@@ -224,6 +224,7 @@ export class LayoutManager {
|
|
|
224
224
|
onTerminalMaximized() {
|
|
225
225
|
if (this.currentLayout === 'grid') {
|
|
226
226
|
this.container.style.gridTemplateColumns = '1fr';
|
|
227
|
+
this.container.style.gridTemplateRows = '1fr';
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
@@ -236,6 +237,8 @@ export class LayoutManager {
|
|
|
236
237
|
_updateGridColumns() {
|
|
237
238
|
const count = this.container.querySelectorAll('.terminal-pane').length;
|
|
238
239
|
const cols = Math.ceil(Math.sqrt(count));
|
|
240
|
+
const rows = Math.ceil(count / cols);
|
|
239
241
|
this.container.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
242
|
+
this.container.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
|
240
243
|
}
|
|
241
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,22 +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, cmdKey = 'Ctrl', fontManager = null) {
|
|
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;
|
|
12
13
|
this.fontManager = fontManager;
|
|
13
14
|
this.cmdKey = cmdKey;
|
|
15
|
+
this.i18n = i18n;
|
|
14
16
|
this.terminals = new Map(); // id -> { term, fitAddon, element, resizeObserver }
|
|
15
17
|
this.counter = 0;
|
|
16
18
|
this.activeId = null;
|
|
17
19
|
this.changeCallbacks = [];
|
|
18
20
|
this.activityCallbacks = [];
|
|
21
|
+
this._dragManager = new DragManager((id1, id2) => this.swapTerminals(id1, id2));
|
|
19
22
|
this.activitySet = new Set(); // terminal IDs with unread activity
|
|
23
|
+
this._shortcutManager = null;
|
|
20
24
|
|
|
21
25
|
// Update all terminals when theme changes
|
|
22
26
|
if (themeManager) {
|
|
@@ -53,45 +57,17 @@ export class TerminalManager {
|
|
|
53
57
|
|
|
54
58
|
const title = document.createElement('span');
|
|
55
59
|
title.className = 'pane-title';
|
|
56
|
-
title.textContent = `Terminal ${num}`;
|
|
60
|
+
title.textContent = this.i18n ? this.i18n.t('terminalTitle', num) : `Terminal ${num}`;
|
|
57
61
|
|
|
58
62
|
// Drag to swap
|
|
59
|
-
|
|
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
|
-
});
|
|
63
|
+
this._dragManager.attach(pane, header, id);
|
|
88
64
|
|
|
89
65
|
const headerActions = document.createElement('div');
|
|
90
66
|
headerActions.className = 'pane-actions';
|
|
91
67
|
|
|
92
68
|
const maximizeBtn = document.createElement('button');
|
|
93
69
|
maximizeBtn.className = 'pane-maximize';
|
|
94
|
-
maximizeBtn.title = `Maximize terminal (${this.cmdKey}+Shift+M)`;
|
|
70
|
+
maximizeBtn.title = this.i18n ? this.i18n.t('maximizeTerminal', this.cmdKey) : `Maximize terminal (${this.cmdKey}+Shift+M)`;
|
|
95
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>'
|
|
96
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>';
|
|
97
73
|
maximizeBtn.addEventListener('click', (e) => {
|
|
@@ -102,7 +78,7 @@ export class TerminalManager {
|
|
|
102
78
|
const closeBtn = document.createElement('button');
|
|
103
79
|
closeBtn.className = 'pane-close';
|
|
104
80
|
closeBtn.textContent = '\u00d7';
|
|
105
|
-
closeBtn.title = `Close terminal (${this.cmdKey}+Shift+\`)`;
|
|
81
|
+
closeBtn.title = this.i18n ? this.i18n.t('closeTerminalBtn', this.cmdKey) : `Close terminal (${this.cmdKey}+Shift+\`)`;
|
|
106
82
|
closeBtn.addEventListener('click', (e) => {
|
|
107
83
|
e.stopPropagation();
|
|
108
84
|
this.closeTerminal(id);
|
|
@@ -138,6 +114,19 @@ export class TerminalManager {
|
|
|
138
114
|
term.loadAddon(webLinksAddon);
|
|
139
115
|
term.open(body);
|
|
140
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
|
+
|
|
141
130
|
// Fit after layout settles
|
|
142
131
|
requestAnimationFrame(() => {
|
|
143
132
|
try { fitAddon.fit(); term.scrollToBottom(); } catch { /* ignore if not visible */ }
|
|
@@ -196,6 +185,10 @@ export class TerminalManager {
|
|
|
196
185
|
return { term, fitAddon, element: pane, resizeObserver, setMuted };
|
|
197
186
|
}
|
|
198
187
|
|
|
188
|
+
setShortcutManager(sm) {
|
|
189
|
+
this._shortcutManager = sm;
|
|
190
|
+
}
|
|
191
|
+
|
|
199
192
|
createTerminal() {
|
|
200
193
|
const id = `term-${++this.counter}`;
|
|
201
194
|
const { fitAddon } = this._buildTerminal(id);
|
|
@@ -377,6 +370,24 @@ export class TerminalManager {
|
|
|
377
370
|
return [...this.terminals.keys()];
|
|
378
371
|
}
|
|
379
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
|
+
|
|
380
391
|
getTitle(id) {
|
|
381
392
|
const entry = this.terminals.get(id);
|
|
382
393
|
if (entry) {
|
|
@@ -384,7 +395,7 @@ export class TerminalManager {
|
|
|
384
395
|
if (paneTitle) {return paneTitle.textContent;}
|
|
385
396
|
}
|
|
386
397
|
const idx = id.replace('term-', '');
|
|
387
|
-
return `Terminal ${idx}`;
|
|
398
|
+
return this.i18n ? this.i18n.t('terminalTitle', idx) : `Terminal ${idx}`;
|
|
388
399
|
}
|
|
389
400
|
|
|
390
401
|
focusNext() {
|
package/public/js/ws-client.js
CHANGED
|
@@ -4,15 +4,18 @@ export class WSClient {
|
|
|
4
4
|
this.listeners = new Map(); // id -> { onData, onExit }
|
|
5
5
|
this.pendingMessages = [];
|
|
6
6
|
this.reconnectDelay = 1000;
|
|
7
|
+
this.reconnectAttempts = 0;
|
|
7
8
|
this.onReconnectCallbacks = [];
|
|
8
9
|
this.onSessionRestoreCallback = null;
|
|
9
10
|
this.onRestoreCompleteCallback = null;
|
|
11
|
+
this.onAlreadyConnectedCallback = null;
|
|
12
|
+
this.onGiveUpCallback = null;
|
|
10
13
|
this.sessionId = this._getOrCreateSessionId();
|
|
11
14
|
this.connect();
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
_getOrCreateSessionId() {
|
|
15
|
-
const key = '
|
|
18
|
+
const key = 'mwt-session-id';
|
|
16
19
|
let id = localStorage.getItem(key);
|
|
17
20
|
if (!id) {
|
|
18
21
|
id = crypto.randomUUID();
|
|
@@ -27,6 +30,7 @@ export class WSClient {
|
|
|
27
30
|
|
|
28
31
|
this.ws.onopen = () => {
|
|
29
32
|
this.reconnectDelay = 1000;
|
|
33
|
+
this.reconnectAttempts = 0;
|
|
30
34
|
// flush pending messages
|
|
31
35
|
for (const msg of this.pendingMessages) {
|
|
32
36
|
this.ws.send(msg);
|
|
@@ -74,7 +78,16 @@ export class WSClient {
|
|
|
74
78
|
}
|
|
75
79
|
};
|
|
76
80
|
|
|
77
|
-
this.ws.onclose = () => {
|
|
81
|
+
this.ws.onclose = (event) => {
|
|
82
|
+
if (event.code === 4409) {
|
|
83
|
+
if (this.onAlreadyConnectedCallback) {this.onAlreadyConnectedCallback();}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.reconnectAttempts += 1;
|
|
87
|
+
if (this.reconnectAttempts > 5) {
|
|
88
|
+
if (this.onGiveUpCallback) {this.onGiveUpCallback();}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
78
91
|
setTimeout(() => {
|
|
79
92
|
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 10000);
|
|
80
93
|
this.connect();
|
|
@@ -133,4 +146,12 @@ export class WSClient {
|
|
|
133
146
|
onRestoreComplete(callback) {
|
|
134
147
|
this.onRestoreCompleteCallback = callback;
|
|
135
148
|
}
|
|
149
|
+
|
|
150
|
+
onAlreadyConnected(callback) {
|
|
151
|
+
this.onAlreadyConnectedCallback = callback;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onGiveUp(callback) {
|
|
155
|
+
this.onGiveUpCallback = callback;
|
|
156
|
+
}
|
|
136
157
|
}
|