@jacksontian/mwt 1.0.0 → 1.1.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.
@@ -32,7 +32,7 @@ export class RingBuffer {
32
32
  }
33
33
 
34
34
  read() {
35
- if (this.length === 0) return '';
35
+ if (this.length === 0) {return '';}
36
36
 
37
37
  if (this.length < this.capacity) {
38
38
  // Haven't wrapped yet — data starts at 0
package/lib/server.js CHANGED
@@ -28,185 +28,185 @@ const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
28
28
  function openBrowser(url) {
29
29
  const cmd = process.platform === 'darwin' ? 'open'
30
30
  : process.platform === 'win32' ? 'start'
31
- : 'xdg-open';
31
+ : 'xdg-open';
32
32
  exec(`${cmd} ${url}`);
33
33
  }
34
34
 
35
35
  export function start(port = 1987, host = '127.0.0.1', options = {}) {
36
36
 
37
- const startCwd = process.cwd();
38
- const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
39
-
40
- // HTTP server for static files
41
- const server = createServer(async (req, res) => {
42
- let filePath;
43
- const url = req.url.split('?')[0];
44
-
45
- if (url === '/') {
46
- filePath = join(__dirname, 'public', 'index.html');
47
- } else {
48
- filePath = join(__dirname, 'public', url);
49
- }
50
-
51
- try {
52
- const data = await readFile(filePath);
53
- const ext = extname(filePath);
54
- res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
55
- res.end(data);
56
- } catch {
57
- res.writeHead(404, { 'Content-Type': 'text/plain' });
58
- res.end('Not Found');
59
- }
60
- });
61
-
62
- // WebSocket server
63
- const wss = new WebSocketServer({ server });
64
-
65
- wss.on('connection', (ws, req) => {
66
- const url = new URL(req.url, 'http://localhost');
67
- const sessionId = url.searchParams.get('sessionId');
68
- if (!sessionId) {
69
- ws.close(4001, 'Missing sessionId');
70
- return;
71
- }
72
-
73
- let session = sessions.get(sessionId);
74
- if (!session) {
75
- session = { terminals: new Map(), ws: null, disconnectedAt: null };
76
- sessions.set(sessionId, session);
77
- }
78
-
79
- // Detach old WS if still open
80
- if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
81
- session.ws.close();
82
- }
83
-
84
- session.ws = ws;
85
- session.disconnectedAt = null;
86
-
87
- // Send session-restore with existing terminal IDs
88
- const terminalIds = [...session.terminals.keys()];
89
- ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
90
-
91
- // Send buffered output for each existing terminal
92
- for (const [id, entry] of session.terminals) {
93
- const buffered = entry.outputBuffer.read();
94
- if (buffered) {
95
- ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
96
- }
97
- }
37
+ const startCwd = process.cwd();
38
+ const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
39
+
40
+ // HTTP server for static files
41
+ const server = createServer(async (req, res) => {
42
+ let filePath;
43
+ const url = req.url.split('?')[0];
98
44
 
99
- // Signal that all buffer messages have been sent
100
- if (terminalIds.length > 0) {
101
- ws.send(JSON.stringify({ type: 'restore-complete' }));
102
- }
45
+ if (url === '/') {
46
+ filePath = join(__dirname, 'public', 'index.html');
47
+ } else {
48
+ filePath = join(__dirname, 'public', url);
49
+ }
103
50
 
104
- ws.on('message', async (raw) => {
105
- let msg;
106
51
  try {
107
- msg = JSON.parse(raw);
52
+ const data = await readFile(filePath);
53
+ const ext = extname(filePath);
54
+ res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
55
+ res.end(data);
108
56
  } catch {
57
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
58
+ res.end('Not Found');
59
+ }
60
+ });
61
+
62
+ // WebSocket server
63
+ const wss = new WebSocketServer({ server });
64
+
65
+ wss.on('connection', (ws, req) => {
66
+ const url = new URL(req.url, 'http://localhost');
67
+ const sessionId = url.searchParams.get('sessionId');
68
+ if (!sessionId) {
69
+ ws.close(4001, 'Missing sessionId');
109
70
  return;
110
71
  }
111
72
 
112
- switch (msg.type) {
113
- case 'create': {
114
- const shell = process.env.SHELL || '/bin/bash';
115
- let ptyProcess;
116
- try {
117
- ptyProcess = pty.spawn(shell, [], {
118
- name: 'xterm-256color',
119
- cols: msg.cols || 80,
120
- rows: msg.rows || 24,
121
- cwd: startCwd,
122
- env: process.env,
123
- });
124
- } catch (err) {
125
- console.error(`Failed to spawn shell "${shell}":`, err.message);
126
- if (ws.readyState === 1) {
127
- ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
128
- }
129
- break;
130
- }
73
+ let session = sessions.get(sessionId);
74
+ if (!session) {
75
+ session = { terminals: new Map(), ws: null, disconnectedAt: null };
76
+ sessions.set(sessionId, session);
77
+ }
131
78
 
132
- const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
133
- session.terminals.set(msg.id, { pty: ptyProcess, outputBuffer });
79
+ // Detach old WS if still open
80
+ if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
81
+ session.ws.close();
82
+ }
134
83
 
135
- ptyProcess.onData((data) => {
136
- outputBuffer.write(data);
137
- if (session.ws && session.ws.readyState === 1) {
138
- session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
139
- }
140
- });
84
+ session.ws = ws;
85
+ session.disconnectedAt = null;
141
86
 
142
- ptyProcess.onExit(({ exitCode }) => {
143
- session.terminals.delete(msg.id);
144
- if (session.ws && session.ws.readyState === 1) {
145
- session.ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode }));
146
- }
147
- });
148
- break;
87
+ // Send session-restore with existing terminal IDs
88
+ const terminalIds = [...session.terminals.keys()];
89
+ ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
90
+
91
+ // Send buffered output for each existing terminal
92
+ for (const [id, entry] of session.terminals) {
93
+ const buffered = entry.outputBuffer.read();
94
+ if (buffered) {
95
+ ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
149
96
  }
97
+ }
98
+
99
+ // Signal that all buffer messages have been sent
100
+ if (terminalIds.length > 0) {
101
+ ws.send(JSON.stringify({ type: 'restore-complete' }));
102
+ }
150
103
 
151
- case 'data': {
152
- const entry = session.terminals.get(msg.id);
153
- if (entry) entry.pty.write(msg.data);
154
- break;
104
+ ws.on('message', async (raw) => {
105
+ let msg;
106
+ try {
107
+ msg = JSON.parse(raw);
108
+ } catch {
109
+ return;
155
110
  }
156
111
 
157
- case 'resize': {
158
- const entry = session.terminals.get(msg.id);
159
- if (entry) {
112
+ switch (msg.type) {
113
+ case 'create': {
114
+ const shell = process.env.SHELL || '/bin/bash';
115
+ let ptyProcess;
160
116
  try {
161
- entry.pty.resize(msg.cols, msg.rows);
162
- } catch {
163
- // ignore invalid resize
117
+ ptyProcess = pty.spawn(shell, [], {
118
+ name: 'xterm-256color',
119
+ cols: msg.cols || 80,
120
+ rows: msg.rows || 24,
121
+ cwd: startCwd,
122
+ env: process.env,
123
+ });
124
+ } catch (err) {
125
+ console.error(`Failed to spawn shell "${shell}":`, err.message);
126
+ if (ws.readyState === 1) {
127
+ ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
128
+ }
129
+ break;
130
+ }
131
+
132
+ const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
133
+ session.terminals.set(msg.id, { pty: ptyProcess, outputBuffer });
134
+
135
+ ptyProcess.onData((data) => {
136
+ outputBuffer.write(data);
137
+ if (session.ws && session.ws.readyState === 1) {
138
+ session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
139
+ }
140
+ });
141
+
142
+ ptyProcess.onExit(({ exitCode }) => {
143
+ session.terminals.delete(msg.id);
144
+ if (session.ws && session.ws.readyState === 1) {
145
+ session.ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode }));
146
+ }
147
+ });
148
+ break;
149
+ }
150
+
151
+ case 'data': {
152
+ const entry = session.terminals.get(msg.id);
153
+ if (entry) { entry.pty.write(msg.data); }
154
+ break;
155
+ }
156
+
157
+ case 'resize': {
158
+ const entry = session.terminals.get(msg.id);
159
+ if (entry) {
160
+ try {
161
+ entry.pty.resize(msg.cols, msg.rows);
162
+ } catch {
163
+ // ignore invalid resize
164
+ }
164
165
  }
166
+ break;
165
167
  }
166
- break;
167
- }
168
168
 
169
- case 'close': {
170
- const entry = session.terminals.get(msg.id);
171
- if (entry) {
172
- entry.pty.kill();
173
- session.terminals.delete(msg.id);
169
+ case 'close': {
170
+ const entry = session.terminals.get(msg.id);
171
+ if (entry) {
172
+ entry.pty.kill();
173
+ session.terminals.delete(msg.id);
174
+ }
175
+ break;
174
176
  }
175
- break;
177
+
176
178
  }
179
+ });
177
180
 
178
- }
181
+ ws.on('close', () => {
182
+ if (session.ws === ws) {
183
+ session.ws = null;
184
+ session.disconnectedAt = Date.now();
185
+ }
186
+ });
179
187
  });
180
188
 
181
- ws.on('close', () => {
182
- if (session.ws === ws) {
183
- session.ws = null;
184
- session.disconnectedAt = Date.now();
189
+ server.listen(port, host, () => {
190
+ const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
191
+ console.log(`mwt running at ${url}`);
192
+ if (options.open !== false) {
193
+ openBrowser(url);
185
194
  }
186
195
  });
187
- });
188
-
189
- server.listen(port, host, () => {
190
- const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
191
- console.log(`mwt running at ${url}`);
192
- if (options.open !== false) {
193
- openBrowser(url);
194
- }
195
- });
196
-
197
- // Clean up sessions that have been disconnected too long
198
- const cleanupTimer = setInterval(() => {
199
- const now = Date.now();
200
- for (const [sessionId, session] of sessions) {
201
- if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
202
- for (const [, entry] of session.terminals) {
203
- entry.pty.kill();
196
+
197
+ // Clean up sessions that have been disconnected too long
198
+ const cleanupTimer = setInterval(() => {
199
+ const now = Date.now();
200
+ for (const [sessionId, session] of sessions) {
201
+ if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
202
+ for (const [, entry] of session.terminals) {
203
+ entry.pty.kill();
204
+ }
205
+ session.terminals.clear();
206
+ sessions.delete(sessionId);
204
207
  }
205
- session.terminals.clear();
206
- sessions.delete(sessionId);
207
208
  }
208
- }
209
- }, CLEANUP_INTERVAL_MS);
210
- cleanupTimer.unref();
209
+ }, CLEANUP_INTERVAL_MS);
210
+ cleanupTimer.unref();
211
211
 
212
212
  } // end start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacksontian/mwt",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Multi-window terminal with side-by-side, grid, and tab layouts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,9 @@
21
21
  "scripts": {
22
22
  "test": "mocha",
23
23
  "cov": "c8 mocha",
24
- "cov:report": "c8 report --reporter=text --reporter=html"
24
+ "cov:report": "c8 report --reporter=text --reporter=html",
25
+ "lint": "eslint .",
26
+ "lint:fix": "eslint . --fix"
25
27
  },
26
28
  "engines": {
27
29
  "node": ">=18"
@@ -31,7 +33,10 @@
31
33
  "ws": "^8.18.0"
32
34
  },
33
35
  "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
34
37
  "c8": "^11.0.0",
38
+ "eslint": "^10.1.0",
39
+ "globals": "^17.4.0",
35
40
  "mocha": "^11.7.5"
36
41
  },
37
42
  "publishConfig": {
@@ -21,9 +21,10 @@
21
21
  --accent: #6c8cff;
22
22
  --accent-hover: #8aa4ff;
23
23
  --text-primary: #d4d8e8;
24
- --text-secondary: #8890a8;
25
- --text-dim: #555b72;
24
+ --text-secondary: #9aa3bc;
25
+ --text-dim: #6f788f;
26
26
  --border: #252a3d;
27
+ --pane-border-idle: #1e2333;
27
28
  --danger: #e05560;
28
29
  --danger-hover: #ff6b76;
29
30
  --terminal-bg: #0a0c12;
@@ -43,6 +44,7 @@
43
44
  --text-secondary: #5a6178;
44
45
  --text-dim: #9098b0;
45
46
  --border: #d0d5e0;
47
+ --pane-border-idle: #c5cad6;
46
48
  --danger: #d43d4e;
47
49
  --danger-hover: #c02838;
48
50
  --terminal-bg: #ffffff;
@@ -291,12 +293,30 @@ body {
291
293
  .terminal-pane {
292
294
  display: flex;
293
295
  flex-direction: column;
296
+ position: relative;
294
297
  background: var(--terminal-bg);
295
- border: 1px solid var(--border);
298
+ border: 1px solid var(--pane-border-idle);
296
299
  border-radius: var(--radius-sm);
297
300
  overflow: hidden;
298
301
  min-width: 0;
299
302
  min-height: 0;
303
+ transition: border-color var(--transition), box-shadow var(--transition);
304
+ }
305
+
306
+ .terminal-pane.pane-focused {
307
+ border-color: var(--accent);
308
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
309
+ z-index: 1;
310
+ }
311
+
312
+ .terminal-pane.pane-focused .pane-header {
313
+ background: color-mix(in srgb, var(--accent) 12%, var(--pane-header));
314
+ border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
315
+ }
316
+
317
+ .terminal-pane.pane-focused .pane-title {
318
+ color: var(--text-primary);
319
+ font-weight: 500;
300
320
  }
301
321
 
302
322
  .pane-header {
@@ -309,11 +329,13 @@ body {
309
329
  border-bottom: 1px solid var(--border);
310
330
  flex-shrink: 0;
311
331
  user-select: none;
332
+ transition: background var(--transition), border-color var(--transition);
312
333
  }
313
334
 
314
335
  .pane-title {
315
336
  font-size: 11px;
316
337
  color: var(--text-secondary);
338
+ transition: color var(--transition), font-weight var(--transition);
317
339
  overflow: hidden;
318
340
  text-overflow: ellipsis;
319
341
  white-space: nowrap;
@@ -387,6 +409,8 @@ body {
387
409
  flex: 1 !important;
388
410
  min-width: 0 !important;
389
411
  min-height: 0 !important;
412
+ grid-column: 1 / -1;
413
+ grid-row: 1 / -1;
390
414
  }
391
415
 
392
416
  .terminal-body {
@@ -402,6 +426,8 @@ body {
402
426
 
403
427
  .terminal-body .xterm-viewport {
404
428
  overflow-y: auto !important;
429
+ /* 与 theme 背景一致,避免 vendor 默认 #000 与画布色差导致边缘发闷 */
430
+ background-color: var(--terminal-bg) !important;
405
431
  }
406
432
 
407
433
  /* ========== Layout: Side by Side ========== */
@@ -441,6 +467,11 @@ body {
441
467
  inset: 0;
442
468
  border-radius: 0;
443
469
  border: none;
470
+ box-shadow: none;
471
+ }
472
+
473
+ .layout-tabs .terminal-pane.pane-focused.active {
474
+ box-shadow: inset 0 0 0 1px var(--accent);
444
475
  }
445
476
 
446
477
  .layout-tabs .terminal-pane.active {
@@ -568,6 +599,235 @@ body {
568
599
  display: block;
569
600
  }
570
601
 
602
+ /* ========== Drag to swap ========== */
603
+ .pane-header.dragging-source {
604
+ opacity: 0.5;
605
+ }
606
+
607
+ .terminal-pane.drag-over {
608
+ outline: 2px solid var(--accent);
609
+ outline-offset: -2px;
610
+ }
611
+
612
+ .pane-header[draggable="true"] {
613
+ cursor: grab;
614
+ }
615
+
616
+ .pane-header[draggable="true"]:active {
617
+ cursor: grabbing;
618
+ }
619
+
620
+ .tab[draggable="true"] {
621
+ cursor: grab;
622
+ }
623
+
624
+ .tab[draggable="true"]:active {
625
+ cursor: grabbing;
626
+ }
627
+
628
+ .tab.drag-over {
629
+ outline: 2px solid var(--accent);
630
+ outline-offset: -2px;
631
+ border-radius: var(--radius-sm) var(--radius-sm) 0 0;
632
+ }
633
+
634
+ /* ========== Shortcuts Button ========== */
635
+ #btn-shortcuts {
636
+ display: flex;
637
+ align-items: center;
638
+ justify-content: center;
639
+ width: 30px;
640
+ height: 30px;
641
+ background: var(--bg-secondary);
642
+ border: 1px solid var(--border);
643
+ border-radius: var(--radius);
644
+ color: var(--text-secondary);
645
+ cursor: pointer;
646
+ transition: all var(--transition);
647
+ margin-right: 4px;
648
+ }
649
+
650
+ #btn-shortcuts:hover {
651
+ background: var(--bg-hover);
652
+ color: var(--text-primary);
653
+ }
654
+
655
+ /* ========== Shortcuts Modal ========== */
656
+ .modal-overlay {
657
+ position: fixed;
658
+ inset: 0;
659
+ background: rgba(0, 0, 0, 0.5);
660
+ display: flex;
661
+ align-items: center;
662
+ justify-content: center;
663
+ z-index: 1000;
664
+ }
665
+
666
+ .modal-overlay.hidden {
667
+ display: none;
668
+ }
669
+
670
+ .modal {
671
+ background: var(--bg-secondary);
672
+ border: 1px solid var(--border);
673
+ border-radius: var(--radius);
674
+ width: 400px;
675
+ max-width: calc(100vw - 32px);
676
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
677
+ }
678
+
679
+ .modal-header {
680
+ display: flex;
681
+ align-items: center;
682
+ justify-content: space-between;
683
+ padding: 14px 16px 12px;
684
+ border-bottom: 1px solid var(--border);
685
+ }
686
+
687
+ .modal-title {
688
+ font-size: 13px;
689
+ font-weight: 600;
690
+ color: var(--text-primary);
691
+ }
692
+
693
+ .modal-close {
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: center;
697
+ width: 22px;
698
+ height: 22px;
699
+ background: transparent;
700
+ border: none;
701
+ border-radius: var(--radius-sm);
702
+ color: var(--text-dim);
703
+ font-size: 18px;
704
+ cursor: pointer;
705
+ line-height: 1;
706
+ transition: all var(--transition);
707
+ }
708
+
709
+ .modal-close:hover {
710
+ background: var(--bg-hover);
711
+ color: var(--text-primary);
712
+ }
713
+
714
+ .modal-body {
715
+ padding: 12px 16px 16px;
716
+ display: flex;
717
+ flex-direction: column;
718
+ gap: 16px;
719
+ }
720
+
721
+ .shortcut-group {
722
+ display: flex;
723
+ flex-direction: column;
724
+ gap: 6px;
725
+ }
726
+
727
+ .shortcut-group-title {
728
+ font-size: 11px;
729
+ font-weight: 600;
730
+ color: var(--text-dim);
731
+ text-transform: uppercase;
732
+ letter-spacing: 0.06em;
733
+ margin-bottom: 2px;
734
+ }
735
+
736
+ .shortcut-row {
737
+ display: flex;
738
+ align-items: center;
739
+ justify-content: space-between;
740
+ gap: 12px;
741
+ }
742
+
743
+ .shortcut-desc {
744
+ font-size: 12px;
745
+ color: var(--text-secondary);
746
+ }
747
+
748
+ .shortcut-key {
749
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
750
+ font-size: 11px;
751
+ color: var(--text-secondary);
752
+ background: var(--bg-tertiary);
753
+ border: 1px solid var(--border);
754
+ border-radius: var(--radius-sm);
755
+ padding: 2px 7px;
756
+ white-space: nowrap;
757
+ flex-shrink: 0;
758
+ }
759
+
760
+ /* ========== Settings Modal ========== */
761
+ #btn-settings {
762
+ display: flex;
763
+ align-items: center;
764
+ justify-content: center;
765
+ width: 28px;
766
+ height: 28px;
767
+ border: none;
768
+ border-radius: var(--radius-sm);
769
+ background: transparent;
770
+ color: var(--text-secondary);
771
+ cursor: pointer;
772
+ transition: background var(--transition), color var(--transition);
773
+ }
774
+
775
+ #btn-settings:hover {
776
+ background: var(--bg-hover);
777
+ color: var(--text-primary);
778
+ }
779
+
780
+ .settings-group {
781
+ display: flex;
782
+ flex-direction: column;
783
+ gap: 12px;
784
+ }
785
+
786
+ .settings-group-title {
787
+ font-size: 11px;
788
+ font-weight: 600;
789
+ text-transform: uppercase;
790
+ letter-spacing: 0.06em;
791
+ color: var(--text-dim);
792
+ margin-bottom: 2px;
793
+ }
794
+
795
+ .settings-row {
796
+ display: flex;
797
+ align-items: center;
798
+ justify-content: space-between;
799
+ gap: 12px;
800
+ }
801
+
802
+ .settings-label {
803
+ font-size: 12px;
804
+ color: var(--text-secondary);
805
+ white-space: nowrap;
806
+ min-width: 70px;
807
+ }
808
+
809
+ input[type="range"]#input-font-size {
810
+ flex: 1;
811
+ accent-color: var(--accent);
812
+ cursor: pointer;
813
+ }
814
+
815
+ .settings-text-input {
816
+ flex: 1;
817
+ background: var(--bg-tertiary);
818
+ border: 1px solid var(--border);
819
+ border-radius: var(--radius-sm);
820
+ color: var(--text-primary);
821
+ font-size: 12px;
822
+ padding: 4px 8px;
823
+ outline: none;
824
+ transition: border-color var(--transition);
825
+ }
826
+
827
+ .settings-text-input:focus {
828
+ border-color: var(--accent);
829
+ }
830
+
571
831
  /* ========== Keyboard shortcut hint ========== */
572
832
  @media (max-width: 600px) {
573
833
  .layout-btn span {