termbeam 0.0.9 → 0.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.
- package/README.md +14 -4
- package/package.json +1 -1
- package/public/index.html +142 -12
- package/public/sw.js +23 -5
- package/public/terminal.html +1636 -483
- package/src/auth.js +13 -0
- package/src/routes.js +14 -1
- package/src/sessions.js +28 -4
- package/src/shells.js +1 -1
- package/src/tunnel.js +17 -12
- package/src/websocket.js +25 -3
package/public/terminal.html
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta
|
|
6
|
-
name="viewport"
|
|
7
|
-
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
8
|
-
/>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
9
6
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
7
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
8
|
<meta name="theme-color" content="#1e1e1e" />
|
|
12
9
|
<link rel="manifest" href="/manifest.json" />
|
|
13
10
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
14
11
|
<title>TermBeam — Terminal</title>
|
|
15
|
-
<link
|
|
16
|
-
rel="stylesheet"
|
|
17
|
-
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
|
18
|
-
/>
|
|
12
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
|
|
19
13
|
<style>
|
|
20
14
|
:root {
|
|
21
15
|
--bg: #1e1e1e;
|
|
@@ -59,364 +53,657 @@
|
|
|
59
53
|
}
|
|
60
54
|
@font-face {
|
|
61
55
|
font-family: 'NerdFont';
|
|
62
|
-
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')
|
|
63
|
-
|
|
64
|
-
font-weight: normal;
|
|
65
|
-
font-style: normal;
|
|
66
|
-
font-display: swap;
|
|
56
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
|
57
|
+
font-weight: normal; font-style: normal; font-display: swap;
|
|
67
58
|
}
|
|
68
59
|
@font-face {
|
|
69
60
|
font-family: 'NerdFont';
|
|
70
|
-
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf')
|
|
71
|
-
|
|
72
|
-
font-weight: bold;
|
|
73
|
-
font-style: normal;
|
|
74
|
-
font-display: swap;
|
|
61
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
|
62
|
+
font-weight: bold; font-style: normal; font-display: swap;
|
|
75
63
|
}
|
|
76
|
-
|
|
77
|
-
* {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
box-sizing: border-box;
|
|
81
|
-
}
|
|
82
|
-
html,
|
|
83
|
-
body {
|
|
84
|
-
height: 100%;
|
|
85
|
-
width: 100%;
|
|
86
|
-
background: var(--bg);
|
|
87
|
-
color: var(--text);
|
|
64
|
+
:root { --sab: env(safe-area-inset-bottom, 0px); }
|
|
65
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
66
|
+
html, body {
|
|
67
|
+
height: 100%; width: 100%; background: var(--bg); color: var(--text);
|
|
88
68
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
89
|
-
overflow: hidden;
|
|
90
|
-
|
|
91
|
-
height: 100dvh;
|
|
69
|
+
overflow: hidden; touch-action: manipulation; height: 100dvh;
|
|
70
|
+
overscroll-behavior: none;
|
|
92
71
|
transition: background 0.3s, color 0.3s;
|
|
93
72
|
}
|
|
94
73
|
|
|
95
|
-
|
|
74
|
+
/* ===== Top Bar (unified) ===== */
|
|
75
|
+
#top-bar {
|
|
96
76
|
height: 40px;
|
|
97
77
|
display: flex;
|
|
98
78
|
align-items: center;
|
|
99
|
-
justify-content: space-between;
|
|
100
|
-
padding: 0 8px;
|
|
101
79
|
background: var(--surface);
|
|
102
80
|
border-bottom: 1px solid var(--border);
|
|
103
|
-
|
|
81
|
+
padding: 0 calc(4px + env(safe-area-inset-right, 0px)) 0 calc(4px + env(safe-area-inset-left, 0px));
|
|
82
|
+
padding-top: env(safe-area-inset-top, 0px);
|
|
83
|
+
gap: 2px;
|
|
104
84
|
transition: background 0.3s, border-color 0.3s;
|
|
105
85
|
}
|
|
106
|
-
#
|
|
86
|
+
#top-bar .left { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
|
|
87
|
+
#top-bar .right { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
88
|
+
#status-dot {
|
|
89
|
+
width: 8px; height: 8px; border-radius: 50%; background: var(--danger);
|
|
90
|
+
display: inline-block; transition: background 0.3s;
|
|
91
|
+
}
|
|
92
|
+
#status-dot.connected { background: var(--success); }
|
|
93
|
+
#session-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
|
|
94
|
+
#status-text { color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
|
|
95
|
+
#tab-list {
|
|
107
96
|
display: flex;
|
|
108
97
|
align-items: center;
|
|
109
|
-
|
|
98
|
+
flex: 1;
|
|
99
|
+
overflow-x: auto;
|
|
100
|
+
-webkit-overflow-scrolling: touch;
|
|
101
|
+
scrollbar-width: none;
|
|
102
|
+
gap: 2px;
|
|
103
|
+
padding: 0 4px;
|
|
104
|
+
height: 100%;
|
|
105
|
+
min-width: 0;
|
|
110
106
|
}
|
|
111
|
-
#
|
|
107
|
+
#tab-list::-webkit-scrollbar { display: none; }
|
|
108
|
+
.session-tab {
|
|
112
109
|
display: flex;
|
|
113
110
|
align-items: center;
|
|
114
|
-
gap:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
background: none;
|
|
111
|
+
gap: 6px;
|
|
112
|
+
padding: 4px 10px;
|
|
113
|
+
background: transparent;
|
|
118
114
|
border: none;
|
|
115
|
+
border-left: 3px solid transparent;
|
|
116
|
+
border-radius: 6px;
|
|
119
117
|
color: var(--text-dim);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
border-radius: 8px;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
font-weight: 500;
|
|
123
120
|
cursor: pointer;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
flex-shrink: 0;
|
|
123
|
+
height: 30px;
|
|
127
124
|
transition: background 0.15s, color 0.15s;
|
|
128
125
|
-webkit-tap-highlight-color: transparent;
|
|
126
|
+
user-select: none;
|
|
129
127
|
}
|
|
130
|
-
.
|
|
131
|
-
|
|
128
|
+
.session-tab:hover { background: var(--border); color: var(--text); }
|
|
129
|
+
.session-tab.active {
|
|
130
|
+
background: var(--bg);
|
|
132
131
|
color: var(--text);
|
|
132
|
+
font-weight: 600;
|
|
133
133
|
}
|
|
134
|
-
.
|
|
135
|
-
background:
|
|
136
|
-
color:
|
|
134
|
+
.session-tab.in-split {
|
|
135
|
+
background: rgba(0,120,212,0.1);
|
|
136
|
+
color: var(--text);
|
|
137
137
|
}
|
|
138
|
-
.
|
|
139
|
-
|
|
138
|
+
.session-tab.dragging { opacity: 0.4; }
|
|
139
|
+
.tab-dot {
|
|
140
|
+
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
140
141
|
}
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
.tab-name { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
|
|
143
|
+
.tab-activity {
|
|
144
|
+
font-size: 10px; color: var(--text-muted); flex-shrink: 0;
|
|
145
|
+
}
|
|
146
|
+
.tab-status {
|
|
147
|
+
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
|
148
|
+
}
|
|
149
|
+
.tab-close {
|
|
150
|
+
display: none;
|
|
151
|
+
background: none; border: none; color: var(--text-muted);
|
|
152
|
+
font-size: 14px; cursor: pointer; padding: 0 2px; line-height: 1;
|
|
153
|
+
transition: color 0.15s;
|
|
154
|
+
}
|
|
155
|
+
.session-tab:hover .tab-close,
|
|
156
|
+
.session-tab.active .tab-close { display: block; }
|
|
157
|
+
.tab-close:hover { color: var(--danger); }
|
|
158
|
+
/* ===== Tab Preview ===== */
|
|
159
|
+
#tab-preview {
|
|
160
|
+
display: none;
|
|
161
|
+
position: fixed;
|
|
162
|
+
z-index: 300;
|
|
163
|
+
width: min(85vw, 340px);
|
|
144
164
|
background: var(--bg);
|
|
145
|
-
border
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
border: 1px solid var(--border);
|
|
166
|
+
border-radius: 10px;
|
|
167
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
pointer-events: none;
|
|
149
170
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
font-weight: 600;
|
|
171
|
+
#tab-preview.visible { display: block; }
|
|
172
|
+
.preview-header {
|
|
173
|
+
display: flex; align-items: center; gap: 6px;
|
|
174
|
+
padding: 6px 10px;
|
|
175
|
+
border-bottom: 1px solid var(--border);
|
|
176
|
+
font-size: 12px; font-weight: 600;
|
|
156
177
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
padding: 0 10px;
|
|
169
|
-
font-size: 11px;
|
|
170
|
-
font-weight: 600;
|
|
171
|
-
margin-left: 2px;
|
|
172
|
-
transition: background 0.15s, color 0.15s, transform 0.1s;
|
|
173
|
-
-webkit-tap-highlight-color: transparent;
|
|
178
|
+
.preview-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
179
|
+
.preview-body {
|
|
180
|
+
padding: 6px 8px;
|
|
181
|
+
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
182
|
+
font-size: 10px;
|
|
183
|
+
line-height: 1.35;
|
|
184
|
+
color: var(--text);
|
|
185
|
+
white-space: pre;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
max-height: 140px;
|
|
188
|
+
-webkit-text-size-adjust: none;
|
|
174
189
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
.preview-empty {
|
|
191
|
+
padding: 12px 10px;
|
|
192
|
+
font-size: 11px;
|
|
193
|
+
color: var(--text-muted);
|
|
194
|
+
text-align: center;
|
|
195
|
+
font-style: italic;
|
|
178
196
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
color:
|
|
182
|
-
|
|
197
|
+
|
|
198
|
+
.tab-bar-btn {
|
|
199
|
+
background: none; border: none; color: var(--text-dim);
|
|
200
|
+
width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
|
|
201
|
+
display: flex; align-items: center; justify-content: center;
|
|
202
|
+
flex-shrink: 0; font-size: 18px; font-weight: 600;
|
|
203
|
+
transition: background 0.15s, color 0.15s;
|
|
204
|
+
-webkit-tap-highlight-color: transparent;
|
|
183
205
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
display: inline-block;
|
|
190
|
-
transition: background 0.3s;
|
|
206
|
+
.tab-bar-btn:hover { background: var(--border); color: var(--text); }
|
|
207
|
+
.tab-bar-btn:active { background: var(--accent); color: #fff; }
|
|
208
|
+
.tab-bar-btn.active { color: var(--accent); }
|
|
209
|
+
#tab-new-btn {
|
|
210
|
+
gap: 3px; width: auto; padding: 0 10px; font-size: 12px;
|
|
191
211
|
}
|
|
192
|
-
|
|
193
|
-
|
|
212
|
+
.new-btn-label { font-size: 11px; font-weight: 600; }
|
|
213
|
+
|
|
214
|
+
.bar-btn {
|
|
215
|
+
background: none; border: none; color: var(--text-dim);
|
|
216
|
+
width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
|
|
217
|
+
display: flex; align-items: center; justify-content: center;
|
|
218
|
+
transition: background 0.15s, color 0.15s;
|
|
219
|
+
-webkit-tap-highlight-color: transparent;
|
|
194
220
|
}
|
|
195
|
-
|
|
196
|
-
|
|
221
|
+
.bar-btn:hover { background: var(--border); color: var(--text); }
|
|
222
|
+
.bar-btn:active { background: var(--accent); color: #fff; }
|
|
223
|
+
.bar-btn svg { display: block; }
|
|
224
|
+
.bar-group {
|
|
225
|
+
display: flex; align-items: center; background: var(--bg);
|
|
226
|
+
border-radius: 8px; padding: 2px; gap: 1px; transition: background 0.3s;
|
|
197
227
|
}
|
|
198
|
-
|
|
228
|
+
.bar-group .bar-btn { width: 26px; height: 26px; border-radius: 6px; font-size: 14px; font-weight: 600; }
|
|
229
|
+
#stop-btn {
|
|
230
|
+
background: none; border: none; color: var(--danger); height: 30px;
|
|
231
|
+
border-radius: 8px; cursor: pointer; display: flex; align-items: center;
|
|
232
|
+
justify-content: center; gap: 4px; padding: 0 8px; font-size: 11px;
|
|
199
233
|
font-weight: 600;
|
|
234
|
+
transition: background 0.15s, color 0.15s, transform 0.1s;
|
|
235
|
+
-webkit-tap-highlight-color: transparent;
|
|
200
236
|
}
|
|
237
|
+
#stop-btn:hover { background: var(--danger); color: #fff; }
|
|
238
|
+
#stop-btn:active { background: var(--danger-hover); color: #fff; transform: scale(0.9); }
|
|
201
239
|
|
|
202
|
-
|
|
240
|
+
/* ===== Terminals Wrapper ===== */
|
|
241
|
+
#terminals-wrapper {
|
|
203
242
|
position: absolute;
|
|
204
|
-
top:
|
|
205
|
-
left:
|
|
206
|
-
right:
|
|
207
|
-
bottom: 52px;
|
|
208
|
-
|
|
243
|
+
top: calc(41px + env(safe-area-inset-top, 0px));
|
|
244
|
+
left: env(safe-area-inset-left, 0px);
|
|
245
|
+
right: env(safe-area-inset-right, 0px);
|
|
246
|
+
bottom: calc(52px + env(safe-area-inset-bottom, 0px));
|
|
247
|
+
display: flex;
|
|
209
248
|
overflow: hidden;
|
|
210
249
|
}
|
|
250
|
+
#terminals-wrapper.split-h { flex-direction: row; }
|
|
251
|
+
#terminals-wrapper.split-v { flex-direction: column; }
|
|
252
|
+
.terminal-pane {
|
|
253
|
+
flex: 1; padding: 2px; overflow: hidden; position: relative;
|
|
254
|
+
display: none;
|
|
255
|
+
}
|
|
256
|
+
.terminal-pane.visible { display: block; }
|
|
257
|
+
#terminals-wrapper.split-h .terminal-pane.visible + .terminal-pane.visible {
|
|
258
|
+
border-left: 2px solid var(--accent);
|
|
259
|
+
}
|
|
260
|
+
#terminals-wrapper.split-v .terminal-pane.visible + .terminal-pane.visible {
|
|
261
|
+
border-top: 2px solid var(--accent);
|
|
262
|
+
}
|
|
211
263
|
|
|
264
|
+
/* ===== Key Bar ===== */
|
|
212
265
|
#key-bar {
|
|
213
|
-
position: fixed;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
right: 0;
|
|
217
|
-
height: 52px;
|
|
218
|
-
display: flex;
|
|
219
|
-
align-items: center;
|
|
220
|
-
background: var(--surface);
|
|
266
|
+
position: fixed; bottom: 0; left: 0; right: 0;
|
|
267
|
+
height: calc(52px + env(safe-area-inset-bottom, 0px));
|
|
268
|
+
display: flex; align-items: center; background: var(--surface);
|
|
221
269
|
border-top: 1px solid var(--border);
|
|
222
|
-
padding: 0 4px;
|
|
223
|
-
|
|
224
|
-
gap: 4px;
|
|
225
|
-
z-index: 50;
|
|
270
|
+
padding: 0 calc(4px + env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) calc(4px + env(safe-area-inset-left, 0px));
|
|
271
|
+
gap: 4px; z-index: 50;
|
|
226
272
|
transition: background 0.3s, border-color 0.3s;
|
|
227
273
|
}
|
|
228
274
|
.key-btn {
|
|
229
|
-
min-width: 0;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
font-size: 12px;
|
|
236
|
-
font-weight: 600;
|
|
237
|
-
cursor: pointer;
|
|
238
|
-
display: flex;
|
|
239
|
-
flex-direction: column;
|
|
240
|
-
align-items: center;
|
|
241
|
-
justify-content: center;
|
|
242
|
-
-webkit-tap-highlight-color: transparent;
|
|
243
|
-
user-select: none;
|
|
244
|
-
white-space: nowrap;
|
|
245
|
-
padding: 2px 8px;
|
|
246
|
-
flex: 1 1 0;
|
|
247
|
-
gap: 0;
|
|
248
|
-
line-height: 1;
|
|
275
|
+
min-width: 0; height: 40px; background: var(--key-bg); color: var(--text);
|
|
276
|
+
border: 1px solid var(--key-border); border-radius: 8px; font-size: 12px;
|
|
277
|
+
font-weight: 600; cursor: pointer; display: flex; flex-direction: column;
|
|
278
|
+
align-items: center; justify-content: center;
|
|
279
|
+
-webkit-tap-highlight-color: transparent; user-select: none;
|
|
280
|
+
white-space: nowrap; padding: 2px 8px; flex: 1 1 0; gap: 0; line-height: 1;
|
|
249
281
|
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s, box-shadow 0.15s;
|
|
250
282
|
box-shadow: 0 1px 3px var(--key-shadow), inset 0 1px 0 rgba(255,255,255,0.05);
|
|
251
283
|
}
|
|
252
|
-
.key-btn .hint {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
284
|
+
.key-btn .hint { font-size: 8px; font-weight: 400; opacity: 0.5; margin-top: 1px; letter-spacing: 0.02em; }
|
|
285
|
+
.key-btn:hover { background: var(--border); border-color: var(--accent); box-shadow: 0 2px 6px var(--key-shadow); }
|
|
286
|
+
.key-btn:active { background: var(--accent); color: #fff; border-color: var(--accent); transform: scale(0.93); box-shadow: none; }
|
|
287
|
+
.key-btn.wide { flex: 1.4 1 0; }
|
|
288
|
+
.key-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
|
|
289
|
+
|
|
290
|
+
.xterm { height: 100% !important; }
|
|
291
|
+
.xterm-viewport { overflow-y: auto !important; scrollbar-width: none; overscroll-behavior: contain; }
|
|
292
|
+
.xterm-viewport::-webkit-scrollbar { display: none; }
|
|
293
|
+
.terminal-pane,
|
|
294
|
+
.terminal-pane .xterm,
|
|
295
|
+
.terminal-pane .xterm-screen,
|
|
296
|
+
.terminal-pane .xterm-viewport,
|
|
297
|
+
.terminal-pane .xterm-helper-textarea { touch-action: none; }
|
|
298
|
+
|
|
299
|
+
/* ===== Toasts & Overlays ===== */
|
|
300
|
+
#copy-toast {
|
|
301
|
+
position: fixed; top: 48px; left: 50%;
|
|
302
|
+
transform: translateX(-50%) translateY(-8px);
|
|
303
|
+
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
|
304
|
+
padding: 6px 16px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
|
305
|
+
opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s; z-index: 200;
|
|
258
306
|
}
|
|
259
|
-
.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
307
|
+
#copy-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
308
|
+
|
|
309
|
+
#paste-overlay {
|
|
310
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
311
|
+
background: var(--overlay-bg); z-index: 150;
|
|
312
|
+
flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
|
263
313
|
}
|
|
264
|
-
.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
314
|
+
#paste-overlay.visible { display: flex; }
|
|
315
|
+
#paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
|
|
316
|
+
#paste-input {
|
|
317
|
+
width: 80%; max-width: 400px; min-height: 80px; background: var(--surface);
|
|
318
|
+
color: var(--text); border: 1px solid var(--border); border-radius: 8px;
|
|
319
|
+
padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
|
|
270
320
|
}
|
|
271
|
-
|
|
272
|
-
|
|
321
|
+
|
|
322
|
+
#reconnect-overlay {
|
|
323
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
324
|
+
background: var(--overlay-bg); z-index: 100;
|
|
325
|
+
flex-direction: column; align-items: center; justify-content: center; gap: 16px;
|
|
273
326
|
}
|
|
274
|
-
.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
327
|
+
#reconnect-overlay.visible { display: flex; }
|
|
328
|
+
#reconnect-overlay .msg { font-size: 17px; color: #fff; }
|
|
329
|
+
.overlay-actions { display: flex; gap: 12px; }
|
|
330
|
+
.overlay-actions button {
|
|
331
|
+
padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px;
|
|
332
|
+
font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.1s;
|
|
279
333
|
}
|
|
334
|
+
.overlay-actions button:active { transform: scale(0.95); }
|
|
335
|
+
#reconnect-btn { background: var(--accent); color: #fff; }
|
|
336
|
+
#reconnect-btn:hover { background: var(--accent-hover); }
|
|
337
|
+
#back-to-sessions { background: rgba(255,255,255,0.15); color: #fff; }
|
|
338
|
+
#back-to-sessions:hover { background: rgba(255,255,255,0.25); }
|
|
280
339
|
|
|
281
|
-
|
|
282
|
-
|
|
340
|
+
/* ===== Folder Browser ===== */
|
|
341
|
+
.cwd-picker {
|
|
342
|
+
display: flex;
|
|
343
|
+
gap: 8px;
|
|
283
344
|
}
|
|
284
|
-
.
|
|
285
|
-
|
|
345
|
+
.cwd-picker input {
|
|
346
|
+
flex: 1;
|
|
286
347
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
position: fixed;
|
|
290
|
-
top: 56px;
|
|
291
|
-
left: 50%;
|
|
292
|
-
transform: translateX(-50%) translateY(-8px);
|
|
293
|
-
background: var(--surface);
|
|
348
|
+
.cwd-browse-btn {
|
|
349
|
+
background: var(--border);
|
|
294
350
|
color: var(--text);
|
|
295
351
|
border: 1px solid var(--border);
|
|
296
|
-
padding: 6px 16px;
|
|
297
352
|
border-radius: 8px;
|
|
298
|
-
|
|
299
|
-
font-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
353
|
+
padding: 0 14px;
|
|
354
|
+
font-size: 18px;
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
flex-shrink: 0;
|
|
357
|
+
display: flex;
|
|
358
|
+
align-items: center;
|
|
359
|
+
transition: background 0.15s, border-color 0.15s;
|
|
304
360
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
transform: translateX(-50%) translateY(0);
|
|
361
|
+
.cwd-browse-btn:hover {
|
|
362
|
+
border-color: var(--accent);
|
|
308
363
|
}
|
|
309
|
-
|
|
310
|
-
|
|
364
|
+
.cwd-browse-btn:active {
|
|
365
|
+
background: var(--accent);
|
|
366
|
+
color: #ffffff;
|
|
367
|
+
}
|
|
368
|
+
.browser-overlay {
|
|
311
369
|
display: none;
|
|
312
370
|
position: fixed;
|
|
313
|
-
top: 0;
|
|
314
|
-
left: 0;
|
|
315
|
-
right: 0;
|
|
316
|
-
bottom: 0;
|
|
371
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
317
372
|
background: var(--overlay-bg);
|
|
318
|
-
z-index:
|
|
319
|
-
flex-direction: column;
|
|
320
|
-
align-items: center;
|
|
373
|
+
z-index: 300;
|
|
321
374
|
justify-content: center;
|
|
322
|
-
|
|
375
|
+
align-items: flex-end;
|
|
323
376
|
}
|
|
324
|
-
|
|
377
|
+
.browser-overlay.visible {
|
|
325
378
|
display: flex;
|
|
326
379
|
}
|
|
327
|
-
|
|
328
|
-
font-size: 15px;
|
|
329
|
-
color: #ffffff;
|
|
330
|
-
font-weight: 600;
|
|
331
|
-
}
|
|
332
|
-
#paste-input {
|
|
333
|
-
width: 80%;
|
|
334
|
-
max-width: 400px;
|
|
335
|
-
min-height: 80px;
|
|
380
|
+
.browser-sheet {
|
|
336
381
|
background: var(--surface);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
343
|
-
resize: vertical;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
#reconnect-overlay {
|
|
347
|
-
display: none;
|
|
348
|
-
position: fixed;
|
|
349
|
-
top: 0;
|
|
350
|
-
left: 0;
|
|
351
|
-
right: 0;
|
|
352
|
-
bottom: 0;
|
|
353
|
-
background: var(--overlay-bg);
|
|
354
|
-
z-index: 100;
|
|
382
|
+
border-radius: 16px 16px 0 0;
|
|
383
|
+
width: 100%;
|
|
384
|
+
max-width: 500px;
|
|
385
|
+
height: 80vh;
|
|
386
|
+
display: flex;
|
|
355
387
|
flex-direction: column;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
gap: 16px;
|
|
388
|
+
overflow: hidden;
|
|
389
|
+
transition: background 0.3s;
|
|
359
390
|
}
|
|
360
|
-
|
|
391
|
+
.browser-header {
|
|
392
|
+
padding: 16px 16px 12px;
|
|
393
|
+
border-bottom: 1px solid var(--border);
|
|
361
394
|
display: flex;
|
|
395
|
+
align-items: center;
|
|
396
|
+
justify-content: space-between;
|
|
362
397
|
}
|
|
363
|
-
|
|
398
|
+
.browser-header h3 {
|
|
364
399
|
font-size: 17px;
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
.overlay-actions {
|
|
368
|
-
display: flex;
|
|
369
|
-
gap: 12px;
|
|
400
|
+
font-weight: 600;
|
|
370
401
|
}
|
|
371
|
-
.
|
|
372
|
-
|
|
402
|
+
.browser-close {
|
|
403
|
+
background: none;
|
|
373
404
|
border: none;
|
|
374
|
-
|
|
375
|
-
font-size:
|
|
376
|
-
font-weight: 600;
|
|
405
|
+
color: var(--text-dim);
|
|
406
|
+
font-size: 24px;
|
|
377
407
|
cursor: pointer;
|
|
408
|
+
padding: 0 4px;
|
|
409
|
+
line-height: 1;
|
|
410
|
+
transition: color 0.15s;
|
|
411
|
+
}
|
|
412
|
+
.browser-close:hover { color: var(--text); }
|
|
413
|
+
.browser-close:active { color: var(--text); }
|
|
414
|
+
.browser-breadcrumb {
|
|
415
|
+
padding: 8px 16px;
|
|
416
|
+
display: flex;
|
|
417
|
+
align-items: center;
|
|
418
|
+
gap: 2px;
|
|
419
|
+
font-size: 13px;
|
|
420
|
+
overflow-x: auto;
|
|
421
|
+
white-space: nowrap;
|
|
422
|
+
border-bottom: 1px solid var(--border);
|
|
423
|
+
flex-shrink: 0;
|
|
424
|
+
-webkit-overflow-scrolling: touch;
|
|
425
|
+
}
|
|
426
|
+
.crumb {
|
|
427
|
+
background: none; border: none; color: var(--text-dim);
|
|
428
|
+
font-size: 13px; cursor: pointer; padding: 4px 6px;
|
|
429
|
+
border-radius: 4px; flex-shrink: 0;
|
|
430
|
+
transition: background 0.15s, color 0.15s;
|
|
431
|
+
}
|
|
432
|
+
.crumb:active, .crumb:hover { background: var(--border); color: var(--text); }
|
|
433
|
+
.crumb.current { color: var(--text); font-weight: 600; }
|
|
434
|
+
.crumb-sep { color: var(--border-subtle); flex-shrink: 0; }
|
|
435
|
+
.browser-list {
|
|
436
|
+
flex: 1; overflow-y: auto; padding: 4px 0;
|
|
437
|
+
-webkit-overflow-scrolling: touch;
|
|
438
|
+
}
|
|
439
|
+
.browser-empty {
|
|
440
|
+
text-align: center; padding: 40px 20px;
|
|
441
|
+
color: var(--text-muted); font-size: 14px;
|
|
442
|
+
}
|
|
443
|
+
.folder-item {
|
|
444
|
+
display: flex; align-items: center; gap: 12px;
|
|
445
|
+
padding: 12px 16px; cursor: pointer;
|
|
446
|
+
border-bottom: 1px solid rgba(60,60,60,0.5);
|
|
447
|
+
transition: background 0.1s;
|
|
448
|
+
-webkit-tap-highlight-color: transparent;
|
|
449
|
+
}
|
|
450
|
+
.folder-item:active { background: rgba(0,120,212,0.2); }
|
|
451
|
+
.folder-item:hover { background: rgba(0,120,212,0.1); }
|
|
452
|
+
.folder-icon { font-size: 22px; flex-shrink: 0; width: 28px; text-align: center; }
|
|
453
|
+
.folder-name {
|
|
454
|
+
font-size: 15px; color: var(--text); flex: 1;
|
|
455
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
456
|
+
}
|
|
457
|
+
.folder-arrow { color: var(--border-subtle); font-size: 18px; flex-shrink: 0; }
|
|
458
|
+
.browser-footer {
|
|
459
|
+
padding: 12px 16px calc(env(safe-area-inset-bottom, 8px) + 8px);
|
|
460
|
+
border-top: 1px solid var(--border);
|
|
461
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
462
|
+
}
|
|
463
|
+
.browser-current-path {
|
|
464
|
+
font-size: 12px; color: var(--text-dim);
|
|
465
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
466
|
+
}
|
|
467
|
+
.browser-select-btn {
|
|
468
|
+
width: 100%; padding: 12px; background: var(--accent);
|
|
469
|
+
color: #ffffff; border: none; border-radius: 10px;
|
|
470
|
+
font-size: 16px; font-weight: 600; cursor: pointer;
|
|
378
471
|
transition: background 0.15s, transform 0.1s;
|
|
379
472
|
}
|
|
380
|
-
.
|
|
381
|
-
|
|
473
|
+
.browser-select-btn:hover { background: var(--accent-hover); }
|
|
474
|
+
.browser-select-btn:active { background: var(--accent-active); transform: scale(0.98); }
|
|
475
|
+
|
|
476
|
+
/* ===== New Session Modal ===== */
|
|
477
|
+
.modal-overlay {
|
|
478
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
479
|
+
background: var(--overlay-bg); z-index: 200;
|
|
480
|
+
justify-content: center; align-items: center;
|
|
382
481
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
482
|
+
.modal-overlay.visible { display: flex; }
|
|
483
|
+
.modal {
|
|
484
|
+
background: var(--surface); border-radius: 16px; width: 90%; max-width: 500px;
|
|
485
|
+
padding: 24px 20px; transition: background 0.3s; max-height: 90vh; overflow-y: auto;
|
|
386
486
|
}
|
|
387
|
-
|
|
388
|
-
|
|
487
|
+
.modal h2 { font-size: 18px; margin-bottom: 16px; }
|
|
488
|
+
.modal label {
|
|
489
|
+
display: block; font-size: 13px; color: var(--text-secondary);
|
|
490
|
+
margin-bottom: 4px; margin-top: 12px;
|
|
389
491
|
}
|
|
390
|
-
|
|
391
|
-
background:
|
|
392
|
-
color:
|
|
492
|
+
.modal input, .modal select {
|
|
493
|
+
width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border);
|
|
494
|
+
border-radius: 8px; color: var(--text); font-size: 15px; outline: none;
|
|
495
|
+
-webkit-appearance: none; appearance: none;
|
|
496
|
+
transition: background 0.3s, border-color 0.15s, color 0.3s;
|
|
497
|
+
}
|
|
498
|
+
.modal select {
|
|
499
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
|
500
|
+
background-repeat: no-repeat; background-position: right 12px center;
|
|
501
|
+
padding-right: 32px; cursor: pointer;
|
|
502
|
+
}
|
|
503
|
+
.modal input:focus, .modal select:focus { border-color: var(--accent); }
|
|
504
|
+
.modal-actions { display: flex; gap: 12px; margin-top: 20px; }
|
|
505
|
+
.modal-actions button {
|
|
506
|
+
flex: 1; padding: 12px; border: none; border-radius: 8px;
|
|
507
|
+
font-size: 15px; font-weight: 600; cursor: pointer;
|
|
508
|
+
transition: background 0.15s, transform 0.1s;
|
|
509
|
+
}
|
|
510
|
+
.modal-actions button:active { transform: scale(0.95); }
|
|
511
|
+
.btn-cancel { background: var(--border); color: var(--text); }
|
|
512
|
+
.btn-cancel:hover { background: var(--border-subtle); }
|
|
513
|
+
.btn-create { background: var(--accent); color: #fff; }
|
|
514
|
+
.btn-create:hover { background: var(--accent-hover); }
|
|
515
|
+
|
|
516
|
+
.color-picker { display: flex; gap: 8px; padding: 6px 0; flex-wrap: wrap; }
|
|
517
|
+
.color-swatch {
|
|
518
|
+
width: 32px; height: 32px; border-radius: 50%; border: 3px solid transparent;
|
|
519
|
+
cursor: pointer; padding: 0; outline: none;
|
|
520
|
+
transition: border-color 0.15s, transform 0.1s;
|
|
521
|
+
-webkit-tap-highlight-color: transparent;
|
|
522
|
+
}
|
|
523
|
+
.color-swatch:hover { transform: scale(1.1); }
|
|
524
|
+
.color-swatch.selected { border-color: var(--text); transform: scale(1.15); }
|
|
525
|
+
|
|
526
|
+
/* ===== Sessions Side Panel (mobile) ===== */
|
|
527
|
+
#panel-toggle {
|
|
528
|
+
display: none;
|
|
529
|
+
background: none; border: none; color: var(--text-dim);
|
|
530
|
+
width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
|
|
531
|
+
align-items: center; justify-content: center;
|
|
532
|
+
flex-shrink: 0;
|
|
533
|
+
transition: background 0.15s, color 0.15s;
|
|
534
|
+
-webkit-tap-highlight-color: transparent;
|
|
535
|
+
}
|
|
536
|
+
#panel-toggle:hover { background: var(--border); color: var(--text); }
|
|
537
|
+
|
|
538
|
+
#side-panel-backdrop {
|
|
539
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
540
|
+
background: rgba(0,0,0,0.5); z-index: 400;
|
|
541
|
+
opacity: 0; transition: opacity 0.25s;
|
|
542
|
+
}
|
|
543
|
+
#side-panel-backdrop.visible { display: block; opacity: 1; }
|
|
544
|
+
|
|
545
|
+
#side-panel {
|
|
546
|
+
position: fixed; top: 0; left: 0; bottom: 0;
|
|
547
|
+
width: min(85vw, 380px);
|
|
548
|
+
background: var(--surface);
|
|
549
|
+
border-right: 1px solid var(--border);
|
|
550
|
+
z-index: 410;
|
|
551
|
+
transform: translateX(-100%);
|
|
552
|
+
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s;
|
|
553
|
+
display: flex; flex-direction: column;
|
|
554
|
+
overflow: hidden;
|
|
555
|
+
padding-top: env(safe-area-inset-top, 0px);
|
|
556
|
+
padding-left: env(safe-area-inset-left, 0px);
|
|
557
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
393
558
|
}
|
|
394
|
-
#
|
|
395
|
-
|
|
559
|
+
#side-panel.open { transform: translateX(0); }
|
|
560
|
+
|
|
561
|
+
.side-panel-header {
|
|
562
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
563
|
+
padding: 12px 14px; border-bottom: 1px solid var(--border);
|
|
564
|
+
font-size: 15px; font-weight: 700;
|
|
565
|
+
}
|
|
566
|
+
.side-panel-close {
|
|
567
|
+
background: none; border: none; color: var(--text-dim);
|
|
568
|
+
width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
|
|
569
|
+
display: flex; align-items: center; justify-content: center;
|
|
570
|
+
font-size: 20px; transition: background 0.15s, color 0.15s;
|
|
571
|
+
-webkit-tap-highlight-color: transparent;
|
|
572
|
+
}
|
|
573
|
+
.side-panel-close:hover { background: var(--border); color: var(--text); }
|
|
574
|
+
|
|
575
|
+
.side-panel-list {
|
|
576
|
+
flex: 1; overflow-y: auto; padding: 8px;
|
|
577
|
+
-webkit-overflow-scrolling: touch;
|
|
578
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.side-panel-card {
|
|
582
|
+
background: var(--bg); border: 1px solid var(--border);
|
|
583
|
+
border-radius: 10px; cursor: pointer; overflow: hidden;
|
|
584
|
+
transition: background 0.15s, border-color 0.15s;
|
|
585
|
+
-webkit-tap-highlight-color: transparent;
|
|
586
|
+
}
|
|
587
|
+
.side-panel-card:hover { border-color: var(--accent); }
|
|
588
|
+
.side-panel-card.active { border-color: var(--accent); border-width: 2px; }
|
|
589
|
+
|
|
590
|
+
.side-panel-card-header {
|
|
591
|
+
display: flex; align-items: center; gap: 8px;
|
|
592
|
+
padding: 10px 12px 6px;
|
|
593
|
+
}
|
|
594
|
+
.side-panel-card-dot {
|
|
595
|
+
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
|
596
|
+
}
|
|
597
|
+
.side-panel-card-name {
|
|
598
|
+
font-size: 13px; font-weight: 600; flex: 1;
|
|
599
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
600
|
+
}
|
|
601
|
+
.side-panel-card-status {
|
|
602
|
+
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
|
603
|
+
}
|
|
604
|
+
.side-panel-card-close {
|
|
605
|
+
background: none; border: none; color: var(--text-muted);
|
|
606
|
+
width: 26px; height: 26px; border-radius: 6px; cursor: pointer;
|
|
607
|
+
display: flex; align-items: center; justify-content: center;
|
|
608
|
+
font-size: 16px; flex-shrink: 0; padding: 0;
|
|
609
|
+
transition: background 0.15s, color 0.15s;
|
|
610
|
+
-webkit-tap-highlight-color: transparent;
|
|
611
|
+
}
|
|
612
|
+
.side-panel-card-close:hover { background: var(--danger); color: #fff; }
|
|
613
|
+
.side-panel-card-meta {
|
|
614
|
+
padding: 0 12px 4px;
|
|
615
|
+
font-size: 10px; color: var(--text-muted);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.side-panel-card-preview {
|
|
619
|
+
margin: 0 8px 8px;
|
|
620
|
+
padding: 6px 8px;
|
|
621
|
+
background: var(--surface);
|
|
622
|
+
border-radius: 6px;
|
|
623
|
+
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
624
|
+
font-size: 9px;
|
|
625
|
+
line-height: 1.3;
|
|
626
|
+
color: var(--text-secondary);
|
|
627
|
+
white-space: pre;
|
|
628
|
+
overflow: hidden;
|
|
629
|
+
max-height: 72px;
|
|
630
|
+
-webkit-text-size-adjust: none;
|
|
631
|
+
}
|
|
632
|
+
.side-panel-card-preview.empty {
|
|
633
|
+
font-style: italic; color: var(--text-muted);
|
|
634
|
+
text-align: center; font-family: inherit; font-size: 11px;
|
|
635
|
+
white-space: normal;
|
|
396
636
|
}
|
|
397
637
|
|
|
638
|
+
@media (max-width: 640px) {
|
|
639
|
+
#panel-toggle { display: flex; }
|
|
640
|
+
#tab-list { display: none; }
|
|
641
|
+
#split-toggle { display: none; }
|
|
642
|
+
#version-text { display: none; }
|
|
643
|
+
#back-btn { display: none; }
|
|
644
|
+
#theme-toggle { display: flex; }
|
|
645
|
+
#stop-btn { padding: 0 8px; }
|
|
646
|
+
#session-name { max-width: 30vw; }
|
|
647
|
+
#status-text { display: none; }
|
|
648
|
+
#tab-new-btn { font-size: 13px; padding: 0 8px; width: auto; }
|
|
649
|
+
}
|
|
398
650
|
</style>
|
|
399
651
|
</head>
|
|
400
652
|
<body>
|
|
401
|
-
|
|
653
|
+
<!-- Sessions Side Panel -->
|
|
654
|
+
<div id="side-panel-backdrop"></div>
|
|
655
|
+
<div id="side-panel">
|
|
656
|
+
<div class="side-panel-header">
|
|
657
|
+
<span>Sessions</span>
|
|
658
|
+
<button class="side-panel-close" id="side-panel-close" title="Close">×</button>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="side-panel-list" id="side-panel-list"></div>
|
|
661
|
+
<div style="padding:8px;border-top:1px solid var(--border)">
|
|
662
|
+
<button id="side-panel-new-btn" style="width:100%;padding:10px;border:1px dashed var(--border);border-radius:8px;background:none;color:var(--accent);font-size:14px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background 0.15s">+ New Session</button>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
|
|
666
|
+
<!-- Top Bar (unified) -->
|
|
667
|
+
<div id="top-bar">
|
|
402
668
|
<div class="left">
|
|
403
|
-
<button
|
|
669
|
+
<button id="panel-toggle" title="Sessions">
|
|
670
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
671
|
+
</button>
|
|
672
|
+
<button class="bar-btn" id="back-btn" onclick="location.href='/'" title="Back to sessions"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
|
404
673
|
<span id="status-dot"></span>
|
|
405
674
|
<span id="session-name">…</span>
|
|
675
|
+
<span id="status-text">Connecting…</span>
|
|
406
676
|
</div>
|
|
677
|
+
<div id="tab-list"></div>
|
|
407
678
|
<div class="right">
|
|
408
|
-
<span id="
|
|
409
|
-
<
|
|
679
|
+
<span id="version-text" style="font-size:11px;color:var(--text-muted)"></span>
|
|
680
|
+
<button class="tab-bar-btn" id="tab-new-btn" title="New session"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="new-btn-label">New</span></button>
|
|
681
|
+
<button class="tab-bar-btn" id="split-toggle" title="Split view">
|
|
682
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
|
|
683
|
+
</button>
|
|
410
684
|
<div class="bar-group">
|
|
411
685
|
<button class="bar-btn" id="zoom-out" title="Decrease font size">−</button>
|
|
412
686
|
<button class="bar-btn" id="zoom-in" title="Increase font size">+</button>
|
|
413
687
|
</div>
|
|
688
|
+
<button class="bar-btn" id="share-btn" title="Share link"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></button>
|
|
689
|
+
<button class="bar-btn" id="refresh-btn" title="Refresh app"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
|
414
690
|
<button class="bar-btn" id="theme-toggle" title="Toggle theme"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button>
|
|
415
691
|
<button id="stop-btn" title="Stop session"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
|
|
416
692
|
</div>
|
|
417
693
|
</div>
|
|
418
694
|
|
|
419
|
-
|
|
695
|
+
<!-- Tab Preview Popover -->
|
|
696
|
+
<div id="tab-preview">
|
|
697
|
+
<div class="preview-header">
|
|
698
|
+
<span class="preview-dot" id="preview-dot"></span>
|
|
699
|
+
<span id="preview-name"></span>
|
|
700
|
+
</div>
|
|
701
|
+
<div class="preview-body" id="preview-body"></div>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
<!-- Terminals Wrapper (panes created dynamically) -->
|
|
705
|
+
<div id="terminals-wrapper"></div>
|
|
706
|
+
|
|
420
707
|
<div id="copy-toast">Copied!</div>
|
|
421
708
|
|
|
422
709
|
<div id="key-bar">
|
|
@@ -432,12 +719,14 @@
|
|
|
432
719
|
<button class="key-btn wide" data-key="	" title="Autocomplete">Tab</button>
|
|
433
720
|
<div class="key-sep"></div>
|
|
434
721
|
<button class="key-btn" data-key="" title="Interrupt process">^C<span class="hint">stop</span></button>
|
|
722
|
+
<div class="key-sep"></div>
|
|
723
|
+
<button class="key-btn" data-key="enter" title="Enter / Return">↵<span class="hint">enter</span></button>
|
|
435
724
|
</div>
|
|
436
725
|
|
|
437
726
|
<div id="reconnect-overlay">
|
|
438
727
|
<div class="msg">Session disconnected</div>
|
|
439
728
|
<div class="overlay-actions">
|
|
440
|
-
<button id="back-to-sessions" onclick="location.href
|
|
729
|
+
<button id="back-to-sessions" onclick="location.href='/'">Sessions</button>
|
|
441
730
|
<button id="reconnect-btn">Reconnect</button>
|
|
442
731
|
</div>
|
|
443
732
|
</div>
|
|
@@ -446,8 +735,61 @@
|
|
|
446
735
|
<label for="paste-input">Paste your text below</label>
|
|
447
736
|
<textarea id="paste-input" placeholder="Long-press here and paste…"></textarea>
|
|
448
737
|
<div class="overlay-actions">
|
|
449
|
-
<button id="paste-cancel" style="background:
|
|
450
|
-
<button id="paste-send" style="background:
|
|
738
|
+
<button id="paste-cancel" style="background:rgba(255,255,255,0.15);color:#fff;">Cancel</button>
|
|
739
|
+
<button id="paste-send" style="background:var(--accent);color:#fff;">Send</button>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<!-- New Session Modal -->
|
|
744
|
+
<div class="modal-overlay" id="new-session-modal">
|
|
745
|
+
<div class="modal">
|
|
746
|
+
<h2>New Session</h2>
|
|
747
|
+
<label for="ns-name">Name</label>
|
|
748
|
+
<input type="text" id="ns-name" placeholder="My Session" />
|
|
749
|
+
<label for="ns-shell">Shell</label>
|
|
750
|
+
<select id="ns-shell"><option value="">Loading shells…</option></select>
|
|
751
|
+
<label for="ns-cmd">Initial Command <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
|
|
752
|
+
<input type="text" id="ns-cmd" placeholder="e.g. htop, vim" />
|
|
753
|
+
<label>Color</label>
|
|
754
|
+
<div class="color-picker" id="ns-color-picker">
|
|
755
|
+
<button type="button" class="color-swatch selected" data-color="#4a9eff" style="background:#4a9eff" title="Blue"></button>
|
|
756
|
+
<button type="button" class="color-swatch" data-color="#4ade80" style="background:#4ade80" title="Green"></button>
|
|
757
|
+
<button type="button" class="color-swatch" data-color="#fbbf24" style="background:#fbbf24" title="Amber"></button>
|
|
758
|
+
<button type="button" class="color-swatch" data-color="#c084fc" style="background:#c084fc" title="Purple"></button>
|
|
759
|
+
<button type="button" class="color-swatch" data-color="#f87171" style="background:#f87171" title="Red"></button>
|
|
760
|
+
<button type="button" class="color-swatch" data-color="#22d3ee" style="background:#22d3ee" title="Cyan"></button>
|
|
761
|
+
<button type="button" class="color-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
|
|
762
|
+
<button type="button" class="color-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
|
|
763
|
+
</div>
|
|
764
|
+
<label for="ns-cwd">Working Directory <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
|
|
765
|
+
<div class="cwd-picker">
|
|
766
|
+
<input type="text" id="ns-cwd" placeholder="Uses server default" />
|
|
767
|
+
<button type="button" class="cwd-browse-btn" id="ns-browse-btn" title="Browse folders">
|
|
768
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
|
769
|
+
</button>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="modal-actions">
|
|
772
|
+
<button class="btn-cancel" id="ns-cancel">Cancel</button>
|
|
773
|
+
<button class="btn-create" id="ns-create">Create</button>
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
<!-- Folder Browser -->
|
|
779
|
+
<div class="browser-overlay" id="ns-browser-overlay">
|
|
780
|
+
<div class="browser-sheet">
|
|
781
|
+
<div class="browser-header">
|
|
782
|
+
<h3>
|
|
783
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 6px"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>Choose Folder
|
|
784
|
+
</h3>
|
|
785
|
+
<button class="browser-close" id="ns-browser-close">×</button>
|
|
786
|
+
</div>
|
|
787
|
+
<div class="browser-breadcrumb" id="ns-browser-breadcrumb"></div>
|
|
788
|
+
<div class="browser-list" id="ns-browser-list"></div>
|
|
789
|
+
<div class="browser-footer">
|
|
790
|
+
<div class="browser-current-path" id="ns-browser-path">/</div>
|
|
791
|
+
<button class="browser-select-btn" id="ns-browser-select">Select This Folder</button>
|
|
792
|
+
</div>
|
|
451
793
|
</div>
|
|
452
794
|
</div>
|
|
453
795
|
|
|
@@ -455,21 +797,24 @@
|
|
|
455
797
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
456
798
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
457
799
|
<script>
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
800
|
+
// ===== Constants =====
|
|
801
|
+
const SESSION_COLORS = ['#4a9eff','#4ade80','#fbbf24','#c084fc','#f87171','#22d3ee','#fb923c','#f472b6'];
|
|
802
|
+
|
|
803
|
+
// ===== State =====
|
|
804
|
+
const managed = new Map(); // sessionId -> ManagedSession
|
|
805
|
+
let activeId = null;
|
|
806
|
+
let splitMode = false;
|
|
807
|
+
let splitSecondId = null;
|
|
462
808
|
|
|
809
|
+
// ===== DOM Refs =====
|
|
463
810
|
const statusDot = document.getElementById('status-dot');
|
|
464
811
|
const statusText = document.getElementById('status-text');
|
|
465
|
-
const
|
|
812
|
+
const sessionNameEl = document.getElementById('session-name');
|
|
466
813
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
let reconnectDelay = 3000;
|
|
470
|
-
const MAX_RECONNECT_DELAY = 30000;
|
|
814
|
+
const tabListEl = document.getElementById('tab-list');
|
|
815
|
+
const terminalsWrapper = document.getElementById('terminals-wrapper');
|
|
471
816
|
|
|
472
|
-
// Terminal
|
|
817
|
+
// ===== Terminal Themes =====
|
|
473
818
|
const darkTermTheme = {
|
|
474
819
|
background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#aeafad', cursorAccent: '#1e1e1e',
|
|
475
820
|
selectionBackground: 'rgba(38, 79, 120, 0.5)',
|
|
@@ -489,200 +834,849 @@
|
|
|
489
834
|
brightCyan: '#0598bc', brightWhite: '#a5a5a5',
|
|
490
835
|
};
|
|
491
836
|
|
|
837
|
+
// ===== Theme =====
|
|
492
838
|
function getTheme() { return localStorage.getItem('termbeam-theme') || 'dark'; }
|
|
493
839
|
function applyTheme(theme) {
|
|
494
840
|
document.documentElement.setAttribute('data-theme', theme);
|
|
495
|
-
document.querySelector('meta[name="theme-color"]').content =
|
|
496
|
-
theme === 'light' ? '#f3f3f3' : '#1e1e1e';
|
|
841
|
+
document.querySelector('meta[name="theme-color"]').content = theme === 'light' ? '#f3f3f3' : '#1e1e1e';
|
|
497
842
|
const btn = document.getElementById('theme-toggle');
|
|
498
843
|
if (btn) btn.innerHTML = theme === 'light'
|
|
499
844
|
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
|
|
500
845
|
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
|
|
501
846
|
localStorage.setItem('termbeam-theme', theme);
|
|
502
|
-
|
|
503
|
-
|
|
847
|
+
for (const [, ms] of managed) {
|
|
848
|
+
ms.term.options.theme = theme === 'light' ? lightTermTheme : darkTermTheme;
|
|
504
849
|
}
|
|
505
850
|
}
|
|
506
851
|
applyTheme(getTheme());
|
|
507
|
-
|
|
508
852
|
document.getElementById('theme-toggle').addEventListener('click', () => {
|
|
509
853
|
applyTheme(getTheme() === 'light' ? 'dark' : 'light');
|
|
510
854
|
});
|
|
511
855
|
|
|
512
|
-
//
|
|
513
|
-
const nerdFont = new FontFace(
|
|
514
|
-
'
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
856
|
+
// ===== Font Loading (non-blocking) =====
|
|
857
|
+
const nerdFont = new FontFace('NerdFont',
|
|
858
|
+
"url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')");
|
|
859
|
+
nerdFont.load().then(font => { document.fonts.add(font); }).catch(() => { console.warn('Nerd Font failed to load, using fallback'); });
|
|
860
|
+
|
|
861
|
+
// Start immediately — don't wait for font
|
|
862
|
+
init();
|
|
863
|
+
|
|
864
|
+
// ===== Helpers =====
|
|
865
|
+
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
|
866
|
+
function escAttr(str) { return String(str).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>'); }
|
|
867
|
+
function safeColor(c) { return /^(#[0-9a-fA-F]{3,8}|var\(--[a-z-]+\)|[a-z]+)$/.test(c) ? c : 'var(--text-muted)'; }
|
|
868
|
+
|
|
869
|
+
function showToast(msg) {
|
|
870
|
+
const toast = document.getElementById('copy-toast');
|
|
871
|
+
toast.textContent = msg;
|
|
872
|
+
toast.classList.add('visible');
|
|
873
|
+
clearTimeout(toast._timer);
|
|
874
|
+
toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function getActivityLabel(ts) {
|
|
878
|
+
if (!ts) return '';
|
|
879
|
+
const diff = (Date.now() - ts) / 1000;
|
|
880
|
+
if (diff < 5) return '';
|
|
881
|
+
if (diff < 60) return Math.floor(diff) + 's';
|
|
882
|
+
if (diff < 3600) return Math.floor(diff / 60) + 'm';
|
|
883
|
+
return Math.floor(diff / 3600) + 'h';
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ===== Zoom =====
|
|
887
|
+
const MIN_FONT = 2, MAX_FONT = 28;
|
|
888
|
+
let fontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
|
|
889
|
+
|
|
890
|
+
function applyZoom(size) {
|
|
891
|
+
fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
|
|
892
|
+
localStorage.setItem('termbeam-fontsize', fontSize);
|
|
893
|
+
for (const [, ms] of managed) {
|
|
894
|
+
ms.term.options.fontSize = fontSize;
|
|
895
|
+
if (ms.container.classList.contains('visible')) {
|
|
896
|
+
ms.fitAddon.fit();
|
|
897
|
+
sendResize(ms);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function sendResize(ms) {
|
|
903
|
+
if (ms.ws && ms.ws.readyState === 1) {
|
|
904
|
+
const dims = ms.fitAddon.proposeDimensions();
|
|
905
|
+
if (dims && (dims.cols !== ms._lastCols || dims.rows !== ms._lastRows)) {
|
|
906
|
+
ms._lastCols = dims.cols;
|
|
907
|
+
ms._lastRows = dims.rows;
|
|
908
|
+
ms.ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ===== Tab Order (persisted in localStorage) =====
|
|
914
|
+
function getTabOrder() {
|
|
915
|
+
let order = JSON.parse(localStorage.getItem('termbeam-tab-order') || '[]');
|
|
916
|
+
const allIds = [...managed.keys()];
|
|
917
|
+
for (const id of allIds) { if (!order.includes(id)) order.push(id); }
|
|
918
|
+
order = order.filter(id => managed.has(id));
|
|
919
|
+
return order;
|
|
920
|
+
}
|
|
921
|
+
function saveTabOrder(order) { localStorage.setItem('termbeam-tab-order', JSON.stringify(order)); }
|
|
922
|
+
|
|
923
|
+
// ===== Init =====
|
|
924
|
+
async function init() {
|
|
925
|
+
const sessionList = await fetch('/api/sessions').then(r => r.json());
|
|
926
|
+
const initialId = new URLSearchParams(location.search).get('id');
|
|
927
|
+
|
|
928
|
+
for (const s of sessionList) addSession(s);
|
|
929
|
+
|
|
930
|
+
const startId = (initialId && managed.has(initialId))
|
|
931
|
+
? initialId
|
|
932
|
+
: (sessionList.length > 0 ? sessionList[0].id : null);
|
|
933
|
+
|
|
934
|
+
if (startId) activateSession(startId);
|
|
935
|
+
else { sessionNameEl.textContent = 'No sessions'; statusText.textContent = ''; document.getElementById('stop-btn').style.display = 'none'; }
|
|
936
|
+
|
|
937
|
+
renderTabs();
|
|
938
|
+
setupKeyBar();
|
|
939
|
+
setupPaste();
|
|
940
|
+
setupNewSessionModal();
|
|
941
|
+
startPolling();
|
|
942
|
+
|
|
943
|
+
// Zoom
|
|
944
|
+
document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
|
|
945
|
+
document.getElementById('zoom-out').addEventListener('click', () => applyZoom(fontSize - 2));
|
|
946
|
+
|
|
947
|
+
// Resize
|
|
948
|
+
function doResize() {
|
|
949
|
+
for (const [, ms] of managed) {
|
|
950
|
+
if (ms.container.classList.contains('visible')) { ms.fitAddon.fit(); sendResize(ms); }
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
window.addEventListener('resize', doResize);
|
|
954
|
+
screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
|
|
955
|
+
|
|
956
|
+
// Mobile soft keyboard
|
|
957
|
+
if (window.visualViewport) {
|
|
958
|
+
const keyBar = document.getElementById('key-bar');
|
|
959
|
+
let vpResizeTimer = null;
|
|
960
|
+
function resetScroll() {
|
|
961
|
+
window.scrollTo(0, 0);
|
|
962
|
+
document.documentElement.scrollTop = 0;
|
|
963
|
+
document.body.scrollTop = 0;
|
|
964
|
+
}
|
|
965
|
+
function onViewportResize() {
|
|
966
|
+
const vv = window.visualViewport;
|
|
967
|
+
const keyboardHeight = window.innerHeight - vv.height;
|
|
968
|
+
const keyboardOpen = keyboardHeight > 50;
|
|
969
|
+
if (keyboardOpen) {
|
|
970
|
+
keyBar.style.bottom = keyboardHeight + 'px';
|
|
971
|
+
keyBar.style.height = '52px';
|
|
972
|
+
keyBar.style.paddingBottom = '0px';
|
|
973
|
+
terminalsWrapper.style.bottom = (52 + keyboardHeight) + 'px';
|
|
974
|
+
} else {
|
|
975
|
+
keyBar.style.bottom = '0px';
|
|
976
|
+
keyBar.style.height = '';
|
|
977
|
+
keyBar.style.paddingBottom = '';
|
|
978
|
+
terminalsWrapper.style.bottom = '';
|
|
979
|
+
}
|
|
980
|
+
resetScroll();
|
|
981
|
+
clearTimeout(vpResizeTimer);
|
|
982
|
+
vpResizeTimer = setTimeout(() => {
|
|
983
|
+
resetScroll();
|
|
984
|
+
doResize();
|
|
985
|
+
}, 150);
|
|
986
|
+
}
|
|
987
|
+
function onViewportScroll() {
|
|
988
|
+
resetScroll();
|
|
989
|
+
}
|
|
990
|
+
window.visualViewport.addEventListener('resize', onViewportResize);
|
|
991
|
+
window.visualViewport.addEventListener('scroll', onViewportScroll);
|
|
992
|
+
// Page should never scroll — catch any browser-initiated scroll
|
|
993
|
+
// (e.g. iOS scrolling to show focused xterm textarea behind keyboard)
|
|
994
|
+
window.addEventListener('scroll', () => {
|
|
995
|
+
if (window.scrollY !== 0) resetScroll();
|
|
996
|
+
}, { passive: true });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Split toggle
|
|
1000
|
+
document.getElementById('split-toggle').addEventListener('click', toggleSplit);
|
|
1001
|
+
|
|
1002
|
+
// Scroll to bottom when returning from idle / tab switch
|
|
1003
|
+
document.addEventListener('visibilitychange', () => {
|
|
1004
|
+
if (!document.hidden && activeId) {
|
|
1005
|
+
const ms = managed.get(activeId);
|
|
1006
|
+
if (ms) {
|
|
1007
|
+
ms.term.scrollToBottom();
|
|
1008
|
+
ms.fitAddon.fit();
|
|
1009
|
+
sendResize(ms);
|
|
1010
|
+
}
|
|
1011
|
+
if (splitMode && splitSecondId) {
|
|
1012
|
+
const ms2 = managed.get(splitSecondId);
|
|
1013
|
+
if (ms2) {
|
|
1014
|
+
ms2.term.scrollToBottom();
|
|
1015
|
+
ms2.fitAddon.fit();
|
|
1016
|
+
sendResize(ms2);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
528
1020
|
});
|
|
529
1021
|
|
|
530
|
-
|
|
531
|
-
|
|
1022
|
+
// Reconnect button
|
|
1023
|
+
document.getElementById('reconnect-btn').addEventListener('click', () => {
|
|
1024
|
+
const ms = managed.get(activeId);
|
|
1025
|
+
if (ms) {
|
|
1026
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1027
|
+
ms.exited = false;
|
|
1028
|
+
ms.reconnectDelay = 3000;
|
|
1029
|
+
ms.term.clear();
|
|
1030
|
+
connectSession(ms);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Stop button
|
|
1035
|
+
document.getElementById('stop-btn').addEventListener('click', async () => {
|
|
1036
|
+
if (!activeId) return;
|
|
1037
|
+
if (!confirm('Stop this session? The process will be killed.')) return;
|
|
1038
|
+
await removeSession(activeId);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Version
|
|
1042
|
+
fetch('/api/version').then(r => r.json()).then(d => {
|
|
1043
|
+
document.getElementById('version-text').textContent = 'v' + d.version;
|
|
1044
|
+
}).catch(() => {});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// ===== Session Management =====
|
|
1048
|
+
function addSession(data) {
|
|
1049
|
+
if (managed.has(data.id)) return;
|
|
1050
|
+
|
|
532
1051
|
const term = new window.Terminal({
|
|
533
|
-
cursorBlink: true,
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
"'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
|
|
537
|
-
fontWeight: 'normal',
|
|
538
|
-
fontWeightBold: 'bold',
|
|
539
|
-
letterSpacing: 0,
|
|
540
|
-
lineHeight: 1.1,
|
|
1052
|
+
cursorBlink: true, fontSize: fontSize,
|
|
1053
|
+
fontFamily: "'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
|
|
1054
|
+
fontWeight: 'normal', fontWeightBold: 'bold', letterSpacing: 0, lineHeight: 1.1,
|
|
541
1055
|
theme: getTheme() === 'light' ? lightTermTheme : darkTermTheme,
|
|
542
|
-
allowProposedApi: true,
|
|
543
|
-
scrollback: 10000,
|
|
1056
|
+
allowProposedApi: true, scrollback: 10000,
|
|
544
1057
|
});
|
|
545
|
-
window._term = term;
|
|
546
1058
|
|
|
547
1059
|
const fitAddon = new window.FitAddon.FitAddon();
|
|
548
1060
|
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
549
1061
|
term.loadAddon(fitAddon);
|
|
550
1062
|
term.loadAddon(webLinksAddon);
|
|
551
1063
|
|
|
552
|
-
const container = document.
|
|
1064
|
+
const container = document.createElement('div');
|
|
1065
|
+
container.className = 'terminal-pane';
|
|
1066
|
+
terminalsWrapper.appendChild(container);
|
|
553
1067
|
term.open(container);
|
|
554
|
-
fitAddon.fit();
|
|
555
1068
|
|
|
556
|
-
|
|
1069
|
+
// Pointer-event scroll handler — uses setPointerCapture so scrolling
|
|
1070
|
+
// survives xterm DOM re-renders under the finger (touch on a letter).
|
|
1071
|
+
(function() {
|
|
1072
|
+
let startY = null, scrolling = false, accum = 0, ptrId = null;
|
|
1073
|
+
|
|
1074
|
+
container.addEventListener('pointerdown', (e) => {
|
|
1075
|
+
if (e.pointerType !== 'touch' || ptrId !== null) return;
|
|
1076
|
+
startY = e.clientY;
|
|
1077
|
+
scrolling = false;
|
|
1078
|
+
accum = 0;
|
|
1079
|
+
ptrId = e.pointerId;
|
|
1080
|
+
}, { capture: true });
|
|
1081
|
+
|
|
1082
|
+
container.addEventListener('pointermove', (e) => {
|
|
1083
|
+
if (e.pointerId !== ptrId) return;
|
|
1084
|
+
const y = e.clientY;
|
|
1085
|
+
const delta = startY - y;
|
|
1086
|
+
if (!scrolling) {
|
|
1087
|
+
if (Math.abs(delta) > 10) {
|
|
1088
|
+
scrolling = true;
|
|
1089
|
+
term.clearSelection();
|
|
1090
|
+
// Lock all future pointer events to this element —
|
|
1091
|
+
// immune to DOM mutations, xterm re-renders, etc.
|
|
1092
|
+
try { container.setPointerCapture(ptrId); } catch (_) {}
|
|
1093
|
+
} else {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
e.preventDefault();
|
|
1098
|
+
e.stopPropagation();
|
|
1099
|
+
startY = y;
|
|
1100
|
+
const lineH = term.options.fontSize * (term.options.lineHeight || 1);
|
|
1101
|
+
accum += delta;
|
|
1102
|
+
const lines = Math.trunc(accum / lineH);
|
|
1103
|
+
if (lines !== 0) { term.scrollLines(lines); accum -= lines * lineH; }
|
|
1104
|
+
}, { capture: true, passive: false });
|
|
1105
|
+
|
|
1106
|
+
function endScroll(e) {
|
|
1107
|
+
if (e.pointerId !== ptrId) return;
|
|
1108
|
+
if (scrolling) {
|
|
1109
|
+
e.stopPropagation();
|
|
1110
|
+
try { container.releasePointerCapture(ptrId); } catch (_) {}
|
|
1111
|
+
}
|
|
1112
|
+
startY = null;
|
|
1113
|
+
scrolling = false;
|
|
1114
|
+
ptrId = null;
|
|
1115
|
+
}
|
|
1116
|
+
container.addEventListener('pointerup', endScroll, { capture: true });
|
|
1117
|
+
container.addEventListener('pointercancel', endScroll, { capture: true });
|
|
1118
|
+
})();
|
|
1119
|
+
|
|
1120
|
+
// Copy on selection
|
|
1121
|
+
term.onSelectionChange(() => {
|
|
1122
|
+
const sel = term.getSelection();
|
|
1123
|
+
if (sel && navigator.clipboard && navigator.clipboard.writeText) {
|
|
1124
|
+
navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {});
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Click to focus
|
|
1129
|
+
container.addEventListener('click', () => {
|
|
1130
|
+
term.focus();
|
|
1131
|
+
// In split mode, clicking a pane makes it active
|
|
1132
|
+
if (splitMode && ms.id !== activeId) {
|
|
1133
|
+
const oldSecond = splitSecondId;
|
|
1134
|
+
splitSecondId = activeId;
|
|
1135
|
+
activeId = ms.id;
|
|
1136
|
+
updateStatusBar();
|
|
1137
|
+
renderTabs();
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const ms = {
|
|
1142
|
+
id: data.id, name: data.name,
|
|
1143
|
+
color: data.color || SESSION_COLORS[managed.size % SESSION_COLORS.length],
|
|
1144
|
+
shell: data.shell, cwd: data.cwd, pid: data.pid,
|
|
1145
|
+
term, fitAddon, container,
|
|
1146
|
+
ws: null, exited: false,
|
|
1147
|
+
reconnectTimer: null, reconnectDelay: 3000,
|
|
1148
|
+
lastActivity: data.lastActivity || Date.now(),
|
|
1149
|
+
};
|
|
557
1150
|
|
|
558
|
-
|
|
559
|
-
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
560
|
-
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
1151
|
+
managed.set(data.id, ms);
|
|
561
1152
|
|
|
562
|
-
|
|
1153
|
+
// Terminal input → WebSocket
|
|
1154
|
+
term.onData(input => {
|
|
1155
|
+
if (ms.ws && ms.ws.readyState === 1) {
|
|
1156
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: input }));
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
connectSession(ms);
|
|
1161
|
+
return ms;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function connectSession(ms) {
|
|
1165
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1166
|
+
if (ms.ws) { try { ms.ws.close(); } catch {} }
|
|
1167
|
+
|
|
1168
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
1169
|
+
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
1170
|
+
ms.ws = ws;
|
|
1171
|
+
|
|
1172
|
+
ws.onopen = () => {
|
|
1173
|
+
if (ms.ws !== ws) return;
|
|
1174
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId: ms.id }));
|
|
1175
|
+
ms.reconnectDelay = 3000;
|
|
1176
|
+
if (ms.id === activeId) {
|
|
563
1177
|
statusDot.className = 'connected';
|
|
564
1178
|
statusText.textContent = '';
|
|
565
1179
|
reconnectOverlay.classList.remove('visible');
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
ws
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1180
|
+
}
|
|
1181
|
+
renderTabs();
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
ws.onmessage = (event) => {
|
|
1185
|
+
if (ms.ws !== ws) return;
|
|
1186
|
+
try {
|
|
1187
|
+
const msg = JSON.parse(event.data);
|
|
1188
|
+
if (msg.type === 'output') {
|
|
1189
|
+
ms.term.write(msg.data);
|
|
1190
|
+
ms.lastActivity = Date.now();
|
|
1191
|
+
} else if (msg.type === 'attached') {
|
|
1192
|
+
if (ms.container.classList.contains('visible')) {
|
|
1193
|
+
sendResize(ms);
|
|
1194
|
+
}
|
|
1195
|
+
if (ms.id === activeId) { statusDot.className = 'connected'; statusText.textContent = ''; }
|
|
1196
|
+
} else if (msg.type === 'exit') {
|
|
1197
|
+
ms.exited = true;
|
|
1198
|
+
renderTabs();
|
|
1199
|
+
if (ms.id === activeId) {
|
|
1200
|
+
statusText.textContent = 'Exited (code ' + msg.code + ')';
|
|
586
1201
|
statusDot.className = '';
|
|
587
|
-
reconnectOverlay.querySelector('.msg').textContent =
|
|
588
|
-
`Session exited (code ${msg.code})`;
|
|
1202
|
+
reconnectOverlay.querySelector('.msg').textContent = 'Session exited (code ' + msg.code + ')';
|
|
589
1203
|
reconnectOverlay.classList.add('visible');
|
|
590
|
-
}
|
|
1204
|
+
}
|
|
1205
|
+
} else if (msg.type === 'error') {
|
|
1206
|
+
if (ms.id === activeId) {
|
|
591
1207
|
statusText.textContent = msg.message;
|
|
592
1208
|
reconnectOverlay.querySelector('.msg').textContent = msg.message;
|
|
593
1209
|
reconnectOverlay.classList.add('visible');
|
|
594
1210
|
}
|
|
595
|
-
} catch {
|
|
596
|
-
term.write(event.data);
|
|
597
1211
|
}
|
|
598
|
-
}
|
|
1212
|
+
} catch {
|
|
1213
|
+
ms.term.write(event.data);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
599
1216
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1217
|
+
ws.onclose = () => {
|
|
1218
|
+
if (ms.ws !== ws) return;
|
|
1219
|
+
if (ms.id === activeId) { statusDot.className = ''; statusText.textContent = 'Disconnected'; }
|
|
1220
|
+
if (!ms.exited) {
|
|
1221
|
+
ms.reconnectTimer = setTimeout(() => {
|
|
1222
|
+
ms.reconnectDelay = Math.min(ms.reconnectDelay * 1.5, 30000);
|
|
1223
|
+
connectSession(ms);
|
|
1224
|
+
}, ms.reconnectDelay);
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
ws.onerror = () => {
|
|
1229
|
+
if (ms.ws !== ws) return;
|
|
1230
|
+
if (ms.id === activeId) statusText.textContent = 'Connection error';
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function activateSession(id) {
|
|
1235
|
+
if (!managed.has(id)) return;
|
|
1236
|
+
activeId = id;
|
|
1237
|
+
reconnectOverlay.classList.remove('visible');
|
|
1238
|
+
|
|
1239
|
+
// Show/hide panes
|
|
1240
|
+
for (const [sid, ms] of managed) {
|
|
1241
|
+
const show = sid === id || (splitMode && sid === splitSecondId);
|
|
1242
|
+
ms.container.classList.toggle('visible', show);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
updateStatusBar();
|
|
1246
|
+
|
|
1247
|
+
// Fit visible terminals and scroll to bottom
|
|
1248
|
+
requestAnimationFrame(() => {
|
|
1249
|
+
for (const [, ms] of managed) {
|
|
1250
|
+
if (ms.container.classList.contains('visible')) {
|
|
1251
|
+
ms.fitAddon.fit();
|
|
1252
|
+
sendResize(ms);
|
|
1253
|
+
ms.term.scrollToBottom();
|
|
606
1254
|
}
|
|
607
|
-
}
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// Update URL
|
|
1259
|
+
const url = new URL(location);
|
|
1260
|
+
url.searchParams.set('id', id);
|
|
1261
|
+
history.replaceState(null, '', url);
|
|
1262
|
+
|
|
1263
|
+
renderTabs();
|
|
1264
|
+
}
|
|
608
1265
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1266
|
+
function updateStatusBar() {
|
|
1267
|
+
const ms = managed.get(activeId);
|
|
1268
|
+
const stopBtn = document.getElementById('stop-btn');
|
|
1269
|
+
if (!ms) {
|
|
1270
|
+
stopBtn.style.display = 'none';
|
|
1271
|
+
return;
|
|
612
1272
|
}
|
|
1273
|
+
stopBtn.style.display = '';
|
|
1274
|
+
sessionNameEl.textContent = ms.name;
|
|
1275
|
+
statusDot.className = (ms.ws && ms.ws.readyState === 1) ? 'connected' : '';
|
|
1276
|
+
statusText.textContent = (ms.ws && ms.ws.readyState === 1) ? '' : (ms.exited ? 'Exited' : 'Disconnected');
|
|
1277
|
+
}
|
|
613
1278
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1279
|
+
async function removeSession(id) {
|
|
1280
|
+
try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
|
|
1281
|
+
|
|
1282
|
+
const ms = managed.get(id);
|
|
1283
|
+
if (ms) {
|
|
1284
|
+
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
1285
|
+
if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
|
|
1286
|
+
ms.term.dispose();
|
|
1287
|
+
ms.container.remove();
|
|
1288
|
+
managed.delete(id);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (id === splitSecondId) splitSecondId = null;
|
|
1292
|
+
if (id === activeId) {
|
|
1293
|
+
const remaining = [...managed.keys()];
|
|
1294
|
+
if (remaining.length > 0) activateSession(remaining[0]);
|
|
1295
|
+
else {
|
|
1296
|
+
activeId = null;
|
|
1297
|
+
sessionNameEl.textContent = 'No sessions';
|
|
1298
|
+
statusText.textContent = '';
|
|
1299
|
+
statusDot.className = '';
|
|
1300
|
+
document.getElementById('stop-btn').style.display = 'none';
|
|
618
1301
|
}
|
|
1302
|
+
}
|
|
1303
|
+
renderTabs();
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// ===== Tab Rendering =====
|
|
1307
|
+
function renderTabs() {
|
|
1308
|
+
const order = getTabOrder();
|
|
1309
|
+
|
|
1310
|
+
tabListEl.innerHTML = order.map(id => {
|
|
1311
|
+
const ms = managed.get(id);
|
|
1312
|
+
if (!ms) return '';
|
|
1313
|
+
const isActive = id === activeId;
|
|
1314
|
+
const isSplit = splitMode && id === splitSecondId;
|
|
1315
|
+
const statusColor = ms.exited ? 'var(--danger)'
|
|
1316
|
+
: (ms.ws && ms.ws.readyState === 1 ? 'var(--success)' : 'var(--text-muted)');
|
|
1317
|
+
const activity = getActivityLabel(ms.lastActivity);
|
|
1318
|
+
let cls = 'session-tab';
|
|
1319
|
+
if (isActive) cls += ' active';
|
|
1320
|
+
if (isSplit) cls += ' in-split';
|
|
1321
|
+
return '<button class="' + cls + '" data-id="' + escAttr(id) + '" draggable="true">'
|
|
1322
|
+
+ '<span class="tab-dot" style="background:' + safeColor(ms.color) + '"></span>'
|
|
1323
|
+
+ '<span class="tab-name">' + esc(ms.name) + '</span>'
|
|
1324
|
+
+ (activity ? '<span class="tab-activity">' + activity + '</span>' : '')
|
|
1325
|
+
+ '<span class="tab-status" style="background:' + safeColor(statusColor) + '"></span>'
|
|
1326
|
+
+ '<span class="tab-close" data-close="' + escAttr(id) + '">×</span>'
|
|
1327
|
+
+ '</button>';
|
|
1328
|
+
}).join('');
|
|
1329
|
+
|
|
1330
|
+
attachTabHandlers();
|
|
1331
|
+
initTabDrag();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ===== Tab Preview =====
|
|
1335
|
+
const previewEl = document.getElementById('tab-preview');
|
|
1336
|
+
const previewDot = document.getElementById('preview-dot');
|
|
1337
|
+
const previewName = document.getElementById('preview-name');
|
|
1338
|
+
const previewBody = document.getElementById('preview-body');
|
|
1339
|
+
let previewTimer = null;
|
|
1340
|
+
let previewVisible = false;
|
|
1341
|
+
|
|
1342
|
+
function getTerminalLines(ms, count) {
|
|
1343
|
+
const buf = ms.term.buffer.active;
|
|
1344
|
+
const totalRows = buf.length;
|
|
1345
|
+
// Find the last non-empty line (buffer has empty padding rows)
|
|
1346
|
+
let lastNonEmpty = -1;
|
|
1347
|
+
for (let i = totalRows - 1; i >= 0; i--) {
|
|
1348
|
+
const line = buf.getLine(i);
|
|
1349
|
+
if (line && line.translateToString(true).trim() !== '') { lastNonEmpty = i; break; }
|
|
1350
|
+
}
|
|
1351
|
+
if (lastNonEmpty < 0) return [];
|
|
1352
|
+
const start = Math.max(0, lastNonEmpty - count + 1);
|
|
1353
|
+
const lines = [];
|
|
1354
|
+
for (let i = start; i <= lastNonEmpty; i++) {
|
|
1355
|
+
const line = buf.getLine(i);
|
|
1356
|
+
if (line) lines.push(line.translateToString(true));
|
|
1357
|
+
}
|
|
1358
|
+
return lines;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function showPreview(tabEl, sessionId) {
|
|
1362
|
+
const ms = managed.get(sessionId);
|
|
1363
|
+
if (!ms) return;
|
|
1364
|
+
const lines = getTerminalLines(ms, 12);
|
|
1365
|
+
previewDot.style.background = ms.color;
|
|
1366
|
+
previewName.textContent = ms.name;
|
|
1367
|
+
if (lines.length === 0) {
|
|
1368
|
+
previewBody.innerHTML = '<div class="preview-empty">No output yet</div>';
|
|
1369
|
+
} else {
|
|
1370
|
+
previewBody.textContent = lines.join('\n');
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
previewEl.classList.add('visible');
|
|
1374
|
+
previewVisible = true;
|
|
1375
|
+
|
|
1376
|
+
// Position: above the tab, centered
|
|
1377
|
+
const tabRect = tabEl.getBoundingClientRect();
|
|
1378
|
+
const pw = previewEl.offsetWidth;
|
|
1379
|
+
const ph = previewEl.offsetHeight;
|
|
1380
|
+
let left = tabRect.left + tabRect.width / 2 - pw / 2;
|
|
1381
|
+
left = Math.max(6, Math.min(left, window.innerWidth - pw - 6));
|
|
1382
|
+
let top = tabRect.top - ph - 6;
|
|
1383
|
+
if (top < 6) top = tabRect.bottom + 6; // Below if not enough room
|
|
1384
|
+
previewEl.style.left = left + 'px';
|
|
1385
|
+
previewEl.style.top = top + 'px';
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function hidePreview() {
|
|
1389
|
+
previewEl.classList.remove('visible');
|
|
1390
|
+
previewVisible = false;
|
|
1391
|
+
if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function attachTabHandlers() {
|
|
1395
|
+
tabListEl.querySelectorAll('.session-tab').forEach(tab => {
|
|
1396
|
+
tab.addEventListener('click', e => {
|
|
1397
|
+
if (e.target.dataset.close) return;
|
|
1398
|
+
hidePreview();
|
|
1399
|
+
activateSession(tab.dataset.id);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
// Desktop: hover preview (non-active tabs only)
|
|
1403
|
+
tab.addEventListener('mouseenter', () => {
|
|
1404
|
+
if (tab.dataset.id === activeId) return;
|
|
1405
|
+
previewTimer = setTimeout(() => showPreview(tab, tab.dataset.id), 400);
|
|
1406
|
+
});
|
|
1407
|
+
tab.addEventListener('mouseleave', () => hidePreview());
|
|
1408
|
+
});
|
|
1409
|
+
tabListEl.querySelectorAll('.tab-close').forEach(btn => {
|
|
1410
|
+
btn.addEventListener('click', e => {
|
|
1411
|
+
e.stopPropagation();
|
|
1412
|
+
if (confirm('Close this session?')) removeSession(btn.dataset.close);
|
|
1413
|
+
});
|
|
619
1414
|
});
|
|
1415
|
+
}
|
|
620
1416
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1417
|
+
// ===== Sessions Side Panel =====
|
|
1418
|
+
const sidePanel = document.getElementById('side-panel');
|
|
1419
|
+
const sidePanelBackdrop = document.getElementById('side-panel-backdrop');
|
|
1420
|
+
const sidePanelList = document.getElementById('side-panel-list');
|
|
1421
|
+
|
|
1422
|
+
function openSidePanel() {
|
|
1423
|
+
renderSidePanel();
|
|
1424
|
+
sidePanel.classList.add('open');
|
|
1425
|
+
sidePanelBackdrop.classList.add('visible');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function closeSidePanel() {
|
|
1429
|
+
sidePanel.classList.remove('open');
|
|
1430
|
+
sidePanelBackdrop.classList.remove('visible');
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function renderSidePanel() {
|
|
1434
|
+
const order = getTabOrder();
|
|
1435
|
+
sidePanelList.innerHTML = order.map(id => {
|
|
1436
|
+
const ms = managed.get(id);
|
|
1437
|
+
if (!ms) return '';
|
|
1438
|
+
const isActive = id === activeId;
|
|
1439
|
+
const statusColor = ms.exited ? 'var(--danger)'
|
|
1440
|
+
: (ms.ws && ms.ws.readyState === 1 ? 'var(--success)' : 'var(--text-muted)');
|
|
1441
|
+
const activity = getActivityLabel(ms.lastActivity);
|
|
1442
|
+
const lines = getTerminalLines(ms, 6);
|
|
1443
|
+
const previewContent = lines.length > 0
|
|
1444
|
+
? '<div class="side-panel-card-preview">' + esc(lines.join('\n')) + '</div>'
|
|
1445
|
+
: '<div class="side-panel-card-preview empty">No output yet</div>';
|
|
1446
|
+
return '<div class="side-panel-card' + (isActive ? ' active' : '') + '" data-id="' + escAttr(id) + '">'
|
|
1447
|
+
+ '<div class="side-panel-card-header">'
|
|
1448
|
+
+ '<span class="side-panel-card-dot" style="background:' + safeColor(ms.color) + '"></span>'
|
|
1449
|
+
+ '<span class="side-panel-card-name">' + esc(ms.name) + '</span>'
|
|
1450
|
+
+ '<span class="side-panel-card-status" style="background:' + safeColor(statusColor) + '"></span>'
|
|
1451
|
+
+ '<button class="side-panel-card-close" data-close-id="' + escAttr(id) + '" title="Close session">×</button>'
|
|
1452
|
+
+ '</div>'
|
|
1453
|
+
+ (activity ? '<div class="side-panel-card-meta">' + activity + ' ago</div>' : '')
|
|
1454
|
+
+ previewContent
|
|
1455
|
+
+ '</div>';
|
|
1456
|
+
}).join('');
|
|
1457
|
+
|
|
1458
|
+
sidePanelList.querySelectorAll('.side-panel-card-close').forEach(btn => {
|
|
1459
|
+
btn.addEventListener('click', async (e) => {
|
|
1460
|
+
e.stopPropagation();
|
|
1461
|
+
const id = btn.dataset.closeId;
|
|
1462
|
+
if (confirm('Close this session?')) {
|
|
1463
|
+
await removeSession(id);
|
|
1464
|
+
renderSidePanel();
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
sidePanelList.querySelectorAll('.side-panel-card').forEach(card => {
|
|
1470
|
+
card.addEventListener('click', () => {
|
|
1471
|
+
activateSession(card.dataset.id);
|
|
1472
|
+
closeSidePanel();
|
|
1473
|
+
});
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
document.getElementById('panel-toggle').addEventListener('click', openSidePanel);
|
|
1478
|
+
document.getElementById('side-panel-close').addEventListener('click', closeSidePanel);
|
|
1479
|
+
sidePanelBackdrop.addEventListener('click', closeSidePanel);
|
|
1480
|
+
|
|
1481
|
+
// ===== Drag to Reorder =====
|
|
1482
|
+
function initTabDrag() {
|
|
1483
|
+
let dragId = null;
|
|
1484
|
+
let dragEl = null;
|
|
1485
|
+
|
|
1486
|
+
tabListEl.querySelectorAll('.session-tab').forEach(tab => {
|
|
1487
|
+
// --- Touch drag (long press) + preview ---
|
|
1488
|
+
let longPressTimer = null;
|
|
1489
|
+
let previewHoldTimer = null;
|
|
1490
|
+
let startX = 0, startY = 0;
|
|
1491
|
+
let isDragging = false;
|
|
1492
|
+
let didPreview = false;
|
|
1493
|
+
|
|
1494
|
+
tab.addEventListener('touchstart', e => {
|
|
1495
|
+
startX = e.touches[0].clientX;
|
|
1496
|
+
startY = e.touches[0].clientY;
|
|
1497
|
+
didPreview = false;
|
|
1498
|
+
|
|
1499
|
+
// 200ms: show preview (if not active tab)
|
|
1500
|
+
if (tab.dataset.id !== activeId) {
|
|
1501
|
+
previewHoldTimer = setTimeout(() => {
|
|
1502
|
+
didPreview = true;
|
|
1503
|
+
showPreview(tab, tab.dataset.id);
|
|
1504
|
+
if (navigator.vibrate) navigator.vibrate(30);
|
|
1505
|
+
}, 200);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// 600ms: enter drag mode (dismiss preview)
|
|
1509
|
+
longPressTimer = setTimeout(() => {
|
|
1510
|
+
hidePreview();
|
|
1511
|
+
isDragging = true;
|
|
1512
|
+
dragId = tab.dataset.id;
|
|
1513
|
+
dragEl = tab;
|
|
1514
|
+
tab.classList.add('dragging');
|
|
1515
|
+
if (navigator.vibrate) navigator.vibrate(50);
|
|
1516
|
+
}, 600);
|
|
1517
|
+
}, { passive: true });
|
|
1518
|
+
|
|
1519
|
+
tab.addEventListener('touchmove', e => {
|
|
1520
|
+
const dx = Math.abs(e.touches[0].clientX - startX);
|
|
1521
|
+
const dy = Math.abs(e.touches[0].clientY - startY);
|
|
1522
|
+
if (!isDragging && (dx > 10 || dy > 10)) {
|
|
1523
|
+
clearTimeout(longPressTimer);
|
|
1524
|
+
clearTimeout(previewHoldTimer);
|
|
1525
|
+
hidePreview();
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (isDragging) {
|
|
1529
|
+
e.preventDefault();
|
|
1530
|
+
const touch = e.touches[0];
|
|
1531
|
+
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
1532
|
+
const target = el ? el.closest('.session-tab') : null;
|
|
1533
|
+
if (target && target !== dragEl && target.dataset.id) {
|
|
1534
|
+
const rect = target.getBoundingClientRect();
|
|
1535
|
+
if (touch.clientX < rect.left + rect.width / 2) {
|
|
1536
|
+
target.parentNode.insertBefore(dragEl, target);
|
|
1537
|
+
} else {
|
|
1538
|
+
target.parentNode.insertBefore(dragEl, target.nextSibling);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
tab.addEventListener('touchend', () => {
|
|
1545
|
+
clearTimeout(longPressTimer);
|
|
1546
|
+
clearTimeout(previewHoldTimer);
|
|
1547
|
+
hidePreview();
|
|
1548
|
+
if (isDragging) {
|
|
1549
|
+
isDragging = false;
|
|
1550
|
+
dragEl.classList.remove('dragging');
|
|
1551
|
+
// Save new order from DOM
|
|
1552
|
+
const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(t => t.dataset.id);
|
|
1553
|
+
saveTabOrder(newOrder);
|
|
1554
|
+
dragId = null;
|
|
1555
|
+
dragEl = null;
|
|
628
1556
|
}
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
// --- Desktop drag ---
|
|
1560
|
+
tab.addEventListener('dragstart', e => {
|
|
1561
|
+
dragId = tab.dataset.id;
|
|
1562
|
+
dragEl = tab;
|
|
1563
|
+
tab.classList.add('dragging');
|
|
1564
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
tab.addEventListener('dragover', e => {
|
|
1568
|
+
e.preventDefault();
|
|
1569
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
tab.addEventListener('drop', e => {
|
|
1573
|
+
e.preventDefault();
|
|
1574
|
+
if (dragId && tab.dataset.id !== dragId) {
|
|
1575
|
+
const rect = tab.getBoundingClientRect();
|
|
1576
|
+
if (e.clientX < rect.left + rect.width / 2) {
|
|
1577
|
+
tab.parentNode.insertBefore(dragEl, tab);
|
|
1578
|
+
} else {
|
|
1579
|
+
tab.parentNode.insertBefore(dragEl, tab.nextSibling);
|
|
1580
|
+
}
|
|
1581
|
+
const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(t => t.dataset.id);
|
|
1582
|
+
saveTabOrder(newOrder);
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
tab.addEventListener('dragend', () => {
|
|
1587
|
+
if (dragEl) dragEl.classList.remove('dragging');
|
|
1588
|
+
dragId = null;
|
|
1589
|
+
dragEl = null;
|
|
1590
|
+
});
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ===== Split View =====
|
|
1595
|
+
function toggleSplit() {
|
|
1596
|
+
splitMode = !splitMode;
|
|
1597
|
+
document.getElementById('split-toggle').classList.toggle('active', splitMode);
|
|
1598
|
+
|
|
1599
|
+
if (splitMode) {
|
|
1600
|
+
// Find a second session to show
|
|
1601
|
+
const order = getTabOrder();
|
|
1602
|
+
const others = order.filter(id => id !== activeId);
|
|
1603
|
+
splitSecondId = others.length > 0 ? others[0] : null;
|
|
1604
|
+
|
|
1605
|
+
if (splitSecondId) {
|
|
1606
|
+
const isMobile = window.innerWidth < 640;
|
|
1607
|
+
terminalsWrapper.classList.add(isMobile ? 'split-v' : 'split-h');
|
|
1608
|
+
managed.get(splitSecondId).container.classList.add('visible');
|
|
1609
|
+
}
|
|
1610
|
+
} else {
|
|
1611
|
+
terminalsWrapper.classList.remove('split-h', 'split-v');
|
|
1612
|
+
splitSecondId = null;
|
|
1613
|
+
// Hide all except active
|
|
1614
|
+
for (const [id, ms] of managed) {
|
|
1615
|
+
ms.container.classList.toggle('visible', id === activeId);
|
|
629
1616
|
}
|
|
630
1617
|
}
|
|
631
|
-
window.addEventListener('resize', doResize);
|
|
632
|
-
screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
|
|
633
1618
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
// Only prevent default on buttons, not the scrollable bar itself
|
|
638
|
-
if (e.target.closest('.key-btn')) {
|
|
639
|
-
e.preventDefault();
|
|
1619
|
+
requestAnimationFrame(() => {
|
|
1620
|
+
for (const [, ms] of managed) {
|
|
1621
|
+
if (ms.container.classList.contains('visible')) { ms.fitAddon.fit(); sendResize(ms); }
|
|
640
1622
|
}
|
|
641
1623
|
});
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1624
|
+
|
|
1625
|
+
renderTabs();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// ===== Key Bar =====
|
|
1629
|
+
function setupKeyBar() {
|
|
1630
|
+
const keyBar = document.getElementById('key-bar');
|
|
1631
|
+
let repeatTimer = null;
|
|
1632
|
+
let repeatInterval = null;
|
|
1633
|
+
|
|
1634
|
+
function sendKey(btn) {
|
|
648
1635
|
if (!btn || !btn.dataset.key) return;
|
|
649
|
-
|
|
650
|
-
|
|
1636
|
+
const data = btn.dataset.key === 'enter' ? '\r' : btn.dataset.key;
|
|
1637
|
+
const ms = managed.get(activeId);
|
|
1638
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1639
|
+
ms.ws.send(JSON.stringify({ type: 'input', data }));
|
|
651
1640
|
}
|
|
652
|
-
|
|
653
|
-
});
|
|
1641
|
+
}
|
|
654
1642
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
term.options.fontSize = fontSize;
|
|
661
|
-
localStorage.setItem('termbeam-fontsize', fontSize);
|
|
662
|
-
doResize();
|
|
1643
|
+
function stopRepeat() {
|
|
1644
|
+
clearTimeout(repeatTimer);
|
|
1645
|
+
clearInterval(repeatInterval);
|
|
1646
|
+
repeatTimer = null;
|
|
1647
|
+
repeatInterval = null;
|
|
663
1648
|
}
|
|
664
|
-
document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
|
|
665
|
-
document.getElementById('zoom-out').addEventListener('click', () => applyZoom(fontSize - 2));
|
|
666
1649
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
|
|
1650
|
+
function startRepeat(btn) {
|
|
1651
|
+
stopRepeat();
|
|
1652
|
+
sendKey(btn);
|
|
1653
|
+
repeatTimer = setTimeout(() => {
|
|
1654
|
+
repeatInterval = setInterval(() => sendKey(btn), 80);
|
|
1655
|
+
}, 400);
|
|
674
1656
|
}
|
|
675
1657
|
|
|
676
|
-
|
|
677
|
-
const
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1658
|
+
keyBar.addEventListener('mousedown', e => {
|
|
1659
|
+
const btn = e.target.closest('.key-btn');
|
|
1660
|
+
if (btn) { e.preventDefault(); startRepeat(btn); }
|
|
1661
|
+
});
|
|
1662
|
+
keyBar.addEventListener('mouseup', stopRepeat);
|
|
1663
|
+
keyBar.addEventListener('mouseleave', stopRepeat);
|
|
1664
|
+
|
|
1665
|
+
keyBar.addEventListener('touchstart', e => {
|
|
1666
|
+
const btn = e.target.closest('.key-btn');
|
|
1667
|
+
if (btn) startRepeat(btn);
|
|
1668
|
+
}, { passive: true });
|
|
1669
|
+
keyBar.addEventListener('touchend', stopRepeat);
|
|
1670
|
+
keyBar.addEventListener('touchcancel', stopRepeat);
|
|
1671
|
+
|
|
1672
|
+
keyBar.addEventListener('click', e => {
|
|
1673
|
+
const btn = e.target.closest('.key-btn');
|
|
1674
|
+
if (btn) { const ms = managed.get(activeId); if (ms) ms.term.focus(); }
|
|
683
1675
|
});
|
|
1676
|
+
}
|
|
684
1677
|
|
|
685
|
-
|
|
1678
|
+
// ===== Paste =====
|
|
1679
|
+
function setupPaste() {
|
|
686
1680
|
const pasteOverlay = document.getElementById('paste-overlay');
|
|
687
1681
|
const pasteInput = document.getElementById('paste-input');
|
|
688
1682
|
|
|
@@ -692,24 +1686,16 @@
|
|
|
692
1686
|
pasteInput.focus();
|
|
693
1687
|
}
|
|
694
1688
|
|
|
695
|
-
|
|
696
|
-
pasteOverlay.classList.remove('visible');
|
|
697
|
-
pasteInput.value = '';
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
document.getElementById('paste-btn').addEventListener('mousedown', (e) => {
|
|
701
|
-
e.preventDefault();
|
|
702
|
-
});
|
|
1689
|
+
document.getElementById('paste-btn').addEventListener('mousedown', e => e.preventDefault());
|
|
703
1690
|
document.getElementById('paste-btn').addEventListener('click', () => {
|
|
704
1691
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
705
|
-
navigator.clipboard.readText().then(
|
|
706
|
-
|
|
707
|
-
|
|
1692
|
+
navigator.clipboard.readText().then(text => {
|
|
1693
|
+
const ms = managed.get(activeId);
|
|
1694
|
+
if (text && ms && ms.ws && ms.ws.readyState === 1) {
|
|
1695
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
708
1696
|
showToast('Pasted!');
|
|
709
1697
|
}
|
|
710
|
-
}).catch(() =>
|
|
711
|
-
openPasteModal();
|
|
712
|
-
});
|
|
1698
|
+
}).catch(() => openPasteModal());
|
|
713
1699
|
} else {
|
|
714
1700
|
openPasteModal();
|
|
715
1701
|
}
|
|
@@ -717,93 +1703,260 @@
|
|
|
717
1703
|
|
|
718
1704
|
document.getElementById('paste-send').addEventListener('click', () => {
|
|
719
1705
|
const text = pasteInput.value;
|
|
720
|
-
|
|
721
|
-
|
|
1706
|
+
const ms = managed.get(activeId);
|
|
1707
|
+
if (text && ms && ms.ws && ms.ws.readyState === 1) {
|
|
1708
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
722
1709
|
}
|
|
723
|
-
|
|
1710
|
+
pasteOverlay.classList.remove('visible');
|
|
1711
|
+
pasteInput.value = '';
|
|
724
1712
|
});
|
|
725
|
-
document.getElementById('paste-cancel').addEventListener('click', closePasteModal);
|
|
726
|
-
|
|
727
|
-
function scheduleReconnect() {
|
|
728
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
729
|
-
const seconds = Math.round(reconnectDelay / 1000);
|
|
730
|
-
reconnectOverlay.querySelector('.msg').textContent =
|
|
731
|
-
`Disconnected — reconnecting in ${seconds}s…`;
|
|
732
|
-
reconnectTimer = setTimeout(() => {
|
|
733
|
-
reconnectTimer = null;
|
|
734
|
-
reconnectOverlay.querySelector('.msg').textContent = 'Reconnecting…';
|
|
735
|
-
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY);
|
|
736
|
-
connect();
|
|
737
|
-
}, reconnectDelay);
|
|
738
|
-
}
|
|
739
1713
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
sessionExited = false;
|
|
744
|
-
reconnectDelay = 3000;
|
|
745
|
-
term.clear();
|
|
746
|
-
connect();
|
|
1714
|
+
document.getElementById('paste-cancel').addEventListener('click', () => {
|
|
1715
|
+
pasteOverlay.classList.remove('visible');
|
|
1716
|
+
pasteInput.value = '';
|
|
747
1717
|
});
|
|
1718
|
+
}
|
|
748
1719
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1720
|
+
// ===== New Session Modal =====
|
|
1721
|
+
let shellsLoaded = false;
|
|
1722
|
+
|
|
1723
|
+
function openNewSessionModal() {
|
|
1724
|
+
loadShellsForModal();
|
|
1725
|
+
document.getElementById('new-session-modal').classList.add('visible');
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function setupNewSessionModal() {
|
|
1729
|
+
document.getElementById('tab-new-btn').addEventListener('click', openNewSessionModal);
|
|
1730
|
+
document.getElementById('side-panel-new-btn').addEventListener('click', () => {
|
|
1731
|
+
closeSidePanel();
|
|
1732
|
+
openNewSessionModal();
|
|
1733
|
+
});
|
|
1734
|
+
document.getElementById('ns-cancel').addEventListener('click', () => {
|
|
1735
|
+
document.getElementById('new-session-modal').classList.remove('visible');
|
|
1736
|
+
});
|
|
1737
|
+
document.getElementById('new-session-modal').addEventListener('click', e => {
|
|
1738
|
+
if (e.target.id === 'new-session-modal') document.getElementById('new-session-modal').classList.remove('visible');
|
|
756
1739
|
});
|
|
757
1740
|
|
|
758
|
-
//
|
|
759
|
-
|
|
1741
|
+
// Color picker
|
|
1742
|
+
document.getElementById('ns-color-picker').addEventListener('click', e => {
|
|
1743
|
+
const swatch = e.target.closest('.color-swatch');
|
|
1744
|
+
if (!swatch) return;
|
|
1745
|
+
document.querySelectorAll('#ns-color-picker .color-swatch').forEach(s => s.classList.remove('selected'));
|
|
1746
|
+
swatch.classList.add('selected');
|
|
1747
|
+
});
|
|
760
1748
|
|
|
761
|
-
|
|
762
|
-
// When keyboard opens, the viewport shrinks — reposition key bar and resize terminal
|
|
763
|
-
if (window.visualViewport) {
|
|
764
|
-
const keyBar = document.getElementById('key-bar');
|
|
765
|
-
const statusBar = document.getElementById('status-bar');
|
|
1749
|
+
document.getElementById('ns-create').addEventListener('click', createNewSession);
|
|
766
1750
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1751
|
+
// Folder browser
|
|
1752
|
+
const nsCwdInput = document.getElementById('ns-cwd');
|
|
1753
|
+
const nsBrowserOverlay = document.getElementById('ns-browser-overlay');
|
|
1754
|
+
const nsBrowserList = document.getElementById('ns-browser-list');
|
|
1755
|
+
const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
|
|
1756
|
+
const nsBrowserPath = document.getElementById('ns-browser-path');
|
|
1757
|
+
let nsBrowsePath = '/';
|
|
770
1758
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1759
|
+
document.getElementById('ns-browse-btn').addEventListener('click', () => {
|
|
1760
|
+
const initial = nsCwdInput.value.trim() || '/';
|
|
1761
|
+
nsBrowseNavigate(initial);
|
|
1762
|
+
nsBrowserOverlay.classList.add('visible');
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
document.getElementById('ns-browser-close').addEventListener('click', () => {
|
|
1766
|
+
nsBrowserOverlay.classList.remove('visible');
|
|
1767
|
+
});
|
|
1768
|
+
nsBrowserOverlay.addEventListener('click', (e) => {
|
|
1769
|
+
if (e.target === nsBrowserOverlay) nsBrowserOverlay.classList.remove('visible');
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
document.getElementById('ns-browser-select').addEventListener('click', () => {
|
|
1773
|
+
nsCwdInput.value = nsBrowsePath;
|
|
1774
|
+
nsBrowserOverlay.classList.remove('visible');
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
async function nsBrowseNavigate(dir) {
|
|
1778
|
+
nsBrowsePath = dir;
|
|
1779
|
+
nsBrowserPath.textContent = dir;
|
|
1780
|
+
nsBrowseRenderBreadcrumb(dir);
|
|
1781
|
+
nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
|
|
1782
|
+
try {
|
|
1783
|
+
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
1784
|
+
const data = await res.json();
|
|
1785
|
+
if (!data.dirs.length) {
|
|
1786
|
+
nsBrowserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
|
|
1787
|
+
return;
|
|
779
1788
|
}
|
|
780
|
-
|
|
781
|
-
|
|
1789
|
+
nsBrowserList.innerHTML = data.dirs.map(d => {
|
|
1790
|
+
const name = d.split(/[/\\]/).pop();
|
|
1791
|
+
return `<div class="folder-item" data-path="${escAttr(d)}">
|
|
1792
|
+
<span class="folder-icon">📁</span>
|
|
1793
|
+
<span class="folder-name">${esc(name)}</span>
|
|
1794
|
+
<span class="folder-arrow">›</span>
|
|
1795
|
+
</div>`;
|
|
1796
|
+
}).join('');
|
|
1797
|
+
nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
|
|
1798
|
+
el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
|
|
1799
|
+
});
|
|
1800
|
+
nsBrowserList.scrollTop = 0;
|
|
1801
|
+
} catch {
|
|
1802
|
+
nsBrowserList.innerHTML = '<div class="browser-empty">Error loading folders</div>';
|
|
782
1803
|
}
|
|
1804
|
+
}
|
|
783
1805
|
|
|
784
|
-
|
|
785
|
-
|
|
1806
|
+
function nsBrowseRenderBreadcrumb(dir) {
|
|
1807
|
+
const parts = dir.split('/').filter(Boolean);
|
|
1808
|
+
let html = `<button class="crumb" data-path="/">/</button>`;
|
|
1809
|
+
let accumulated = '';
|
|
1810
|
+
parts.forEach((part, i) => {
|
|
1811
|
+
accumulated += '/' + part;
|
|
1812
|
+
const isCurrent = i === parts.length - 1;
|
|
1813
|
+
html += `<span class="crumb-sep">›</span>`;
|
|
1814
|
+
html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
|
|
1815
|
+
});
|
|
1816
|
+
nsBrowserBreadcrumb.innerHTML = html;
|
|
1817
|
+
nsBrowserBreadcrumb.querySelectorAll('.crumb').forEach(el => {
|
|
1818
|
+
el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
|
|
1819
|
+
});
|
|
1820
|
+
nsBrowserBreadcrumb.scrollLeft = nsBrowserBreadcrumb.scrollWidth;
|
|
786
1821
|
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async function loadShellsForModal() {
|
|
1825
|
+
if (shellsLoaded) return;
|
|
1826
|
+
const sel = document.getElementById('ns-shell');
|
|
1827
|
+
try {
|
|
1828
|
+
const data = await fetch('/api/shells').then(r => r.json());
|
|
1829
|
+
sel.innerHTML = data.shells.map(s =>
|
|
1830
|
+
'<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
|
|
1831
|
+
+ esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
|
|
1832
|
+
).join('');
|
|
1833
|
+
shellsLoaded = true;
|
|
1834
|
+
} catch { sel.innerHTML = '<option value="">Could not detect shells</option>'; }
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
async function createNewSession() {
|
|
1838
|
+
const name = document.getElementById('ns-name').value.trim();
|
|
1839
|
+
const shell = document.getElementById('ns-shell').value;
|
|
1840
|
+
const cwd = document.getElementById('ns-cwd').value.trim();
|
|
1841
|
+
const cmd = document.getElementById('ns-cmd').value.trim();
|
|
1842
|
+
const colorEl = document.querySelector('#ns-color-picker .color-swatch.selected');
|
|
1843
|
+
const color = colorEl ? colorEl.dataset.color : null;
|
|
1844
|
+
|
|
1845
|
+
const body = {};
|
|
1846
|
+
if (name) body.name = name;
|
|
1847
|
+
if (shell) body.shell = shell;
|
|
1848
|
+
if (cwd) body.cwd = cwd;
|
|
1849
|
+
if (cmd) body.initialCommand = cmd;
|
|
1850
|
+
if (color) body.color = color;
|
|
787
1851
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (s) sessionName.textContent = s.name;
|
|
1852
|
+
try {
|
|
1853
|
+
const res = await fetch('/api/sessions', {
|
|
1854
|
+
method: 'POST',
|
|
1855
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1856
|
+
body: JSON.stringify(body),
|
|
794
1857
|
});
|
|
1858
|
+
const data = await res.json();
|
|
1859
|
+
|
|
1860
|
+
// Fetch full session list to get the new session data
|
|
1861
|
+
const list = await fetch('/api/sessions').then(r => r.json());
|
|
1862
|
+
const newSession = list.find(s => s.id === data.id);
|
|
1863
|
+
if (newSession) {
|
|
1864
|
+
addSession(newSession);
|
|
1865
|
+
activateSession(data.id);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
document.getElementById('new-session-modal').classList.remove('visible');
|
|
1869
|
+
document.getElementById('ns-name').value = '';
|
|
1870
|
+
document.getElementById('ns-cmd').value = '';
|
|
1871
|
+
document.getElementById('ns-cwd').value = '';
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
console.error('Failed to create session:', err);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// ===== Polling =====
|
|
1878
|
+
function startPolling() {
|
|
1879
|
+
setInterval(async () => {
|
|
1880
|
+
try {
|
|
1881
|
+
const list = await fetch('/api/sessions').then(r => r.json());
|
|
1882
|
+
const serverIds = new Set(list.map(s => s.id));
|
|
1883
|
+
|
|
1884
|
+
// Add new sessions created elsewhere
|
|
1885
|
+
for (const s of list) {
|
|
1886
|
+
if (!managed.has(s.id)) {
|
|
1887
|
+
addSession(s);
|
|
1888
|
+
} else {
|
|
1889
|
+
// Update metadata
|
|
1890
|
+
const ms = managed.get(s.id);
|
|
1891
|
+
ms.name = s.name;
|
|
1892
|
+
ms.color = s.color;
|
|
1893
|
+
ms.lastActivity = s.lastActivity;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
795
1896
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1897
|
+
// Remove sessions deleted elsewhere
|
|
1898
|
+
for (const id of [...managed.keys()]) {
|
|
1899
|
+
if (!serverIds.has(id)) {
|
|
1900
|
+
const ms = managed.get(id);
|
|
1901
|
+
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
1902
|
+
if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
|
|
1903
|
+
ms.term.dispose();
|
|
1904
|
+
ms.container.remove();
|
|
1905
|
+
managed.delete(id);
|
|
1906
|
+
if (id === splitSecondId) splitSecondId = null;
|
|
1907
|
+
if (id === activeId) {
|
|
1908
|
+
const remaining = [...managed.keys()];
|
|
1909
|
+
if (remaining.length > 0) activateSession(remaining[0]);
|
|
1910
|
+
else {
|
|
1911
|
+
activeId = null;
|
|
1912
|
+
sessionNameEl.textContent = 'No sessions';
|
|
1913
|
+
statusText.textContent = '';
|
|
1914
|
+
statusDot.className = '';
|
|
1915
|
+
document.getElementById('stop-btn').style.display = 'none';
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
803
1920
|
|
|
804
|
-
|
|
1921
|
+
renderTabs();
|
|
1922
|
+
} catch {}
|
|
1923
|
+
}, 3000);
|
|
805
1924
|
}
|
|
806
1925
|
|
|
1926
|
+
// ===== Share Button =====
|
|
1927
|
+
function copyToClipboardFallback(text) {
|
|
1928
|
+
const ta = document.createElement('textarea');
|
|
1929
|
+
ta.value = text;
|
|
1930
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
|
1931
|
+
document.body.appendChild(ta);
|
|
1932
|
+
ta.select();
|
|
1933
|
+
try { document.execCommand('copy'); } catch {}
|
|
1934
|
+
document.body.removeChild(ta);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
document.getElementById('share-btn').addEventListener('click', async () => {
|
|
1938
|
+
const url = location.href;
|
|
1939
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1940
|
+
try { await navigator.clipboard.writeText(url); showToast('Link copied!'); return; } catch {}
|
|
1941
|
+
}
|
|
1942
|
+
copyToClipboardFallback(url);
|
|
1943
|
+
showToast('Link copied!');
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// ===== Refresh Button =====
|
|
1947
|
+
document.getElementById('refresh-btn').addEventListener('click', async () => {
|
|
1948
|
+
if ('caches' in window) {
|
|
1949
|
+
const keys = await caches.keys();
|
|
1950
|
+
await Promise.all(keys.map(k => caches.delete(k)));
|
|
1951
|
+
}
|
|
1952
|
+
if (navigator.serviceWorker) {
|
|
1953
|
+
const reg = await navigator.serviceWorker.getRegistration();
|
|
1954
|
+
if (reg) await reg.update();
|
|
1955
|
+
}
|
|
1956
|
+
location.reload();
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
// ===== Service Worker =====
|
|
807
1960
|
if ('serviceWorker' in navigator) {
|
|
808
1961
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
809
1962
|
}
|