@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # mwt - Multi-Window Terminal
2
2
 
3
- A lightweight web-based multi-window terminal for **local development**. Run multiple shell sessions in the browser with side-by-side, grid, and tab layouts.
3
+ A lightweight web-based multi-window terminal for **local development**. Run multiple shell sessions in the browser with columns, rows, grid, and tab layouts.
4
4
 
5
5
  > **Note:** mwt is designed for local use on your development machine. It does not provide authentication, encryption, or any access control — do not expose it to the network.
6
6
 
@@ -9,9 +9,13 @@ A lightweight web-based multi-window terminal for **local development**. Run mul
9
9
  [![Node.js](https://img.shields.io/node/v/@jacksontian/mwt)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jacksontian/mwt)](LICENSE)
11
11
 
12
- ### Side by Side
12
+ ### Columns
13
13
 
14
- ![Side by Side](./figures/sidebyside.png)
14
+ ![Columns](./figures/columns.png)
15
+
16
+ ### Rows
17
+
18
+ ![Rows](./figures/rows.png)
15
19
 
16
20
  ### Grid
17
21
 
@@ -42,15 +46,15 @@ mwt 想解决的问题很简单:**给本地开发提供一个轻量、直觉
42
46
 
43
47
  **浏览器即界面** — 选择浏览器作为 UI 层,天然跨平台,不需要安装桌面应用。布局切换、主题跟随、快捷键操作都在浏览器中完成,所见即所得。
44
48
 
45
- **会话不丢失** — 每个终端保留 100KB 的输出缓冲区,刷新页面或网络断开后可以恢复现场。断线 30 分钟内自动重连,开发流程不被打断。
49
+ **会话不丢失** — 每个终端保留 100KB 的输出缓冲区,刷新页面或网络断开后可以恢复现场;断线 30 分钟内服务端保留会话,自动重连后回放缓冲。**同一 `sessionId` 同时只允许一个标签页保持 WebSocket**,新开标签页会被拒绝,避免多页争抢同一组 PTY。详见下文 **Session & recovery**。
46
50
 
47
51
  **够用就好** — 不做 SSH、不做认证、不做插件系统。mwt 只专注于一件事:在本地高效地管理多个终端窗口。功能边界清晰,代码量控制在 2000 行左右,任何人都可以快速理解和修改。
48
52
 
49
53
  ## Features
50
54
 
51
55
  - **Multi-terminal** - Create and manage multiple terminal sessions simultaneously
52
- - **Three layouts** - Side-by-side, grid, and tabs, switch anytime
53
- - **Session persistence** - Reconnect without losing terminal output (100KB buffer per terminal)
56
+ - **Four layouts** - Columns, rows, grid, and tabs, switch anytime
57
+ - **Session persistence** - Reconnect without losing recent terminal output (100KB buffer per terminal); **one browser tab** may be connected per session (see **Session & recovery**)
54
58
  - **Auto-reconnect** - WebSocket disconnection recovery with exponential backoff
55
59
  - **Dark / Light theme** - Manual toggle or follow system preference
56
60
  - **Keyboard-driven** - Full keyboard shortcut support
@@ -87,6 +91,20 @@ Example:
87
91
  mwt -p 8080
88
92
  ```
89
93
 
94
+ ## Session & recovery
95
+
96
+ Understanding how the browser talks to the server avoids surprises (especially with multiple tabs).
97
+
98
+ | Topic | Behavior |
99
+ |--------|----------|
100
+ | **Session ID** | On first visit, the page stores a UUID in `localStorage` under `myterminal-session-id`. Every WebSocket connection sends this id so the server can attach to the same logical session (same PTY processes) after a refresh. |
101
+ | **Single active tab (互斥连接)** | Only **one** browser tab may keep the WebSocket open for that session. If you open mwt in a **second** tab while the first is still connected, the new tab’s connection is **rejected** and you see a full-page message: *mwt is already open in another tab.* This is intentional: two tabs would both try to drive the same shells. Close the other tab or use one tab only. |
102
+ | **Refresh & reconnect** | Reloading the **same** tab disconnects briefly, then reconnects with the same `sessionId`. Existing terminal ids are restored; the server replays recent output from its per-terminal ring buffer (see below). |
103
+ | **Output buffer** | While connected, the server keeps the **last ~100 KB** of output per terminal. After reconnect, that chunk is sent to the client so you don’t lose recent scrollback. It is not a full session log. |
104
+ | **Idle cleanup** | If **no** tab is connected for **30 minutes**, the server **kills** that session’s shells and clears its state. The next visit still uses the same `sessionId` in `localStorage`, but you’ll get an **empty** session (no old terminals)—create new ones as needed. |
105
+
106
+ **Summary:** one tab connected at a time; refresh is fine; a second simultaneous tab is blocked; long disconnects drop server-side sessions after 30 minutes.
107
+
90
108
  ## Keyboard Shortcuts
91
109
 
92
110
  | Shortcut | Action |
@@ -0,0 +1,40 @@
1
+ import pty from 'node-pty';
2
+ import { RingBuffer } from './ring-buffer.js';
3
+
4
+ const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
5
+
6
+ /**
7
+ * Spawn a PTY process and attach it to a session terminal slot.
8
+ * Returns the terminal entry { pty, outputBuffer } on success,
9
+ * or null if spawn fails (the caller receives an 'exit' message).
10
+ */
11
+ export function spawnTerminal({ id, cols, rows, cwd, onData, onExit }) {
12
+ const shell = process.env.SHELL || '/bin/bash';
13
+ let ptyProcess;
14
+ try {
15
+ ptyProcess = pty.spawn(shell, [], {
16
+ name: 'xterm-256color',
17
+ cols: cols || 80,
18
+ rows: rows || 24,
19
+ cwd,
20
+ env: process.env,
21
+ });
22
+ } catch (err) {
23
+ console.error(`Failed to spawn shell "${shell}":`, err.message);
24
+ onExit(id, 1);
25
+ return null;
26
+ }
27
+
28
+ const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
29
+
30
+ ptyProcess.onData((data) => {
31
+ outputBuffer.write(data);
32
+ onData(id, data);
33
+ });
34
+
35
+ ptyProcess.onExit(({ exitCode }) => {
36
+ onExit(id, exitCode);
37
+ });
38
+
39
+ return { pty: ptyProcess, outputBuffer };
40
+ }
package/lib/server.js CHANGED
@@ -2,10 +2,10 @@ import { createServer } from 'node:http';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join, extname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { exec } from 'node:child_process';
5
+ import { execFile } from 'node:child_process';
6
6
  import { WebSocketServer } from 'ws';
7
- import pty from 'node-pty';
8
- import { RingBuffer } from './ring-buffer.js';
7
+ import { SessionManager } from './session-manager.js';
8
+ import { spawnTerminal } from './pty-manager.js';
9
9
 
10
10
  const __dirname = fileURLToPath(new URL('..', import.meta.url));
11
11
 
@@ -21,21 +21,24 @@ const MIME_TYPES = {
21
21
  '.ico': 'image/x-icon',
22
22
  };
23
23
 
24
- const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
25
- const CLEANUP_INTERVAL_MS = 60 * 1000; // check every minute
26
- const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
27
-
28
24
  function openBrowser(url) {
29
25
  const cmd = process.platform === 'darwin' ? 'open'
30
- : process.platform === 'win32' ? 'start'
26
+ : process.platform === 'win32' ? 'cmd'
31
27
  : 'xdg-open';
32
- exec(`${cmd} ${url}`);
28
+ const args = process.platform === 'win32' ? ['/c', 'start', url] : [url];
29
+ execFile(cmd, args);
33
30
  }
34
31
 
35
32
  export function start(port = 1987, host = '127.0.0.1', options = {}) {
36
-
37
33
  const startCwd = process.cwd();
38
- const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
34
+ const sessions = new SessionManager();
35
+
36
+ const publicDir = join(__dirname, 'public');
37
+ const SECURITY_HEADERS = {
38
+ 'X-Content-Type-Options': 'nosniff',
39
+ 'X-Frame-Options': 'DENY',
40
+ 'Cache-Control': 'no-store',
41
+ };
39
42
 
40
43
  // HTTP server for static files
41
44
  const server = createServer(async (req, res) => {
@@ -43,18 +46,32 @@ export function start(port = 1987, host = '127.0.0.1', options = {}) {
43
46
  const url = req.url.split('?')[0];
44
47
 
45
48
  if (url === '/') {
46
- filePath = join(__dirname, 'public', 'index.html');
49
+ filePath = join(publicDir, 'index.html');
47
50
  } else {
48
- filePath = join(__dirname, 'public', url);
51
+ filePath = join(publicDir, url);
52
+ }
53
+
54
+ // Prevent path traversal: resolved path must stay within publicDir
55
+ if (!filePath.startsWith(publicDir + '/') && filePath !== join(publicDir, 'index.html')) {
56
+ res.writeHead(403, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
57
+ res.end('Forbidden');
58
+ return;
59
+ }
60
+
61
+ // Only serve known file types
62
+ const ext = extname(filePath);
63
+ if (!MIME_TYPES[ext]) {
64
+ res.writeHead(403, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
65
+ res.end('Forbidden');
66
+ return;
49
67
  }
50
68
 
51
69
  try {
52
70
  const data = await readFile(filePath);
53
- const ext = extname(filePath);
54
- res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
71
+ res.writeHead(200, { 'Content-Type': MIME_TYPES[ext], ...SECURITY_HEADERS });
55
72
  res.end(data);
56
73
  } catch {
57
- res.writeHead(404, { 'Content-Type': 'text/plain' });
74
+ res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
58
75
  res.end('Not Found');
59
76
  }
60
77
  });
@@ -70,19 +87,15 @@ export function start(port = 1987, host = '127.0.0.1', options = {}) {
70
87
  return;
71
88
  }
72
89
 
73
- let session = sessions.get(sessionId);
74
- if (!session) {
75
- session = { terminals: new Map(), ws: null, disconnectedAt: null };
76
- sessions.set(sessionId, session);
77
- }
90
+ const session = sessions.getOrCreate(sessionId);
78
91
 
79
- // Detach old WS if still open
80
- if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
81
- session.ws.close();
92
+ // Reject new connection if session already has an active WebSocket
93
+ if (session.ws && session.ws.readyState === 1) {
94
+ ws.close(4409, 'already connected');
95
+ return;
82
96
  }
83
97
 
84
- session.ws = ws;
85
- session.disconnectedAt = null;
98
+ sessions.connect(session, ws);
86
99
 
87
100
  // Send session-restore with existing terminal IDs
88
101
  const terminalIds = [...session.terminals.keys()];
@@ -111,40 +124,26 @@ export function start(port = 1987, host = '127.0.0.1', options = {}) {
111
124
 
112
125
  switch (msg.type) {
113
126
  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
- }
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
- }
127
+ const entry = spawnTerminal({
128
+ id: msg.id,
129
+ cols: msg.cols,
130
+ rows: msg.rows,
131
+ cwd: startCwd,
132
+ onData: (id, data) => {
133
+ if (session.ws && session.ws.readyState === 1) {
134
+ session.ws.send(JSON.stringify({ type: 'data', id, data }));
135
+ }
136
+ },
137
+ onExit: (id, exitCode) => {
138
+ sessions.deleteTerminal(session, id);
139
+ if (session.ws && session.ws.readyState === 1) {
140
+ session.ws.send(JSON.stringify({ type: 'exit', id, exitCode }));
141
+ }
142
+ },
147
143
  });
144
+ if (entry) {
145
+ session.terminals.set(msg.id, entry);
146
+ }
148
147
  break;
149
148
  }
150
149
 
@@ -170,19 +169,15 @@ export function start(port = 1987, host = '127.0.0.1', options = {}) {
170
169
  const entry = session.terminals.get(msg.id);
171
170
  if (entry) {
172
171
  entry.pty.kill();
173
- session.terminals.delete(msg.id);
172
+ sessions.deleteTerminal(session, msg.id);
174
173
  }
175
174
  break;
176
175
  }
177
-
178
176
  }
179
177
  });
180
178
 
181
179
  ws.on('close', () => {
182
- if (session.ws === ws) {
183
- session.ws = null;
184
- session.disconnectedAt = Date.now();
185
- }
180
+ sessions.disconnect(session, ws);
186
181
  });
187
182
  });
188
183
 
@@ -194,19 +189,10 @@ export function start(port = 1987, host = '127.0.0.1', options = {}) {
194
189
  }
195
190
  });
196
191
 
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);
207
- }
208
- }
209
- }, CLEANUP_INTERVAL_MS);
210
- cleanupTimer.unref();
192
+ // Kill all PTY processes on exit to avoid orphan processes
193
+ function killAll() { sessions.killAll(); }
211
194
 
212
- } // end start
195
+ process.once('exit', killAll);
196
+ process.once('SIGINT', () => { killAll(); process.exit(130); });
197
+ process.once('SIGTERM', () => { killAll(); process.exit(143); });
198
+ }
@@ -0,0 +1,66 @@
1
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
2
+ const CLEANUP_INTERVAL_MS = 60 * 1000; // check every minute
3
+
4
+ export class SessionManager {
5
+ constructor() {
6
+ this._sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
7
+
8
+ const timer = setInterval(() => this._cleanup(), CLEANUP_INTERVAL_MS);
9
+ timer.unref();
10
+ }
11
+
12
+ /** Return existing session or create a new one. */
13
+ getOrCreate(sessionId) {
14
+ let session = this._sessions.get(sessionId);
15
+ if (!session) {
16
+ session = { terminals: new Map(), ws: null, disconnectedAt: null };
17
+ this._sessions.set(sessionId, session);
18
+ }
19
+ return session;
20
+ }
21
+
22
+ get(sessionId) {
23
+ return this._sessions.get(sessionId);
24
+ }
25
+
26
+ /** Mark a session as connected to the given WebSocket. */
27
+ connect(session, ws) {
28
+ session.ws = ws;
29
+ session.disconnectedAt = null;
30
+ }
31
+
32
+ /** Mark a session as disconnected. */
33
+ disconnect(session, ws) {
34
+ if (session.ws === ws) {
35
+ session.ws = null;
36
+ session.disconnectedAt = Date.now();
37
+ }
38
+ }
39
+
40
+ /** Remove a terminal entry from a session. */
41
+ deleteTerminal(session, id) {
42
+ session.terminals.delete(id);
43
+ }
44
+
45
+ /** Kill all PTY processes across all sessions. */
46
+ killAll() {
47
+ for (const session of this._sessions.values()) {
48
+ for (const entry of session.terminals.values()) {
49
+ try { entry.pty.kill(); } catch {}
50
+ }
51
+ }
52
+ }
53
+
54
+ _cleanup() {
55
+ const now = Date.now();
56
+ for (const [sessionId, session] of this._sessions) {
57
+ if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
58
+ for (const [, entry] of session.terminals) {
59
+ entry.pty.kill();
60
+ }
61
+ session.terminals.clear();
62
+ this._sessions.delete(sessionId);
63
+ }
64
+ }
65
+ }
66
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jacksontian/mwt",
3
- "version": "1.1.0",
4
- "description": "Multi-window terminal with side-by-side, grid, and tab layouts",
3
+ "version": "1.2.0",
4
+ "description": "Multi-window terminal with columns, grid, and tab layouts",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mwt": "bin/mwt.js"
@@ -85,16 +85,14 @@ body {
85
85
  -webkit-app-region: no-drag;
86
86
  }
87
87
 
88
+ .toolbar-left, .toolbar-right {
89
+ flex: 1;
90
+ }
91
+
88
92
  .toolbar-right {
89
- min-width: 100px;
90
93
  justify-content: flex-end;
91
94
  }
92
95
 
93
- #terminal-count {
94
- color: var(--text-dim);
95
- font-size: 12px;
96
- font-variant-numeric: tabular-nums;
97
- }
98
96
 
99
97
  #btn-new-terminal {
100
98
  display: flex;
@@ -134,8 +132,10 @@ body {
134
132
  .layout-btn {
135
133
  display: flex;
136
134
  align-items: center;
135
+ justify-content: center;
137
136
  gap: 5px;
138
- padding: 5px 12px;
137
+ width: 80px;
138
+ padding: 5px 0;
139
139
  background: transparent;
140
140
  color: var(--text-secondary);
141
141
  border: none;
@@ -430,25 +430,39 @@ body {
430
430
  background-color: var(--terminal-bg) !important;
431
431
  }
432
432
 
433
- /* ========== Layout: Side by Side ========== */
434
- .layout-side-by-side {
433
+ /* ========== Layout: Columns ========== */
434
+ .layout-columns {
435
435
  display: flex;
436
436
  flex-direction: row;
437
437
  gap: 2px;
438
438
  padding: 2px;
439
439
  }
440
440
 
441
- .layout-side-by-side .terminal-pane {
441
+ .layout-columns .terminal-pane {
442
442
  flex: 1;
443
443
  min-width: 200px;
444
444
  }
445
445
 
446
+ /* ========== Layout: Rows ========== */
447
+ .layout-rows {
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 2px;
451
+ padding: 2px;
452
+ }
453
+
454
+ .layout-rows .terminal-pane {
455
+ flex: 1;
456
+ min-height: 100px;
457
+ }
458
+
446
459
  /* ========== Layout: Grid ========== */
447
460
  .layout-grid {
448
461
  display: grid;
449
462
  gap: 2px;
450
463
  padding: 2px;
451
464
  grid-template-columns: 1fr;
465
+ grid-auto-rows: 1fr;
452
466
  }
453
467
 
454
468
  .layout-grid .terminal-pane {
@@ -564,6 +578,96 @@ body {
564
578
  display: block;
565
579
  }
566
580
 
581
+ /* ========== Language Dropdown ========== */
582
+ #lang-dropdown {
583
+ position: relative;
584
+ margin-right: 4px;
585
+ }
586
+
587
+ #btn-lang {
588
+ display: flex;
589
+ align-items: center;
590
+ justify-content: center;
591
+ height: 30px;
592
+ padding: 0 8px;
593
+ background: var(--bg-secondary);
594
+ border: 1px solid var(--border);
595
+ border-radius: var(--radius);
596
+ color: var(--text-secondary);
597
+ cursor: pointer;
598
+ transition: all var(--transition);
599
+ font-size: 11px;
600
+ font-weight: 600;
601
+ letter-spacing: 0.02em;
602
+ gap: 4px;
603
+ }
604
+
605
+ #btn-lang:hover, #lang-dropdown.open #btn-lang {
606
+ background: var(--bg-hover);
607
+ color: var(--text-primary);
608
+ }
609
+
610
+ #btn-lang .lang-chevron {
611
+ width: 8px;
612
+ height: 8px;
613
+ transition: transform var(--transition);
614
+ }
615
+
616
+ #lang-dropdown.open .lang-chevron {
617
+ transform: rotate(180deg);
618
+ }
619
+
620
+ #lang-menu {
621
+ display: none;
622
+ position: absolute;
623
+ top: calc(100% + 4px);
624
+ right: 0;
625
+ min-width: 110px;
626
+ background: var(--bg-secondary);
627
+ border: 1px solid var(--border);
628
+ border-radius: var(--radius);
629
+ box-shadow: 0 4px 12px rgba(0,0,0,0.25);
630
+ z-index: 1000;
631
+ overflow: hidden;
632
+ }
633
+
634
+ #lang-dropdown.open #lang-menu {
635
+ display: block;
636
+ }
637
+
638
+ .lang-option {
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: space-between;
642
+ width: 100%;
643
+ padding: 7px 12px;
644
+ background: none;
645
+ border: none;
646
+ color: var(--text-secondary);
647
+ cursor: pointer;
648
+ font-size: 12px;
649
+ text-align: left;
650
+ transition: background var(--transition), color var(--transition);
651
+ }
652
+
653
+ .lang-option:hover {
654
+ background: var(--bg-hover);
655
+ color: var(--text-primary);
656
+ }
657
+
658
+ .lang-option.active {
659
+ color: var(--accent);
660
+ }
661
+
662
+ .lang-option .lang-check {
663
+ opacity: 0;
664
+ color: var(--accent);
665
+ }
666
+
667
+ .lang-option.active .lang-check {
668
+ opacity: 1;
669
+ }
670
+
567
671
  /* ========== Theme Toggle ========== */
568
672
  #btn-theme-toggle {
569
673
  display: flex;
@@ -762,14 +866,15 @@ body {
762
866
  display: flex;
763
867
  align-items: center;
764
868
  justify-content: center;
765
- width: 28px;
766
- height: 28px;
767
- border: none;
768
- border-radius: var(--radius-sm);
769
- background: transparent;
869
+ width: 30px;
870
+ height: 30px;
871
+ background: var(--bg-secondary);
872
+ border: 1px solid var(--border);
873
+ border-radius: var(--radius);
770
874
  color: var(--text-secondary);
771
875
  cursor: pointer;
772
- transition: background var(--transition), color var(--transition);
876
+ transition: all var(--transition);
877
+ margin-right: 4px;
773
878
  }
774
879
 
775
880
  #btn-settings:hover {