@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.
@@ -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
+ }