@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.
@@ -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 = 'side-by-side';
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-side-by-side', 'layout-grid', 'layout-tabs');
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
- 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
- });
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() {
@@ -1,4 +1,4 @@
1
- const STORAGE_KEY = 'myterminal-theme';
1
+ const STORAGE_KEY = 'mwt-theme';
2
2
 
3
3
  const DARK_TERMINAL_THEME = {
4
4
  background: '#0a0c12',
@@ -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 = 'myterminal-session-id';
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
  }