@jacksontian/mwt 1.0.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/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/mwt.js +33 -0
- package/lib/ring-buffer.js +53 -0
- package/lib/server.js +212 -0
- package/package.json +41 -0
- package/public/css/style.css +579 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +56 -0
- package/public/js/app.js +278 -0
- package/public/js/layout-manager.js +160 -0
- package/public/js/terminal-manager.js +353 -0
- package/public/js/theme-manager.js +96 -0
- package/public/js/ws-client.js +136 -0
- package/public/vendor/xterm/addon-fit.mjs +18 -0
- package/public/vendor/xterm/addon-web-links.mjs +18 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
package/public/js/app.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { WSClient } from './ws-client.js';
|
|
2
|
+
import { TerminalManager } from './terminal-manager.js';
|
|
3
|
+
import { LayoutManager } from './layout-manager.js';
|
|
4
|
+
import { ThemeManager } from './theme-manager.js';
|
|
5
|
+
const wsClient = new WSClient();
|
|
6
|
+
const container = document.getElementById('terminal-container');
|
|
7
|
+
const tabBar = document.getElementById('tab-bar');
|
|
8
|
+
const terminalCount = document.getElementById('terminal-count');
|
|
9
|
+
|
|
10
|
+
const themeManager = new ThemeManager();
|
|
11
|
+
const layoutManager = new LayoutManager(container, tabBar);
|
|
12
|
+
const terminalManager = new TerminalManager(wsClient, container, themeManager);
|
|
13
|
+
|
|
14
|
+
// Theme toggle
|
|
15
|
+
document.getElementById('btn-theme-toggle').addEventListener('click', () => {
|
|
16
|
+
themeManager.toggle();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Cross-wire layout manager and terminal manager
|
|
20
|
+
layoutManager.fitAll = () => terminalManager.fitAll();
|
|
21
|
+
layoutManager.closeTerminal = (id) => terminalManager.closeTerminal(id);
|
|
22
|
+
|
|
23
|
+
// Activity notification: visual indicators + browser notifications + title flash
|
|
24
|
+
const originalTitle = document.title;
|
|
25
|
+
let titleFlashTimer = null;
|
|
26
|
+
|
|
27
|
+
function requestNotificationPermission() {
|
|
28
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
29
|
+
Notification.requestPermission();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const notificationCooldown = new Map(); // id -> timestamp
|
|
34
|
+
const NOTIFICATION_DEBOUNCE_MS = 2000;
|
|
35
|
+
|
|
36
|
+
terminalManager.onActivity((id, cleared) => {
|
|
37
|
+
if (cleared) {
|
|
38
|
+
layoutManager.clearActivity(id);
|
|
39
|
+
// If no more unread activity, stop title flash
|
|
40
|
+
if (terminalManager.activitySet.size === 0) {
|
|
41
|
+
clearInterval(titleFlashTimer);
|
|
42
|
+
titleFlashTimer = null;
|
|
43
|
+
document.title = originalTitle;
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
layoutManager.markActivity(id);
|
|
49
|
+
|
|
50
|
+
// Browser notification when page is hidden
|
|
51
|
+
if (document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const lastNotify = notificationCooldown.get(id) || 0;
|
|
54
|
+
if (now - lastNotify > NOTIFICATION_DEBOUNCE_MS) {
|
|
55
|
+
notificationCooldown.set(id, now);
|
|
56
|
+
const title = terminalManager.getTitle(id);
|
|
57
|
+
const n = new Notification(`${title} has new output`, {
|
|
58
|
+
body: 'Click to switch to this terminal',
|
|
59
|
+
tag: `mwt-${id}`,
|
|
60
|
+
});
|
|
61
|
+
n.onclick = () => {
|
|
62
|
+
window.focus();
|
|
63
|
+
terminalManager.activeId = id;
|
|
64
|
+
terminalManager.focusTerminal(id);
|
|
65
|
+
if (layoutManager.currentLayout === 'tabs') {
|
|
66
|
+
layoutManager.activateTab(id);
|
|
67
|
+
}
|
|
68
|
+
n.close();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Title flash when page is hidden
|
|
74
|
+
if (document.hidden && !titleFlashTimer) {
|
|
75
|
+
let flash = true;
|
|
76
|
+
titleFlashTimer = setInterval(() => {
|
|
77
|
+
document.title = flash ? `[*] ${originalTitle}` : originalTitle;
|
|
78
|
+
flash = !flash;
|
|
79
|
+
}, 1000);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Stop title flash when page becomes visible
|
|
84
|
+
document.addEventListener('visibilitychange', () => {
|
|
85
|
+
if (!document.hidden) {
|
|
86
|
+
clearInterval(titleFlashTimer);
|
|
87
|
+
titleFlashTimer = null;
|
|
88
|
+
document.title = originalTitle;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Request notification permission on first user interaction
|
|
93
|
+
document.addEventListener('click', requestNotificationPermission, { once: true });
|
|
94
|
+
|
|
95
|
+
function updateCount() {
|
|
96
|
+
const count = terminalManager.getCount();
|
|
97
|
+
terminalCount.textContent = `${count} terminal${count !== 1 ? 's' : ''}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
terminalManager.onChange((event) => {
|
|
101
|
+
if (event.type === 'add') {
|
|
102
|
+
layoutManager.onTerminalAdded(event.id);
|
|
103
|
+
} else if (event.type === 'remove') {
|
|
104
|
+
layoutManager.onTerminalRemoved(event.id);
|
|
105
|
+
} else if (event.type === 'title') {
|
|
106
|
+
layoutManager.updateTabTitle(event.id, event.title);
|
|
107
|
+
}
|
|
108
|
+
updateCount();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// New Terminal button
|
|
112
|
+
document.getElementById('btn-new-terminal').addEventListener('click', () => {
|
|
113
|
+
terminalManager.createTerminal();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Layout persistence
|
|
117
|
+
const LAYOUT_KEY = 'myterminal-layout';
|
|
118
|
+
const ACTIVE_TAB_KEY = 'myterminal-active-tab';
|
|
119
|
+
|
|
120
|
+
function saveLayoutState() {
|
|
121
|
+
localStorage.setItem(LAYOUT_KEY, layoutManager.currentLayout);
|
|
122
|
+
if (layoutManager.activeTabId) {
|
|
123
|
+
localStorage.setItem(ACTIVE_TAB_KEY, layoutManager.activeTabId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Layout switcher
|
|
128
|
+
document.querySelectorAll('.layout-btn').forEach(btn => {
|
|
129
|
+
btn.addEventListener('click', () => {
|
|
130
|
+
document.querySelectorAll('.layout-btn').forEach(b => b.classList.remove('active'));
|
|
131
|
+
btn.classList.add('active');
|
|
132
|
+
layoutManager.setLayout(btn.dataset.layout);
|
|
133
|
+
saveLayoutState();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Fullscreen toggle
|
|
138
|
+
document.getElementById('btn-fullscreen').addEventListener('click', () => {
|
|
139
|
+
if (!document.fullscreenElement) {
|
|
140
|
+
document.documentElement.requestFullscreen();
|
|
141
|
+
} else {
|
|
142
|
+
document.exitFullscreen();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
document.addEventListener('fullscreenchange', () => {
|
|
147
|
+
setTimeout(() => terminalManager.fitAll(), 100);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Keyboard shortcuts
|
|
151
|
+
document.addEventListener('keydown', (e) => {
|
|
152
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
153
|
+
const shift = e.shiftKey;
|
|
154
|
+
const alt = e.altKey;
|
|
155
|
+
|
|
156
|
+
// Ctrl+Shift+T: new terminal
|
|
157
|
+
if (ctrl && shift && e.key === 'T') {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
terminalManager.createTerminal();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Ctrl+Shift+W: close active terminal
|
|
164
|
+
if (ctrl && shift && e.key === 'W') {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
terminalManager.closeActiveTerminal();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Ctrl+Shift+]: next terminal
|
|
171
|
+
if (ctrl && shift && e.key === '}') {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const nextId = terminalManager.focusNext();
|
|
174
|
+
if (nextId) {
|
|
175
|
+
terminalManager.clearActivity(nextId);
|
|
176
|
+
if (layoutManager.currentLayout === 'tabs') {
|
|
177
|
+
layoutManager.activateTab(nextId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Ctrl+Shift+[: previous terminal
|
|
184
|
+
if (ctrl && shift && e.key === '{') {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
const prevId = terminalManager.focusPrev();
|
|
187
|
+
if (prevId) {
|
|
188
|
+
terminalManager.clearActivity(prevId);
|
|
189
|
+
if (layoutManager.currentLayout === 'tabs') {
|
|
190
|
+
layoutManager.activateTab(prevId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Alt+1~9: switch to terminal N
|
|
197
|
+
if (alt && e.key >= '1' && e.key <= '9') {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
const ids = terminalManager.getIds();
|
|
200
|
+
const idx = parseInt(e.key, 10) - 1;
|
|
201
|
+
if (idx < ids.length) {
|
|
202
|
+
const targetId = ids[idx];
|
|
203
|
+
terminalManager.activeId = targetId;
|
|
204
|
+
terminalManager.focusTerminal(targetId);
|
|
205
|
+
terminalManager.clearActivity(targetId);
|
|
206
|
+
if (layoutManager.currentLayout === 'tabs') {
|
|
207
|
+
layoutManager.activateTab(targetId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Ctrl+Shift+M: maximize/restore active terminal
|
|
214
|
+
if (ctrl && shift && e.key === 'M') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
terminalManager.toggleMaximizeActive();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// F11: toggle fullscreen
|
|
221
|
+
if (e.key === 'F11') {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
if (!document.fullscreenElement) {
|
|
224
|
+
document.documentElement.requestFullscreen();
|
|
225
|
+
} else {
|
|
226
|
+
document.exitFullscreen();
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Prevent browser back navigation to avoid losing terminal sessions
|
|
233
|
+
history.pushState(null, '', location.href);
|
|
234
|
+
window.addEventListener('popstate', () => {
|
|
235
|
+
history.pushState(null, '', location.href);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Window resize -> refit all
|
|
239
|
+
window.addEventListener('resize', () => {
|
|
240
|
+
terminalManager.fitAll();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Session restore handler
|
|
244
|
+
wsClient.onSessionRestore((terminalIds) => {
|
|
245
|
+
// Clear any existing client-side terminals (e.g., on re-reconnect)
|
|
246
|
+
terminalManager.clearAll();
|
|
247
|
+
|
|
248
|
+
if (terminalIds.length === 0) {
|
|
249
|
+
// New session - create first terminal
|
|
250
|
+
terminalManager.createTerminal();
|
|
251
|
+
} else {
|
|
252
|
+
// Restore existing terminals
|
|
253
|
+
for (const id of terminalIds) {
|
|
254
|
+
terminalManager.restoreTerminal(id);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Restore layout from localStorage
|
|
259
|
+
const savedLayout = localStorage.getItem(LAYOUT_KEY);
|
|
260
|
+
if (savedLayout) {
|
|
261
|
+
document.querySelectorAll('.layout-btn').forEach(b => {
|
|
262
|
+
b.classList.toggle('active', b.dataset.layout === savedLayout);
|
|
263
|
+
});
|
|
264
|
+
layoutManager.setLayout(savedLayout);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const savedActiveTab = localStorage.getItem(ACTIVE_TAB_KEY);
|
|
268
|
+
if (savedActiveTab && layoutManager.currentLayout === 'tabs') {
|
|
269
|
+
layoutManager.activateTab(savedActiveTab);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
updateCount();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Unmute terminals after buffer replay completes
|
|
276
|
+
wsClient.onRestoreComplete(() => {
|
|
277
|
+
terminalManager.unmuteAll();
|
|
278
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export class LayoutManager {
|
|
2
|
+
constructor(container, tabBar) {
|
|
3
|
+
this.container = container;
|
|
4
|
+
this.tabBar = tabBar;
|
|
5
|
+
this.currentLayout = 'side-by-side';
|
|
6
|
+
this.activeTabId = null;
|
|
7
|
+
// These will be set by app.js after construction
|
|
8
|
+
this.fitAll = () => {};
|
|
9
|
+
this.closeTerminal = () => {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setLayout(mode) {
|
|
13
|
+
// Remove all layout classes
|
|
14
|
+
this.container.classList.remove('layout-side-by-side', 'layout-grid', 'layout-tabs');
|
|
15
|
+
this.container.classList.add(`layout-${mode}`);
|
|
16
|
+
this.currentLayout = mode;
|
|
17
|
+
|
|
18
|
+
if (mode === 'tabs') {
|
|
19
|
+
this.tabBar.classList.remove('hidden');
|
|
20
|
+
this._rebuildTabBar();
|
|
21
|
+
// Activate the current tab, or the first one
|
|
22
|
+
const panes = this.container.querySelectorAll('.terminal-pane');
|
|
23
|
+
if (panes.length > 0) {
|
|
24
|
+
const targetId = this.activeTabId && this.container.querySelector(`.terminal-pane[data-id="${this.activeTabId}"]`)
|
|
25
|
+
? this.activeTabId
|
|
26
|
+
: panes[0].dataset.id;
|
|
27
|
+
this.activateTab(targetId);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
this.tabBar.classList.add('hidden');
|
|
31
|
+
// Make all panes visible (remove active class used by tabs)
|
|
32
|
+
this.container.querySelectorAll('.terminal-pane').forEach(p => {
|
|
33
|
+
p.classList.remove('active');
|
|
34
|
+
});
|
|
35
|
+
if (mode === 'grid') {
|
|
36
|
+
this._updateGridColumns();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Refit after layout change
|
|
41
|
+
requestAnimationFrame(() => this.fitAll());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onTerminalAdded(id) {
|
|
45
|
+
if (this.currentLayout === 'tabs') {
|
|
46
|
+
this._addTab(id);
|
|
47
|
+
this.activateTab(id);
|
|
48
|
+
} else if (this.currentLayout === 'grid') {
|
|
49
|
+
this._updateGridColumns();
|
|
50
|
+
}
|
|
51
|
+
requestAnimationFrame(() => this.fitAll());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onTerminalRemoved(id) {
|
|
55
|
+
if (this.currentLayout === 'tabs') {
|
|
56
|
+
this._removeTab(id);
|
|
57
|
+
if (this.activeTabId === id) {
|
|
58
|
+
// Activate another tab
|
|
59
|
+
const firstTab = this.tabBar.querySelector('.tab');
|
|
60
|
+
if (firstTab) {
|
|
61
|
+
this.activateTab(firstTab.dataset.id);
|
|
62
|
+
} else {
|
|
63
|
+
this.activeTabId = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} else if (this.currentLayout === 'grid') {
|
|
67
|
+
this._updateGridColumns();
|
|
68
|
+
}
|
|
69
|
+
requestAnimationFrame(() => this.fitAll());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
activateTab(id) {
|
|
73
|
+
this.activeTabId = id;
|
|
74
|
+
|
|
75
|
+
// Update tab bar
|
|
76
|
+
this.tabBar.querySelectorAll('.tab').forEach(tab => {
|
|
77
|
+
tab.classList.toggle('active', tab.dataset.id === id);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Update panes
|
|
81
|
+
this.container.querySelectorAll('.terminal-pane').forEach(pane => {
|
|
82
|
+
pane.classList.toggle('active', pane.dataset.id === id);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Clear activity indicator for the activated tab
|
|
86
|
+
this.clearActivity(id);
|
|
87
|
+
|
|
88
|
+
requestAnimationFrame(() => this.fitAll());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
markActivity(id) {
|
|
92
|
+
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
93
|
+
if (tab) tab.classList.add('has-activity');
|
|
94
|
+
const pane = this.container.querySelector(`.terminal-pane[data-id="${id}"]`);
|
|
95
|
+
if (pane) pane.classList.add('has-activity');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clearActivity(id) {
|
|
99
|
+
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
100
|
+
if (tab) tab.classList.remove('has-activity');
|
|
101
|
+
const pane = this.container.querySelector(`.terminal-pane[data-id="${id}"]`);
|
|
102
|
+
if (pane) pane.classList.remove('has-activity');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_rebuildTabBar() {
|
|
106
|
+
this.tabBar.innerHTML = '';
|
|
107
|
+
const panes = this.container.querySelectorAll('.terminal-pane');
|
|
108
|
+
panes.forEach(pane => {
|
|
109
|
+
const paneTitle = pane.querySelector('.pane-title')?.textContent;
|
|
110
|
+
this._addTab(pane.dataset.id, paneTitle);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_addTab(id, label) {
|
|
115
|
+
const tab = document.createElement('div');
|
|
116
|
+
tab.className = 'tab';
|
|
117
|
+
tab.dataset.id = id;
|
|
118
|
+
|
|
119
|
+
const title = document.createElement('span');
|
|
120
|
+
title.className = 'tab-title';
|
|
121
|
+
const idx = id.replace('term-', '');
|
|
122
|
+
title.textContent = label || `Terminal ${idx}`;
|
|
123
|
+
|
|
124
|
+
const closeBtn = document.createElement('span');
|
|
125
|
+
closeBtn.className = 'tab-close';
|
|
126
|
+
closeBtn.textContent = '\u00d7';
|
|
127
|
+
closeBtn.addEventListener('click', (e) => {
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
this.closeTerminal(id);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
tab.appendChild(title);
|
|
133
|
+
tab.appendChild(closeBtn);
|
|
134
|
+
|
|
135
|
+
tab.addEventListener('click', () => {
|
|
136
|
+
this.activateTab(id);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.tabBar.appendChild(tab);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
updateTabTitle(id, title) {
|
|
143
|
+
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"] .tab-title`);
|
|
144
|
+
if (tab) {
|
|
145
|
+
tab.textContent = title;
|
|
146
|
+
tab.title = title;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_removeTab(id) {
|
|
151
|
+
const tab = this.tabBar.querySelector(`.tab[data-id="${id}"]`);
|
|
152
|
+
if (tab) tab.remove();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_updateGridColumns() {
|
|
156
|
+
const count = this.container.querySelectorAll('.terminal-pane').length;
|
|
157
|
+
const cols = count <= 1 ? 1 : count <= 4 ? 2 : count <= 9 ? 3 : 4;
|
|
158
|
+
this.container.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
159
|
+
}
|
|
160
|
+
}
|