@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 +24 -6
- package/lib/pty-manager.js +40 -0
- package/lib/server.js +66 -80
- package/lib/session-manager.js +66 -0
- package/package.json +2 -2
- package/public/css/style.css +121 -16
- package/public/index.html +42 -32
- package/public/js/app.js +117 -102
- package/public/js/drag-manager.js +42 -0
- package/public/js/font-manager.js +3 -3
- package/public/js/i18n.js +176 -0
- package/public/js/layout-manager.js +5 -2
- package/public/js/shortcut-manager.js +102 -0
- package/public/js/terminal-manager.js +45 -34
- package/public/js/theme-manager.js +1 -1
- package/public/js/ws-client.js +23 -2
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
|
|
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
|
[](https://nodejs.org)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
###
|
|
12
|
+
### Columns
|
|
13
13
|
|
|
14
|
-

|
|
15
|
+
|
|
16
|
+
### Rows
|
|
17
|
+
|
|
18
|
+

|
|
15
19
|
|
|
16
20
|
### Grid
|
|
17
21
|
|
|
@@ -42,15 +46,15 @@ mwt 想解决的问题很简单:**给本地开发提供一个轻量、直觉
|
|
|
42
46
|
|
|
43
47
|
**浏览器即界面** — 选择浏览器作为 UI 层,天然跨平台,不需要安装桌面应用。布局切换、主题跟随、快捷键操作都在浏览器中完成,所见即所得。
|
|
44
48
|
|
|
45
|
-
**会话不丢失** — 每个终端保留 100KB
|
|
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
|
-
- **
|
|
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 {
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
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' ? '
|
|
26
|
+
: process.platform === 'win32' ? 'cmd'
|
|
31
27
|
: 'xdg-open';
|
|
32
|
-
|
|
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
|
|
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(
|
|
49
|
+
filePath = join(publicDir, 'index.html');
|
|
47
50
|
} else {
|
|
48
|
-
filePath = join(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
80
|
-
if (session.ws && session.ws
|
|
81
|
-
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
198
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Multi-window terminal with
|
|
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"
|
package/public/css/style.css
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
434
|
-
.layout-
|
|
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-
|
|
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:
|
|
766
|
-
height:
|
|
767
|
-
|
|
768
|
-
border
|
|
769
|
-
|
|
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:
|
|
876
|
+
transition: all var(--transition);
|
|
877
|
+
margin-right: 4px;
|
|
773
878
|
}
|
|
774
879
|
|
|
775
880
|
#btn-settings:hover {
|