lit-shell.js 1.1.0 → 1.2.1
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/CHANGELOG.md +36 -0
- package/README.md +438 -9
- package/dist/client/browser-bundle.js +61 -1
- package/dist/client/browser-bundle.js.map +3 -3
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/terminal-client.d.ts +19 -0
- package/dist/client/terminal-client.d.ts.map +1 -1
- package/dist/client/terminal-client.js +61 -0
- package/dist/client/terminal-client.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/session-manager.d.ts +6 -0
- package/dist/server/session-manager.d.ts.map +1 -1
- package/dist/server/session-manager.js +12 -2
- package/dist/server/session-manager.js.map +1 -1
- package/dist/server/terminal-server.d.ts.map +1 -1
- package/dist/server/terminal-server.js +23 -4
- package/dist/server/terminal-server.js.map +1 -1
- package/dist/shared/types.d.ts +6 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/ui/browser-bundle.js +1625 -96
- package/dist/ui/browser-bundle.js.map +4 -4
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/lit-shell-terminal.d.ts +225 -6
- package/dist/ui/lit-shell-terminal.d.ts.map +1 -1
- package/dist/ui/lit-shell-terminal.js +1605 -60
- package/dist/ui/lit-shell-terminal.js.map +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/styles.js +22 -0
- package/dist/ui/styles.js.map +1 -1
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* lit-shell-terminal web component
|
|
3
3
|
*
|
|
4
4
|
* A ready-to-use terminal component that wraps xterm.js and
|
|
5
|
-
* connects to
|
|
5
|
+
* connects to an lit-shell server via WebSocket.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* ```html
|
|
@@ -24,6 +24,7 @@ import { LitElement, html, css, nothing } from 'lit';
|
|
|
24
24
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
25
25
|
import { sharedStyles, buttonStyles, themeStyles } from './styles.js';
|
|
26
26
|
import { TerminalClient } from '../client/terminal-client.js';
|
|
27
|
+
import { VERSION } from '../version.js';
|
|
27
28
|
let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
28
29
|
constructor() {
|
|
29
30
|
super(...arguments);
|
|
@@ -37,9 +38,19 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
37
38
|
this.noHeader = false;
|
|
38
39
|
this.autoConnect = false;
|
|
39
40
|
this.autoSpawn = false;
|
|
41
|
+
// Docker container properties
|
|
42
|
+
this.container = '';
|
|
43
|
+
this.containerShell = '';
|
|
44
|
+
this.containerUser = '';
|
|
45
|
+
this.containerCwd = '';
|
|
46
|
+
// UI panel options
|
|
47
|
+
this.showConnectionPanel = false;
|
|
48
|
+
this.showSettings = false;
|
|
49
|
+
this.showStatusBar = false;
|
|
50
|
+
this.showTabs = false;
|
|
40
51
|
// Terminal appearance
|
|
41
52
|
this.fontSize = 14;
|
|
42
|
-
this.fontFamily = 'Menlo, Monaco, "Courier New", monospace';
|
|
53
|
+
this.fontFamily = '"Cascadia Mono", "Cascadia Code", Consolas, "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", Hack, "Fira Code", "JetBrains Mono", Menlo, Monaco, "Courier New", monospace';
|
|
43
54
|
// State
|
|
44
55
|
this.client = null;
|
|
45
56
|
this.terminal = null;
|
|
@@ -49,17 +60,89 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
49
60
|
this.loading = false;
|
|
50
61
|
this.error = null;
|
|
51
62
|
this.sessionInfo = null;
|
|
63
|
+
// Connection panel state
|
|
64
|
+
this.containers = [];
|
|
65
|
+
this.serverInfo = null;
|
|
66
|
+
this.selectedContainer = '';
|
|
67
|
+
this.selectedShell = '/bin/sh';
|
|
68
|
+
this.connectionMode = 'local';
|
|
69
|
+
// Persistence options
|
|
70
|
+
this.orphanTimeout = 3600000; // 1 hour default
|
|
71
|
+
this.useTmux = false;
|
|
72
|
+
// Session multiplexing state
|
|
73
|
+
this.availableSessions = [];
|
|
74
|
+
this.selectedSessionId = '';
|
|
75
|
+
this.clientCount = 1;
|
|
76
|
+
// Settings state
|
|
77
|
+
this.settingsMenuOpen = false;
|
|
78
|
+
// Reconnect dialog state
|
|
79
|
+
this.showReconnectDialog = false;
|
|
80
|
+
this.reconnectSessionId = null;
|
|
81
|
+
// Mobile support state
|
|
82
|
+
this.isMobile = false;
|
|
83
|
+
this.showTouchKeyboard = true;
|
|
84
|
+
this.showExtraKeyRows = false;
|
|
85
|
+
// Status bar state
|
|
86
|
+
this.statusMessage = '';
|
|
87
|
+
this.statusType = 'info';
|
|
88
|
+
// Tab state
|
|
89
|
+
this.tabs = [];
|
|
90
|
+
this.activeTabId = '';
|
|
91
|
+
this.tabCounter = 0;
|
|
52
92
|
// xterm.js module (loaded dynamically)
|
|
53
93
|
this.xtermModule = null;
|
|
54
94
|
this.fitAddonModule = null;
|
|
55
95
|
this.resizeObserver = null;
|
|
96
|
+
// ==================== Touch Keyboard Methods ====================
|
|
97
|
+
// Modifier key state for touch keyboard
|
|
98
|
+
this.ctrlPressed = false;
|
|
99
|
+
this.altPressed = false;
|
|
56
100
|
}
|
|
57
101
|
connectedCallback() {
|
|
58
102
|
super.connectedCallback();
|
|
103
|
+
// Set version as data attribute for easy inspection
|
|
104
|
+
this.setAttribute('data-version', VERSION);
|
|
105
|
+
// Detect mobile devices
|
|
106
|
+
this.detectMobile();
|
|
107
|
+
// Create initial tab if tabs are enabled
|
|
108
|
+
if (this.showTabs && this.tabs.length === 0) {
|
|
109
|
+
this.createTab();
|
|
110
|
+
}
|
|
59
111
|
if (this.autoConnect && this.url) {
|
|
60
112
|
this.connect();
|
|
61
113
|
}
|
|
62
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Detect if running on a mobile device
|
|
117
|
+
*/
|
|
118
|
+
detectMobile() {
|
|
119
|
+
// Check for touch capability
|
|
120
|
+
const hasTouch = navigator.maxTouchPoints > 0;
|
|
121
|
+
// Check for mobile-sized viewport
|
|
122
|
+
const isMobileWidth = window.matchMedia('(max-width: 768px)').matches;
|
|
123
|
+
// Check for mobile user agent (backup detection)
|
|
124
|
+
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
125
|
+
this.isMobile = hasTouch && (isMobileWidth || mobileUA);
|
|
126
|
+
this.updateMobileAttribute();
|
|
127
|
+
// Listen for viewport changes
|
|
128
|
+
window.matchMedia('(max-width: 768px)').addEventListener('change', (e) => {
|
|
129
|
+
if (navigator.maxTouchPoints > 0) {
|
|
130
|
+
this.isMobile = e.matches;
|
|
131
|
+
this.updateMobileAttribute();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Update mobile attribute for CSS targeting
|
|
137
|
+
*/
|
|
138
|
+
updateMobileAttribute() {
|
|
139
|
+
if (this.isMobile) {
|
|
140
|
+
this.setAttribute('mobile', '');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
this.removeAttribute('mobile');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
63
146
|
disconnectedCallback() {
|
|
64
147
|
super.disconnectedCallback();
|
|
65
148
|
this.cleanup();
|
|
@@ -89,35 +172,30 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
89
172
|
throw new Error('Failed to load xterm.js. Make sure it is available.');
|
|
90
173
|
}
|
|
91
174
|
}
|
|
92
|
-
//
|
|
93
|
-
await this.
|
|
175
|
+
// Inject xterm CSS into shadow DOM (required because CSS doesn't cross shadow boundaries)
|
|
176
|
+
await this.injectXtermCSS();
|
|
94
177
|
}
|
|
95
178
|
/**
|
|
96
|
-
*
|
|
97
|
-
* This is necessary because CSS loaded in the main document doesn't apply inside shadow DOM
|
|
179
|
+
* Inject xterm.js CSS into shadow DOM
|
|
98
180
|
*/
|
|
99
|
-
async
|
|
181
|
+
async injectXtermCSS() {
|
|
100
182
|
if (!this.shadowRoot)
|
|
101
183
|
return;
|
|
102
|
-
// Check if
|
|
184
|
+
// Check if already injected
|
|
103
185
|
if (this.shadowRoot.querySelector('#xterm-styles'))
|
|
104
186
|
return;
|
|
105
187
|
try {
|
|
106
|
-
// Fetch xterm
|
|
188
|
+
// Fetch xterm CSS from CDN
|
|
107
189
|
const response = await fetch('https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css');
|
|
108
|
-
|
|
109
|
-
throw new Error(`Failed to fetch xterm.css: ${response.status}`);
|
|
110
|
-
}
|
|
111
|
-
const cssText = await response.text();
|
|
190
|
+
const css = await response.text();
|
|
112
191
|
// Create style element and inject into shadow DOM
|
|
113
192
|
const style = document.createElement('style');
|
|
114
193
|
style.id = 'xterm-styles';
|
|
115
|
-
style.textContent =
|
|
116
|
-
this.shadowRoot.
|
|
194
|
+
style.textContent = css;
|
|
195
|
+
this.shadowRoot.prepend(style);
|
|
117
196
|
}
|
|
118
|
-
catch (
|
|
119
|
-
console.warn('[lit-shell] Failed to load xterm
|
|
120
|
-
// Terminal will still work but may have visual issues
|
|
197
|
+
catch (e) {
|
|
198
|
+
console.warn('[lit-shell] Failed to load xterm CSS:', e);
|
|
121
199
|
}
|
|
122
200
|
}
|
|
123
201
|
/**
|
|
@@ -149,27 +227,144 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
149
227
|
});
|
|
150
228
|
this.client.onError((err) => {
|
|
151
229
|
this.error = err.message;
|
|
230
|
+
this.setStatus(err.message, 'error');
|
|
152
231
|
this.dispatchEvent(new CustomEvent('error', { detail: { error: err }, bubbles: true, composed: true }));
|
|
153
232
|
});
|
|
233
|
+
// Capture reference to this client for use in closures
|
|
234
|
+
const client = this.client;
|
|
154
235
|
this.client.onData((data) => {
|
|
155
|
-
|
|
236
|
+
// Find the terminal that belongs to this client (for multi-tab support)
|
|
237
|
+
if (this.showTabs) {
|
|
238
|
+
const tab = this.tabs.find((t) => t.client === client);
|
|
239
|
+
if (tab?.terminal) {
|
|
240
|
+
tab.terminal.write(data);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else if (this.terminal) {
|
|
156
244
|
this.terminal.write(data);
|
|
157
245
|
}
|
|
158
246
|
});
|
|
159
247
|
this.client.onExit((code) => {
|
|
160
|
-
this
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
248
|
+
// Find the tab that belongs to this client
|
|
249
|
+
if (this.showTabs) {
|
|
250
|
+
const tab = this.tabs.find((t) => t.client === client);
|
|
251
|
+
if (tab) {
|
|
252
|
+
tab.sessionActive = false;
|
|
253
|
+
tab.sessionInfo = null;
|
|
254
|
+
if (tab.terminal) {
|
|
255
|
+
tab.terminal.writeln('');
|
|
256
|
+
tab.terminal.writeln(`\x1b[1;33m[Process exited with code: ${code}]\x1b[0m`);
|
|
257
|
+
}
|
|
258
|
+
// Update component state if this is the active tab
|
|
259
|
+
if (tab.id === this.activeTabId) {
|
|
260
|
+
this.sessionActive = false;
|
|
261
|
+
this.sessionInfo = null;
|
|
262
|
+
}
|
|
263
|
+
this.tabs = [...this.tabs]; // Trigger re-render
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
this.sessionActive = false;
|
|
268
|
+
this.sessionInfo = null;
|
|
269
|
+
if (this.terminal) {
|
|
270
|
+
this.terminal.writeln('');
|
|
271
|
+
this.terminal.writeln(`\x1b[1;33m[Process exited with code: ${code}]\x1b[0m`);
|
|
272
|
+
}
|
|
165
273
|
}
|
|
166
274
|
this.dispatchEvent(new CustomEvent('exit', { detail: { exitCode: code }, bubbles: true, composed: true }));
|
|
167
275
|
});
|
|
168
276
|
this.client.onSpawned((info) => {
|
|
277
|
+
// Update tab-specific state
|
|
278
|
+
if (this.showTabs) {
|
|
279
|
+
const tab = this.tabs.find((t) => t.client === client);
|
|
280
|
+
if (tab) {
|
|
281
|
+
tab.sessionInfo = info;
|
|
282
|
+
tab.sessionActive = true;
|
|
283
|
+
// Update label to show shell/container
|
|
284
|
+
tab.label = info.container || info.shell.split('/').pop() || 'Terminal';
|
|
285
|
+
this.tabs = [...this.tabs];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
169
288
|
this.sessionInfo = info;
|
|
289
|
+
this.setStatus(`Session started: ${info.container || info.shell}`, 'success');
|
|
170
290
|
this.dispatchEvent(new CustomEvent('spawned', { detail: { session: info }, bubbles: true, composed: true }));
|
|
171
291
|
});
|
|
292
|
+
// Server info and container list handlers
|
|
293
|
+
this.client.onServerInfo((info) => {
|
|
294
|
+
this.serverInfo = info;
|
|
295
|
+
if (info.dockerEnabled) {
|
|
296
|
+
this.connectionMode = 'docker';
|
|
297
|
+
this.client?.requestContainerList();
|
|
298
|
+
}
|
|
299
|
+
this.selectedShell = info.defaultShell;
|
|
300
|
+
});
|
|
301
|
+
this.client.onContainerList((containers) => {
|
|
302
|
+
this.containers = containers;
|
|
303
|
+
if (containers.length > 0 && !this.selectedContainer) {
|
|
304
|
+
this.selectedContainer = containers[0].name;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
// Multiplexing event handlers
|
|
308
|
+
this.client.onSessionList((sessions) => {
|
|
309
|
+
this.availableSessions = sessions;
|
|
310
|
+
if (sessions.length > 0 && !this.selectedSessionId) {
|
|
311
|
+
this.selectedSessionId = sessions[0].sessionId;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
this.client.onClientJoined((sessionId, count) => {
|
|
315
|
+
// Update tab-specific state
|
|
316
|
+
if (this.showTabs) {
|
|
317
|
+
const tab = this.tabs.find((t) => t.client === client);
|
|
318
|
+
if (tab) {
|
|
319
|
+
tab.clientCount = count;
|
|
320
|
+
this.tabs = [...this.tabs];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
this.clientCount = count;
|
|
324
|
+
this.setStatus(`Client joined (${count} total)`, 'info');
|
|
325
|
+
});
|
|
326
|
+
this.client.onClientLeft((sessionId, count) => {
|
|
327
|
+
// Update tab-specific state
|
|
328
|
+
if (this.showTabs) {
|
|
329
|
+
const tab = this.tabs.find((t) => t.client === client);
|
|
330
|
+
if (tab) {
|
|
331
|
+
tab.clientCount = count;
|
|
332
|
+
this.tabs = [...this.tabs];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
this.clientCount = count;
|
|
336
|
+
this.setStatus(`Client left (${count} remaining)`, 'info');
|
|
337
|
+
});
|
|
338
|
+
this.client.onSessionClosed((sessionId, reason) => {
|
|
339
|
+
// Update tab-specific state
|
|
340
|
+
if (this.showTabs) {
|
|
341
|
+
const tab = this.tabs.find((t) => t.client === client && t.sessionInfo?.sessionId === sessionId);
|
|
342
|
+
if (tab) {
|
|
343
|
+
tab.sessionActive = false;
|
|
344
|
+
tab.sessionInfo = null;
|
|
345
|
+
this.tabs = [...this.tabs];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (this.sessionInfo?.sessionId === sessionId) {
|
|
349
|
+
this.sessionActive = false;
|
|
350
|
+
this.sessionInfo = null;
|
|
351
|
+
this.setStatus(`Session closed: ${reason}`, 'info');
|
|
352
|
+
}
|
|
353
|
+
// Refresh session list
|
|
354
|
+
client.requestSessionList();
|
|
355
|
+
});
|
|
356
|
+
// Reconnect dialog handler
|
|
357
|
+
this.client.onReconnectWithSession((sessionId) => {
|
|
358
|
+
this.reconnectSessionId = sessionId;
|
|
359
|
+
this.showReconnectDialog = true;
|
|
360
|
+
});
|
|
172
361
|
await this.client.connect();
|
|
362
|
+
// Request session list after connecting
|
|
363
|
+
this.client.requestSessionList();
|
|
364
|
+
// Sync state to active tab
|
|
365
|
+
if (this.showTabs) {
|
|
366
|
+
this.syncStateToActiveTab();
|
|
367
|
+
}
|
|
173
368
|
}
|
|
174
369
|
catch (err) {
|
|
175
370
|
this.error = err instanceof Error ? err.message : 'Connection failed';
|
|
@@ -208,17 +403,30 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
208
403
|
cols: this.terminal?.cols || this.cols,
|
|
209
404
|
rows: this.terminal?.rows || this.rows,
|
|
210
405
|
env: options?.env,
|
|
406
|
+
// Docker container options
|
|
407
|
+
container: options?.container || this.container || undefined,
|
|
408
|
+
containerShell: options?.containerShell || this.containerShell || undefined,
|
|
409
|
+
containerUser: options?.containerUser || this.containerUser || undefined,
|
|
410
|
+
containerCwd: options?.containerCwd || this.containerCwd || undefined,
|
|
211
411
|
};
|
|
212
412
|
const info = await this.client.spawn(spawnOptions);
|
|
213
413
|
this.sessionActive = true;
|
|
214
414
|
this.sessionInfo = info;
|
|
415
|
+
// Sync state to active tab
|
|
416
|
+
if (this.showTabs) {
|
|
417
|
+
this.syncStateToActiveTab();
|
|
418
|
+
}
|
|
419
|
+
// Refresh session list so other tabs can see this session
|
|
420
|
+
this.client.requestSessionList();
|
|
215
421
|
// Focus terminal
|
|
216
422
|
if (this.terminal) {
|
|
217
423
|
this.terminal.focus();
|
|
218
424
|
}
|
|
425
|
+
return info;
|
|
219
426
|
}
|
|
220
427
|
catch (err) {
|
|
221
428
|
this.error = err instanceof Error ? err.message : 'Failed to spawn session';
|
|
429
|
+
throw err;
|
|
222
430
|
}
|
|
223
431
|
finally {
|
|
224
432
|
this.loading = false;
|
|
@@ -232,7 +440,14 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
232
440
|
return;
|
|
233
441
|
await this.loadXterm();
|
|
234
442
|
await this.updateComplete;
|
|
235
|
-
|
|
443
|
+
// Get the correct container (either single terminal or tab-specific)
|
|
444
|
+
let container = null;
|
|
445
|
+
if (this.showTabs && this.activeTabId) {
|
|
446
|
+
container = this.shadowRoot?.querySelector(`.tab-terminal-container[data-tab-id="${this.activeTabId}"]`) ?? null;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
container = this.shadowRoot?.querySelector('.terminal-container') ?? null;
|
|
450
|
+
}
|
|
236
451
|
if (!container)
|
|
237
452
|
return;
|
|
238
453
|
// Get theme colors
|
|
@@ -269,33 +484,54 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
269
484
|
this.client.resize(cols, rows);
|
|
270
485
|
}
|
|
271
486
|
});
|
|
272
|
-
// Setup resize observer
|
|
273
|
-
this.resizeObserver
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
487
|
+
// Setup resize observer for this container
|
|
488
|
+
if (!this.resizeObserver) {
|
|
489
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
490
|
+
// Fit all visible terminals
|
|
491
|
+
if (this.showTabs) {
|
|
492
|
+
const activeTab = this.getActiveTab();
|
|
493
|
+
if (activeTab?.fitAddon) {
|
|
494
|
+
activeTab.fitAddon.fit();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else if (this.fitAddon) {
|
|
498
|
+
this.fitAddon.fit();
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
278
502
|
this.resizeObserver.observe(container);
|
|
503
|
+
// Sync to active tab if in tab mode
|
|
504
|
+
if (this.showTabs) {
|
|
505
|
+
this.syncStateToActiveTab();
|
|
506
|
+
}
|
|
279
507
|
}
|
|
280
508
|
/**
|
|
281
509
|
* Get terminal theme based on component theme
|
|
282
510
|
*/
|
|
283
511
|
getTerminalTheme() {
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
if (this.theme === '
|
|
512
|
+
// Determine effective theme (handle 'auto' by checking system preference)
|
|
513
|
+
let effectiveTheme = this.theme;
|
|
514
|
+
if (this.theme === 'auto') {
|
|
515
|
+
effectiveTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
516
|
+
}
|
|
517
|
+
if (effectiveTheme === 'light') {
|
|
287
518
|
return {
|
|
288
519
|
background: '#ffffff',
|
|
289
520
|
foreground: '#1f2937',
|
|
290
521
|
cursor: '#1f2937',
|
|
522
|
+
cursorAccent: '#ffffff',
|
|
291
523
|
selection: '#b4d5fe',
|
|
524
|
+
selectionForeground: '#1f2937',
|
|
292
525
|
};
|
|
293
526
|
}
|
|
527
|
+
// Dark theme
|
|
294
528
|
return {
|
|
295
529
|
background: '#1e1e1e',
|
|
296
530
|
foreground: '#cccccc',
|
|
297
531
|
cursor: '#ffffff',
|
|
532
|
+
cursorAccent: '#1e1e1e',
|
|
298
533
|
selection: '#264f78',
|
|
534
|
+
selectionForeground: '#ffffff',
|
|
299
535
|
};
|
|
300
536
|
}
|
|
301
537
|
/**
|
|
@@ -358,6 +594,755 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
358
594
|
}
|
|
359
595
|
this.fitAddon = null;
|
|
360
596
|
}
|
|
597
|
+
// ==================== Tab Management Methods ====================
|
|
598
|
+
/**
|
|
599
|
+
* Get the active tab
|
|
600
|
+
*/
|
|
601
|
+
getActiveTab() {
|
|
602
|
+
return this.tabs.find((t) => t.id === this.activeTabId);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Create a new tab
|
|
606
|
+
*/
|
|
607
|
+
createTab(label) {
|
|
608
|
+
this.tabCounter++;
|
|
609
|
+
const tab = {
|
|
610
|
+
id: `tab-${this.tabCounter}`,
|
|
611
|
+
label: label || `Terminal ${this.tabCounter}`,
|
|
612
|
+
client: null,
|
|
613
|
+
terminal: null,
|
|
614
|
+
fitAddon: null,
|
|
615
|
+
connected: false,
|
|
616
|
+
sessionActive: false,
|
|
617
|
+
sessionInfo: null,
|
|
618
|
+
clientCount: 1,
|
|
619
|
+
containerEl: null,
|
|
620
|
+
};
|
|
621
|
+
this.tabs = [...this.tabs, tab];
|
|
622
|
+
// Switch to the new tab
|
|
623
|
+
this.switchTab(tab.id);
|
|
624
|
+
return tab;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Switch to a tab
|
|
628
|
+
*/
|
|
629
|
+
switchTab(tabId) {
|
|
630
|
+
const tab = this.tabs.find((t) => t.id === tabId);
|
|
631
|
+
if (!tab)
|
|
632
|
+
return;
|
|
633
|
+
this.activeTabId = tabId;
|
|
634
|
+
// Sync state from tab to component (for backward compatibility with single-terminal code)
|
|
635
|
+
this.client = tab.client;
|
|
636
|
+
this.terminal = tab.terminal;
|
|
637
|
+
this.fitAddon = tab.fitAddon;
|
|
638
|
+
this.connected = tab.connected;
|
|
639
|
+
this.sessionActive = tab.sessionActive;
|
|
640
|
+
this.sessionInfo = tab.sessionInfo;
|
|
641
|
+
this.clientCount = tab.clientCount;
|
|
642
|
+
// Focus the terminal and fit it
|
|
643
|
+
this.updateComplete.then(() => {
|
|
644
|
+
if (tab.terminal) {
|
|
645
|
+
tab.terminal.focus();
|
|
646
|
+
}
|
|
647
|
+
if (tab.fitAddon) {
|
|
648
|
+
tab.fitAddon.fit();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Close a tab
|
|
654
|
+
*/
|
|
655
|
+
closeTab(tabId) {
|
|
656
|
+
const tabIndex = this.tabs.findIndex((t) => t.id === tabId);
|
|
657
|
+
if (tabIndex === -1)
|
|
658
|
+
return;
|
|
659
|
+
const tab = this.tabs[tabIndex];
|
|
660
|
+
// Cleanup tab resources
|
|
661
|
+
if (tab.terminal) {
|
|
662
|
+
tab.terminal.dispose();
|
|
663
|
+
}
|
|
664
|
+
if (tab.client) {
|
|
665
|
+
tab.client.disconnect();
|
|
666
|
+
}
|
|
667
|
+
// Remove tab
|
|
668
|
+
this.tabs = this.tabs.filter((t) => t.id !== tabId);
|
|
669
|
+
// If we closed the active tab, switch to another
|
|
670
|
+
if (this.activeTabId === tabId && this.tabs.length > 0) {
|
|
671
|
+
// Switch to previous tab, or first if we were first
|
|
672
|
+
const newIndex = Math.max(0, tabIndex - 1);
|
|
673
|
+
this.switchTab(this.tabs[newIndex].id);
|
|
674
|
+
}
|
|
675
|
+
// If no tabs left, clear state
|
|
676
|
+
if (this.tabs.length === 0) {
|
|
677
|
+
this.activeTabId = '';
|
|
678
|
+
this.client = null;
|
|
679
|
+
this.terminal = null;
|
|
680
|
+
this.fitAddon = null;
|
|
681
|
+
this.connected = false;
|
|
682
|
+
this.sessionActive = false;
|
|
683
|
+
this.sessionInfo = null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Update the active tab's state from component state
|
|
688
|
+
*/
|
|
689
|
+
syncStateToActiveTab() {
|
|
690
|
+
const tab = this.getActiveTab();
|
|
691
|
+
if (tab) {
|
|
692
|
+
tab.client = this.client;
|
|
693
|
+
tab.terminal = this.terminal;
|
|
694
|
+
tab.fitAddon = this.fitAddon;
|
|
695
|
+
tab.connected = this.connected;
|
|
696
|
+
tab.sessionActive = this.sessionActive;
|
|
697
|
+
tab.sessionInfo = this.sessionInfo;
|
|
698
|
+
tab.clientCount = this.clientCount;
|
|
699
|
+
// Trigger re-render
|
|
700
|
+
this.tabs = [...this.tabs];
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Render the tab bar
|
|
705
|
+
*/
|
|
706
|
+
renderTabBar() {
|
|
707
|
+
if (!this.showTabs)
|
|
708
|
+
return nothing;
|
|
709
|
+
return html `
|
|
710
|
+
<div class="tab-bar">
|
|
711
|
+
${this.tabs.map((tab) => html `
|
|
712
|
+
<button
|
|
713
|
+
class="tab ${tab.id === this.activeTabId ? 'active' : ''}"
|
|
714
|
+
@click=${() => this.switchTab(tab.id)}
|
|
715
|
+
>
|
|
716
|
+
<span class="tab-status ${tab.sessionActive ? 'connected' : ''}"></span>
|
|
717
|
+
<span>${tab.label}</span>
|
|
718
|
+
${this.tabs.length > 1
|
|
719
|
+
? html `
|
|
720
|
+
<button
|
|
721
|
+
class="tab-close"
|
|
722
|
+
@click=${(e) => {
|
|
723
|
+
e.stopPropagation();
|
|
724
|
+
this.closeTab(tab.id);
|
|
725
|
+
}}
|
|
726
|
+
title="Close tab"
|
|
727
|
+
>
|
|
728
|
+
×
|
|
729
|
+
</button>
|
|
730
|
+
`
|
|
731
|
+
: nothing}
|
|
732
|
+
</button>
|
|
733
|
+
`)}
|
|
734
|
+
<button class="tab-add" @click=${() => this.createTab()} title="New tab">
|
|
735
|
+
+
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
`;
|
|
739
|
+
}
|
|
740
|
+
// ==================== End Tab Management Methods ====================
|
|
741
|
+
/**
|
|
742
|
+
* Set status message
|
|
743
|
+
*/
|
|
744
|
+
setStatus(message, type = 'info') {
|
|
745
|
+
this.statusMessage = message;
|
|
746
|
+
this.statusType = type;
|
|
747
|
+
// Auto-clear success/info messages after 5 seconds
|
|
748
|
+
if (type !== 'error') {
|
|
749
|
+
setTimeout(() => {
|
|
750
|
+
if (this.statusMessage === message) {
|
|
751
|
+
this.statusMessage = '';
|
|
752
|
+
}
|
|
753
|
+
}, 5000);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Clear status message
|
|
758
|
+
*/
|
|
759
|
+
clearStatus() {
|
|
760
|
+
this.statusMessage = '';
|
|
761
|
+
this.statusType = 'info';
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Handle theme change
|
|
765
|
+
*/
|
|
766
|
+
handleThemeChange(e) {
|
|
767
|
+
const select = e.target;
|
|
768
|
+
this.theme = select.value;
|
|
769
|
+
// Apply theme to xterm.js terminal
|
|
770
|
+
this.applyTerminalTheme();
|
|
771
|
+
this.dispatchEvent(new CustomEvent('theme-change', {
|
|
772
|
+
detail: { theme: this.theme },
|
|
773
|
+
bubbles: true,
|
|
774
|
+
composed: true
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Apply current theme to xterm.js terminal
|
|
779
|
+
*/
|
|
780
|
+
applyTerminalTheme() {
|
|
781
|
+
if (!this.terminal)
|
|
782
|
+
return;
|
|
783
|
+
const terminalTheme = this.getTerminalTheme();
|
|
784
|
+
this.terminal.options.theme = terminalTheme;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Apply current font size to xterm.js terminal
|
|
788
|
+
*/
|
|
789
|
+
applyTerminalFontSize() {
|
|
790
|
+
if (!this.terminal)
|
|
791
|
+
return;
|
|
792
|
+
this.terminal.options.fontSize = this.fontSize;
|
|
793
|
+
// Re-fit the terminal after font size change
|
|
794
|
+
if (this.fitAddon) {
|
|
795
|
+
this.fitAddon.fit();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Handle connection mode change
|
|
800
|
+
*/
|
|
801
|
+
handleModeChange(e) {
|
|
802
|
+
const select = e.target;
|
|
803
|
+
this.connectionMode = select.value;
|
|
804
|
+
if ((this.connectionMode === 'docker' || this.connectionMode === 'docker-attach') && this.client && this.connected) {
|
|
805
|
+
this.client.requestContainerList();
|
|
806
|
+
}
|
|
807
|
+
if (this.connectionMode === 'join' && this.client && this.connected) {
|
|
808
|
+
this.client.requestSessionList();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Refresh session list
|
|
813
|
+
*/
|
|
814
|
+
refreshSessions() {
|
|
815
|
+
if (this.client && this.connected) {
|
|
816
|
+
this.client.requestSessionList();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Join an existing session
|
|
821
|
+
*/
|
|
822
|
+
async join(sessionId, requestHistory = true) {
|
|
823
|
+
if (!this.client || !this.connected) {
|
|
824
|
+
throw new Error('Not connected to server');
|
|
825
|
+
}
|
|
826
|
+
this.loading = true;
|
|
827
|
+
this.error = null;
|
|
828
|
+
try {
|
|
829
|
+
// Initialize terminal UI if needed
|
|
830
|
+
await this.initTerminalUI();
|
|
831
|
+
const session = await this.client.join({
|
|
832
|
+
sessionId,
|
|
833
|
+
requestHistory,
|
|
834
|
+
historyLimit: 50000,
|
|
835
|
+
});
|
|
836
|
+
this.sessionActive = true;
|
|
837
|
+
this.sessionInfo = {
|
|
838
|
+
sessionId: session.sessionId,
|
|
839
|
+
shell: session.shell,
|
|
840
|
+
cwd: session.cwd,
|
|
841
|
+
cols: session.cols,
|
|
842
|
+
rows: session.rows,
|
|
843
|
+
createdAt: session.createdAt || new Date(),
|
|
844
|
+
container: session.container,
|
|
845
|
+
};
|
|
846
|
+
this.clientCount = session.clientCount;
|
|
847
|
+
// Sync state to active tab
|
|
848
|
+
if (this.showTabs) {
|
|
849
|
+
this.syncStateToActiveTab();
|
|
850
|
+
}
|
|
851
|
+
this.setStatus(`Joined session (${session.clientCount} clients)`, 'success');
|
|
852
|
+
// Focus terminal
|
|
853
|
+
if (this.terminal) {
|
|
854
|
+
this.terminal.focus();
|
|
855
|
+
}
|
|
856
|
+
return session;
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
this.error = err instanceof Error ? err.message : 'Failed to join session';
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
finally {
|
|
863
|
+
this.loading = false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Leave current session without killing it
|
|
868
|
+
*/
|
|
869
|
+
leave() {
|
|
870
|
+
if (this.client && this.sessionInfo) {
|
|
871
|
+
this.client.leave(this.sessionInfo.sessionId);
|
|
872
|
+
this.sessionActive = false;
|
|
873
|
+
this.sessionInfo = null;
|
|
874
|
+
this.setStatus('Left session', 'info');
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Handle connect from connection panel
|
|
879
|
+
*/
|
|
880
|
+
async handlePanelConnect() {
|
|
881
|
+
if (!this.connected) {
|
|
882
|
+
await this.connect();
|
|
883
|
+
}
|
|
884
|
+
if (this.connected) {
|
|
885
|
+
if (this.connectionMode === 'join' && this.selectedSessionId) {
|
|
886
|
+
// Join existing session
|
|
887
|
+
await this.join(this.selectedSessionId);
|
|
888
|
+
}
|
|
889
|
+
else if (this.connectionMode === 'docker-attach' && this.selectedContainer) {
|
|
890
|
+
// Docker attach mode - connect to container's main process
|
|
891
|
+
const options = {
|
|
892
|
+
container: this.selectedContainer,
|
|
893
|
+
attachMode: true,
|
|
894
|
+
orphanTimeout: this.orphanTimeout,
|
|
895
|
+
};
|
|
896
|
+
await this.spawn(options);
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
// Spawn new session (local or docker exec)
|
|
900
|
+
const options = {
|
|
901
|
+
orphanTimeout: this.orphanTimeout,
|
|
902
|
+
useTmux: this.useTmux,
|
|
903
|
+
};
|
|
904
|
+
if (this.connectionMode === 'docker' && this.selectedContainer) {
|
|
905
|
+
options.container = this.selectedContainer;
|
|
906
|
+
options.containerShell = this.selectedShell || '/bin/sh';
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
options.shell = this.selectedShell || undefined;
|
|
910
|
+
}
|
|
911
|
+
await this.spawn(options);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Toggle settings menu
|
|
917
|
+
*/
|
|
918
|
+
toggleSettingsMenu() {
|
|
919
|
+
this.settingsMenuOpen = !this.settingsMenuOpen;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Handle reconnect dialog - Yes button
|
|
923
|
+
*/
|
|
924
|
+
async handleReconnectYes() {
|
|
925
|
+
if (!this.reconnectSessionId || !this.client)
|
|
926
|
+
return;
|
|
927
|
+
this.showReconnectDialog = false;
|
|
928
|
+
try {
|
|
929
|
+
// Initialize terminal UI if needed
|
|
930
|
+
await this.initTerminalUI();
|
|
931
|
+
// Join the previous session with history
|
|
932
|
+
await this.join(this.reconnectSessionId, true);
|
|
933
|
+
this.setStatus('Rejoined previous session', 'success');
|
|
934
|
+
}
|
|
935
|
+
catch (err) {
|
|
936
|
+
this.setStatus(`Failed to rejoin: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error');
|
|
937
|
+
}
|
|
938
|
+
this.reconnectSessionId = null;
|
|
939
|
+
this.client?.clearPreviousSessionId();
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Handle reconnect dialog - No button
|
|
943
|
+
*/
|
|
944
|
+
handleReconnectNo() {
|
|
945
|
+
this.showReconnectDialog = false;
|
|
946
|
+
this.reconnectSessionId = null;
|
|
947
|
+
this.client?.clearPreviousSessionId();
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Render reconnect dialog
|
|
951
|
+
*/
|
|
952
|
+
renderReconnectDialog() {
|
|
953
|
+
if (!this.showReconnectDialog)
|
|
954
|
+
return nothing;
|
|
955
|
+
return html `
|
|
956
|
+
<div class="reconnect-dialog-overlay">
|
|
957
|
+
<div class="reconnect-dialog">
|
|
958
|
+
<h3>Session Available</h3>
|
|
959
|
+
<p>Your previous session is still active. Would you like to rejoin?</p>
|
|
960
|
+
<div class="reconnect-dialog-buttons">
|
|
961
|
+
<button class="btn-primary" @click=${this.handleReconnectYes}>
|
|
962
|
+
Yes, Rejoin
|
|
963
|
+
</button>
|
|
964
|
+
<button class="btn-secondary" @click=${this.handleReconnectNo}>
|
|
965
|
+
No, Start New
|
|
966
|
+
</button>
|
|
967
|
+
</div>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Render connection panel
|
|
974
|
+
*/
|
|
975
|
+
renderConnectionPanel() {
|
|
976
|
+
if (!this.showConnectionPanel)
|
|
977
|
+
return nothing;
|
|
978
|
+
const runningContainers = this.containers.filter(c => c.state === 'running');
|
|
979
|
+
const acceptingSessions = this.availableSessions.filter(s => s.accepting);
|
|
980
|
+
return html `
|
|
981
|
+
<div class="connection-panel">
|
|
982
|
+
<div class="connection-panel-title">
|
|
983
|
+
<span>Connection</span>
|
|
984
|
+
${this.availableSessions.length > 0
|
|
985
|
+
? html `<span style="font-size: 11px; color: var(--ls-status-connected);">${this.availableSessions.length} session(s) available</span>`
|
|
986
|
+
: nothing}
|
|
987
|
+
${this.serverInfo?.dockerEnabled
|
|
988
|
+
? html `<span style="font-size: 11px; color: var(--ls-text-muted);">Docker enabled</span>`
|
|
989
|
+
: nothing}
|
|
990
|
+
</div>
|
|
991
|
+
<div class="connection-form">
|
|
992
|
+
<div class="form-group">
|
|
993
|
+
<label>Mode</label>
|
|
994
|
+
<select
|
|
995
|
+
.value=${this.connectionMode}
|
|
996
|
+
@change=${this.handleModeChange}
|
|
997
|
+
?disabled=${this.sessionActive}
|
|
998
|
+
>
|
|
999
|
+
<option value="local">New Local Shell</option>
|
|
1000
|
+
${this.serverInfo?.dockerEnabled
|
|
1001
|
+
? html `
|
|
1002
|
+
<option value="docker">Docker Exec (new shell)</option>
|
|
1003
|
+
<option value="docker-attach">Docker Attach (main process)</option>
|
|
1004
|
+
`
|
|
1005
|
+
: nothing}
|
|
1006
|
+
${acceptingSessions.length > 0
|
|
1007
|
+
? html `<option value="join">Join Existing Session</option>`
|
|
1008
|
+
: nothing}
|
|
1009
|
+
</select>
|
|
1010
|
+
</div>
|
|
1011
|
+
|
|
1012
|
+
${this.connectionMode === 'join' ? html `
|
|
1013
|
+
<div class="form-group">
|
|
1014
|
+
<label style="display: flex; justify-content: space-between; align-items: center;">
|
|
1015
|
+
<span>Session</span>
|
|
1016
|
+
<button
|
|
1017
|
+
style="font-size: 10px; padding: 2px 6px;"
|
|
1018
|
+
@click=${this.refreshSessions}
|
|
1019
|
+
?disabled=${!this.connected}
|
|
1020
|
+
>Refresh</button>
|
|
1021
|
+
</label>
|
|
1022
|
+
<select
|
|
1023
|
+
.value=${this.selectedSessionId}
|
|
1024
|
+
@change=${(e) => this.selectedSessionId = e.target.value}
|
|
1025
|
+
?disabled=${this.sessionActive}
|
|
1026
|
+
>
|
|
1027
|
+
${acceptingSessions.length === 0
|
|
1028
|
+
? html `<option value="">No sessions available</option>`
|
|
1029
|
+
: acceptingSessions.map(s => html `
|
|
1030
|
+
<option value=${s.sessionId}>
|
|
1031
|
+
${s.label || s.sessionId.substring(0, 12)}
|
|
1032
|
+
(${s.type === 'local' ? s.shell : s.container || s.type})
|
|
1033
|
+
- ${s.clientCount} client(s)
|
|
1034
|
+
</option>
|
|
1035
|
+
`)}
|
|
1036
|
+
</select>
|
|
1037
|
+
</div>
|
|
1038
|
+
` : this.connectionMode === 'docker' || this.connectionMode === 'docker-attach' ? html `
|
|
1039
|
+
<div class="form-group">
|
|
1040
|
+
<label>Container</label>
|
|
1041
|
+
<select
|
|
1042
|
+
.value=${this.selectedContainer}
|
|
1043
|
+
@change=${(e) => this.selectedContainer = e.target.value}
|
|
1044
|
+
?disabled=${this.sessionActive}
|
|
1045
|
+
>
|
|
1046
|
+
${runningContainers.length === 0
|
|
1047
|
+
? html `<option value="">No containers running</option>`
|
|
1048
|
+
: runningContainers.map(c => html `
|
|
1049
|
+
<option value=${c.name}>${c.name} (${c.image})</option>
|
|
1050
|
+
`)}
|
|
1051
|
+
</select>
|
|
1052
|
+
</div>
|
|
1053
|
+
` : nothing}
|
|
1054
|
+
|
|
1055
|
+
${this.connectionMode !== 'join' && this.connectionMode !== 'docker-attach' ? html `
|
|
1056
|
+
<div class="form-group">
|
|
1057
|
+
<label>Shell</label>
|
|
1058
|
+
<select
|
|
1059
|
+
.value=${this.selectedShell}
|
|
1060
|
+
@change=${(e) => this.selectedShell = e.target.value}
|
|
1061
|
+
?disabled=${this.sessionActive}
|
|
1062
|
+
>
|
|
1063
|
+
${this.serverInfo?.allowedShells.length
|
|
1064
|
+
? this.serverInfo.allowedShells.map(s => html `<option value=${s}>${s}</option>`)
|
|
1065
|
+
: html `
|
|
1066
|
+
<option value="/bin/bash">/bin/bash</option>
|
|
1067
|
+
<option value="/bin/sh">/bin/sh</option>
|
|
1068
|
+
<option value="/bin/zsh">/bin/zsh</option>
|
|
1069
|
+
`}
|
|
1070
|
+
</select>
|
|
1071
|
+
</div>
|
|
1072
|
+
` : nothing}
|
|
1073
|
+
|
|
1074
|
+
${this.connectionMode === 'local' || this.connectionMode === 'docker' ? html `
|
|
1075
|
+
<div class="form-group">
|
|
1076
|
+
<label>Session Timeout</label>
|
|
1077
|
+
<select
|
|
1078
|
+
.value=${String(this.orphanTimeout)}
|
|
1079
|
+
@change=${(e) => this.orphanTimeout = parseInt(e.target.value)}
|
|
1080
|
+
?disabled=${this.sessionActive}
|
|
1081
|
+
>
|
|
1082
|
+
<option value="60000">1 minute</option>
|
|
1083
|
+
<option value="300000">5 minutes</option>
|
|
1084
|
+
<option value="900000">15 minutes</option>
|
|
1085
|
+
<option value="3600000">1 hour</option>
|
|
1086
|
+
<option value="21600000">6 hours</option>
|
|
1087
|
+
<option value="86400000">24 hours</option>
|
|
1088
|
+
<option value="604800000">1 week</option>
|
|
1089
|
+
</select>
|
|
1090
|
+
</div>
|
|
1091
|
+
` : nothing}
|
|
1092
|
+
|
|
1093
|
+
${this.connectionMode === 'docker' ? html `
|
|
1094
|
+
<div class="form-group">
|
|
1095
|
+
<label style="display: flex; align-items: center; gap: 6px;">
|
|
1096
|
+
<input
|
|
1097
|
+
type="checkbox"
|
|
1098
|
+
.checked=${this.useTmux}
|
|
1099
|
+
@change=${(e) => this.useTmux = e.target.checked}
|
|
1100
|
+
?disabled=${this.sessionActive}
|
|
1101
|
+
/>
|
|
1102
|
+
Use tmux (persist forever)
|
|
1103
|
+
</label>
|
|
1104
|
+
</div>
|
|
1105
|
+
` : nothing}
|
|
1106
|
+
|
|
1107
|
+
<div class="form-group">
|
|
1108
|
+
${!this.connected
|
|
1109
|
+
? html `<button class="btn-primary" @click=${this.handlePanelConnect} ?disabled=${this.loading}>
|
|
1110
|
+
${this.loading ? 'Connecting...' : 'Connect'}
|
|
1111
|
+
</button>`
|
|
1112
|
+
: !this.sessionActive
|
|
1113
|
+
? html `<button class="btn-primary" @click=${this.handlePanelConnect} ?disabled=${this.loading || (this.connectionMode === 'join' && !this.selectedSessionId) || ((this.connectionMode === 'docker' || this.connectionMode === 'docker-attach') && !this.selectedContainer)}>
|
|
1114
|
+
${this.loading ? 'Starting...' : this.connectionMode === 'join' ? 'Join Session' : this.connectionMode === 'docker-attach' ? 'Attach' : 'Start Session'}
|
|
1115
|
+
</button>`
|
|
1116
|
+
: html `<button class="btn-danger" @click=${this.kill}>
|
|
1117
|
+
${this.clientCount > 1 ? 'Leave Session' : 'Stop Session'}
|
|
1118
|
+
</button>`}
|
|
1119
|
+
</div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
`;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Render settings dropdown
|
|
1126
|
+
*/
|
|
1127
|
+
renderSettingsDropdown() {
|
|
1128
|
+
if (!this.showSettings)
|
|
1129
|
+
return nothing;
|
|
1130
|
+
return html `
|
|
1131
|
+
<div class="settings-dropdown">
|
|
1132
|
+
<button @click=${this.toggleSettingsMenu} title="Settings">
|
|
1133
|
+
⚙️
|
|
1134
|
+
</button>
|
|
1135
|
+
${this.settingsMenuOpen ? html `
|
|
1136
|
+
<div class="settings-menu">
|
|
1137
|
+
<div class="settings-menu-item">
|
|
1138
|
+
<span>Theme</span>
|
|
1139
|
+
<select
|
|
1140
|
+
.value=${this.theme}
|
|
1141
|
+
@change=${this.handleThemeChange}
|
|
1142
|
+
>
|
|
1143
|
+
<option value="dark">Dark</option>
|
|
1144
|
+
<option value="light">Light</option>
|
|
1145
|
+
<option value="auto">Auto</option>
|
|
1146
|
+
</select>
|
|
1147
|
+
</div>
|
|
1148
|
+
<div class="settings-divider"></div>
|
|
1149
|
+
<div class="settings-menu-item">
|
|
1150
|
+
<span>Font Size</span>
|
|
1151
|
+
<select
|
|
1152
|
+
.value=${String(this.fontSize)}
|
|
1153
|
+
@change=${(e) => {
|
|
1154
|
+
this.fontSize = parseInt(e.target.value);
|
|
1155
|
+
this.applyTerminalFontSize();
|
|
1156
|
+
}}
|
|
1157
|
+
>
|
|
1158
|
+
<option value="12">12px</option>
|
|
1159
|
+
<option value="14">14px</option>
|
|
1160
|
+
<option value="16">16px</option>
|
|
1161
|
+
<option value="18">18px</option>
|
|
1162
|
+
</select>
|
|
1163
|
+
</div>
|
|
1164
|
+
<div class="settings-divider"></div>
|
|
1165
|
+
<div class="settings-menu-item" @click=${this.clear}>
|
|
1166
|
+
<span>Clear Terminal</span>
|
|
1167
|
+
</div>
|
|
1168
|
+
</div>
|
|
1169
|
+
` : nothing}
|
|
1170
|
+
</div>
|
|
1171
|
+
`;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Send a special key sequence to the terminal
|
|
1175
|
+
*/
|
|
1176
|
+
sendKey(key) {
|
|
1177
|
+
if (!this.client || !this.sessionActive)
|
|
1178
|
+
return;
|
|
1179
|
+
// Apply modifiers if pressed
|
|
1180
|
+
let sequence = key;
|
|
1181
|
+
if (this.ctrlPressed) {
|
|
1182
|
+
// Convert to control character (Ctrl+A = \x01, Ctrl+C = \x03, etc.)
|
|
1183
|
+
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
|
1184
|
+
sequence = String.fromCharCode(key.charCodeAt(0) - 96);
|
|
1185
|
+
}
|
|
1186
|
+
else if (key.length === 1 && key >= 'A' && key <= 'Z') {
|
|
1187
|
+
sequence = String.fromCharCode(key.charCodeAt(0) - 64);
|
|
1188
|
+
}
|
|
1189
|
+
this.ctrlPressed = false;
|
|
1190
|
+
}
|
|
1191
|
+
if (this.altPressed) {
|
|
1192
|
+
// Alt/Meta sends ESC prefix
|
|
1193
|
+
sequence = '\x1b' + sequence;
|
|
1194
|
+
this.altPressed = false;
|
|
1195
|
+
}
|
|
1196
|
+
this.client.write(sequence);
|
|
1197
|
+
this.terminal?.focus();
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Send ANSI escape sequence
|
|
1201
|
+
*/
|
|
1202
|
+
sendEscape(code) {
|
|
1203
|
+
if (!this.client || !this.sessionActive)
|
|
1204
|
+
return;
|
|
1205
|
+
this.client.write('\x1b' + code);
|
|
1206
|
+
this.terminal?.focus();
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Send control character directly
|
|
1210
|
+
*/
|
|
1211
|
+
sendCtrl(char) {
|
|
1212
|
+
if (!this.client || !this.sessionActive)
|
|
1213
|
+
return;
|
|
1214
|
+
const code = char.toUpperCase().charCodeAt(0) - 64;
|
|
1215
|
+
this.client.write(String.fromCharCode(code));
|
|
1216
|
+
this.terminal?.focus();
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Toggle Ctrl modifier
|
|
1220
|
+
*/
|
|
1221
|
+
toggleCtrl() {
|
|
1222
|
+
this.ctrlPressed = !this.ctrlPressed;
|
|
1223
|
+
this.altPressed = false; // Reset alt when ctrl toggled
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Toggle Alt modifier
|
|
1227
|
+
*/
|
|
1228
|
+
toggleAlt() {
|
|
1229
|
+
this.altPressed = !this.altPressed;
|
|
1230
|
+
this.ctrlPressed = false; // Reset ctrl when alt toggled
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Toggle extra key rows visibility
|
|
1234
|
+
*/
|
|
1235
|
+
toggleExtraRows() {
|
|
1236
|
+
this.showExtraKeyRows = !this.showExtraKeyRows;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Toggle touch keyboard visibility
|
|
1240
|
+
*/
|
|
1241
|
+
toggleTouchKeyboard() {
|
|
1242
|
+
this.showTouchKeyboard = !this.showTouchKeyboard;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Render touch keyboard for mobile
|
|
1246
|
+
*/
|
|
1247
|
+
renderTouchKeyboard() {
|
|
1248
|
+
if (!this.isMobile)
|
|
1249
|
+
return nothing;
|
|
1250
|
+
return html `
|
|
1251
|
+
<!-- Toggle bar to show/hide keyboard -->
|
|
1252
|
+
<div class="touch-keyboard-toggle">
|
|
1253
|
+
<button @click=${this.toggleTouchKeyboard} title="${this.showTouchKeyboard ? 'Hide' : 'Show'} keyboard">
|
|
1254
|
+
${this.showTouchKeyboard ? '▼' : '▲'}
|
|
1255
|
+
</button>
|
|
1256
|
+
</div>
|
|
1257
|
+
|
|
1258
|
+
${this.showTouchKeyboard ? html `
|
|
1259
|
+
<div class="touch-keyboard">
|
|
1260
|
+
<!-- Row 1: ESC, navigation, special -->
|
|
1261
|
+
<div class="touch-keyboard-row">
|
|
1262
|
+
<button class="touch-key" @click=${() => this.sendEscape('')}>ESC</button>
|
|
1263
|
+
<button class="touch-key" @click=${() => this.sendKey('/')}>/</button>
|
|
1264
|
+
<button class="touch-key" @click=${() => this.sendKey('-')}>-</button>
|
|
1265
|
+
<button class="touch-key" @click=${() => this.sendEscape('[H')}>HOME</button>
|
|
1266
|
+
<button class="touch-key" @click=${() => this.sendEscape('[A')}>↑</button>
|
|
1267
|
+
<button class="touch-key" @click=${() => this.sendEscape('[F')}>END</button>
|
|
1268
|
+
<button class="touch-key" @click=${() => this.sendEscape('[5~')}>PGUP</button>
|
|
1269
|
+
</div>
|
|
1270
|
+
|
|
1271
|
+
<!-- Row 2: TAB, modifiers, arrows -->
|
|
1272
|
+
<div class="touch-keyboard-row">
|
|
1273
|
+
<button class="touch-key" @click=${() => this.sendKey('\t')}>TAB</button>
|
|
1274
|
+
<button class="touch-key toggle-btn ${this.ctrlPressed ? 'active' : ''}" @click=${this.toggleCtrl}>CTRL</button>
|
|
1275
|
+
<button class="touch-key toggle-btn ${this.altPressed ? 'active' : ''}" @click=${this.toggleAlt}>ALT</button>
|
|
1276
|
+
<button class="touch-key" @click=${() => this.sendEscape('[D')}>←</button>
|
|
1277
|
+
<button class="touch-key" @click=${() => this.sendEscape('[B')}>↓</button>
|
|
1278
|
+
<button class="touch-key" @click=${() => this.sendEscape('[C')}>→</button>
|
|
1279
|
+
<button class="touch-key" @click=${() => this.sendEscape('[6~')}>PGDN</button>
|
|
1280
|
+
</div>
|
|
1281
|
+
|
|
1282
|
+
<!-- Expandable extra rows -->
|
|
1283
|
+
<div class="touch-keyboard-extra ${this.showExtraKeyRows ? 'expanded' : ''}">
|
|
1284
|
+
<!-- Row 3: Common control sequences -->
|
|
1285
|
+
<div class="touch-keyboard-row">
|
|
1286
|
+
<button class="touch-key danger" @click=${() => this.sendCtrl('C')}>^C</button>
|
|
1287
|
+
<button class="touch-key" @click=${() => this.sendCtrl('D')}>^D</button>
|
|
1288
|
+
<button class="touch-key" @click=${() => this.sendCtrl('Z')}>^Z</button>
|
|
1289
|
+
<button class="touch-key" @click=${() => this.sendCtrl('L')}>^L</button>
|
|
1290
|
+
<button class="touch-key" @click=${() => this.sendCtrl('A')}>^A</button>
|
|
1291
|
+
<button class="touch-key" @click=${() => this.sendCtrl('E')}>^E</button>
|
|
1292
|
+
<button class="touch-key" @click=${() => this.sendCtrl('R')}>^R</button>
|
|
1293
|
+
</div>
|
|
1294
|
+
</div>
|
|
1295
|
+
|
|
1296
|
+
<!-- Toggle for extra rows -->
|
|
1297
|
+
<div class="touch-keyboard-row">
|
|
1298
|
+
<button
|
|
1299
|
+
class="touch-key wide"
|
|
1300
|
+
@click=${this.toggleExtraRows}
|
|
1301
|
+
title="${this.showExtraKeyRows ? 'Hide' : 'Show'} extra keys"
|
|
1302
|
+
>
|
|
1303
|
+
${this.showExtraKeyRows ? '▲ Less' : '▼ More'}
|
|
1304
|
+
</button>
|
|
1305
|
+
</div>
|
|
1306
|
+
</div>
|
|
1307
|
+
` : nothing}
|
|
1308
|
+
`;
|
|
1309
|
+
}
|
|
1310
|
+
// ==================== End Touch Keyboard Methods ====================
|
|
1311
|
+
/**
|
|
1312
|
+
* Render status bar
|
|
1313
|
+
*/
|
|
1314
|
+
renderStatusBar() {
|
|
1315
|
+
if (!this.showStatusBar)
|
|
1316
|
+
return nothing;
|
|
1317
|
+
return html `
|
|
1318
|
+
<div class="status-bar">
|
|
1319
|
+
<div class="status-bar-left">
|
|
1320
|
+
<span class="status-dot ${this.connected ? 'connected' : ''}"></span>
|
|
1321
|
+
<span>${this.connected
|
|
1322
|
+
? (this.sessionActive ? 'Session active' : 'Connected')
|
|
1323
|
+
: 'Disconnected'}</span>
|
|
1324
|
+
${this.sessionInfo ? html `
|
|
1325
|
+
<span style="color: var(--ls-text-muted)">|</span>
|
|
1326
|
+
<span>${this.sessionInfo.container || this.sessionInfo.shell}</span>
|
|
1327
|
+
<span style="color: var(--ls-text-muted)">${this.sessionInfo.cols}x${this.sessionInfo.rows}</span>
|
|
1328
|
+
` : nothing}
|
|
1329
|
+
</div>
|
|
1330
|
+
<div class="status-bar-right">
|
|
1331
|
+
${this.statusMessage ? html `
|
|
1332
|
+
<span class="${this.statusType === 'error' ? 'status-bar-error' : this.statusType === 'success' ? 'status-bar-success' : ''}">
|
|
1333
|
+
${this.statusType === 'error' ? '⚠️' : this.statusType === 'success' ? '✓' : ''}
|
|
1334
|
+
${this.statusMessage}
|
|
1335
|
+
</span>
|
|
1336
|
+
<button
|
|
1337
|
+
style="background: none; border: none; cursor: pointer; padding: 0; font-size: 10px;"
|
|
1338
|
+
@click=${this.clearStatus}
|
|
1339
|
+
title="Dismiss"
|
|
1340
|
+
>✕</button>
|
|
1341
|
+
` : nothing}
|
|
1342
|
+
</div>
|
|
1343
|
+
</div>
|
|
1344
|
+
`;
|
|
1345
|
+
}
|
|
361
1346
|
render() {
|
|
362
1347
|
return html `
|
|
363
1348
|
${this.noHeader
|
|
@@ -368,39 +1353,74 @@ let LitShellTerminal = class LitShellTerminal extends LitElement {
|
|
|
368
1353
|
<span>Terminal</span>
|
|
369
1354
|
${this.sessionInfo
|
|
370
1355
|
? html `<span style="font-weight: normal; font-size: 12px; color: var(--ls-text-muted)">
|
|
371
|
-
${this.sessionInfo.
|
|
1356
|
+
${this.sessionInfo.container
|
|
1357
|
+
? `${this.sessionInfo.container} (${this.sessionInfo.shell})`
|
|
1358
|
+
: this.sessionInfo.shell}
|
|
372
1359
|
</span>`
|
|
373
1360
|
: nothing}
|
|
374
1361
|
</div>
|
|
375
1362
|
<div class="header-actions">
|
|
376
|
-
${!this.
|
|
1363
|
+
${!this.showConnectionPanel ? html `
|
|
1364
|
+
${!this.connected
|
|
377
1365
|
? html `<button @click=${this.connect} ?disabled=${this.loading}>
|
|
378
|
-
|
|
379
|
-
|
|
1366
|
+
${this.loading ? 'Connecting...' : 'Connect'}
|
|
1367
|
+
</button>`
|
|
380
1368
|
: !this.sessionActive
|
|
381
1369
|
? html `<button @click=${() => this.spawn()} ?disabled=${this.loading}>
|
|
382
|
-
|
|
383
|
-
|
|
1370
|
+
${this.loading ? 'Spawning...' : 'Start'}
|
|
1371
|
+
</button>`
|
|
384
1372
|
: html `<button @click=${this.kill}>Stop</button>`}
|
|
1373
|
+
` : nothing}
|
|
385
1374
|
<button @click=${this.clear} ?disabled=${!this.sessionActive}>Clear</button>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
<
|
|
389
|
-
|
|
1375
|
+
${this.renderSettingsDropdown()}
|
|
1376
|
+
${!this.showStatusBar ? html `
|
|
1377
|
+
<div class="status">
|
|
1378
|
+
<span class="status-dot ${this.connected ? 'connected' : ''}"></span>
|
|
1379
|
+
<span>${this.connected ? 'Connected' : 'Disconnected'}</span>
|
|
1380
|
+
</div>
|
|
1381
|
+
` : nothing}
|
|
390
1382
|
</div>
|
|
391
1383
|
</div>
|
|
392
1384
|
`}
|
|
393
1385
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1386
|
+
${this.renderConnectionPanel()}
|
|
1387
|
+
${this.renderTabBar()}
|
|
1388
|
+
|
|
1389
|
+
${this.showTabs && this.tabs.length > 0
|
|
1390
|
+
? html `
|
|
1391
|
+
<div class="terminals-wrapper">
|
|
1392
|
+
${this.tabs.map((tab) => html `
|
|
1393
|
+
<div
|
|
1394
|
+
class="tab-terminal-container ${tab.id === this.activeTabId ? 'active' : ''}"
|
|
1395
|
+
data-tab-id=${tab.id}
|
|
1396
|
+
>
|
|
1397
|
+
${this.loading && tab.id === this.activeTabId && !tab.terminal
|
|
1398
|
+
? html `<div class="loading"><span class="loading-spinner">⏳</span> Loading...</div>`
|
|
1399
|
+
: this.error && tab.id === this.activeTabId && !tab.terminal
|
|
1400
|
+
? html `<div class="error">❌ ${this.error}</div>`
|
|
1401
|
+
: nothing}
|
|
1402
|
+
</div>
|
|
1403
|
+
`)}
|
|
1404
|
+
</div>
|
|
1405
|
+
`
|
|
1406
|
+
: html `
|
|
1407
|
+
<div class="terminal-container">
|
|
1408
|
+
${this.loading && !this.terminal
|
|
1409
|
+
? html `<div class="loading"><span class="loading-spinner">⏳</span> Loading...</div>`
|
|
1410
|
+
: this.error && !this.terminal
|
|
1411
|
+
? html `<div class="error">❌ ${this.error}</div>`
|
|
1412
|
+
: nothing}
|
|
1413
|
+
</div>
|
|
1414
|
+
`}
|
|
1415
|
+
|
|
1416
|
+
${this.renderStatusBar()}
|
|
1417
|
+
${this.renderTouchKeyboard()}
|
|
1418
|
+
${this.renderReconnectDialog()}
|
|
401
1419
|
`;
|
|
402
1420
|
}
|
|
403
1421
|
};
|
|
1422
|
+
/** lit-shell.js version */
|
|
1423
|
+
LitShellTerminal.VERSION = VERSION;
|
|
404
1424
|
LitShellTerminal.styles = [
|
|
405
1425
|
sharedStyles,
|
|
406
1426
|
themeStyles,
|
|
@@ -504,15 +1524,450 @@ LitShellTerminal.styles = [
|
|
|
504
1524
|
:host([no-header]) .header {
|
|
505
1525
|
display: none;
|
|
506
1526
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1527
|
+
|
|
1528
|
+
/* Connection panel */
|
|
1529
|
+
.connection-panel {
|
|
1530
|
+
padding: 12px;
|
|
1531
|
+
background: var(--ls-bg-header);
|
|
1532
|
+
border-bottom: 1px solid var(--ls-border);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.connection-panel-title {
|
|
1536
|
+
font-weight: 600;
|
|
1537
|
+
margin-bottom: 12px;
|
|
1538
|
+
display: flex;
|
|
1539
|
+
align-items: center;
|
|
1540
|
+
gap: 8px;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
.connection-form {
|
|
1544
|
+
display: grid;
|
|
1545
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
1546
|
+
gap: 10px;
|
|
1547
|
+
align-items: end;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
.form-group {
|
|
1551
|
+
display: flex;
|
|
1552
|
+
flex-direction: column;
|
|
1553
|
+
gap: 4px;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.form-group label {
|
|
1557
|
+
font-size: 11px;
|
|
1558
|
+
text-transform: uppercase;
|
|
1559
|
+
color: var(--ls-text-muted);
|
|
1560
|
+
letter-spacing: 0.5px;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
.form-group select,
|
|
1564
|
+
.form-group input {
|
|
1565
|
+
padding: 6px 10px;
|
|
1566
|
+
border: 1px solid var(--ls-border);
|
|
1567
|
+
border-radius: 4px;
|
|
1568
|
+
background: var(--ls-bg);
|
|
1569
|
+
color: var(--ls-text);
|
|
1570
|
+
font-size: 13px;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
.form-group select:focus,
|
|
1574
|
+
.form-group input:focus {
|
|
1575
|
+
outline: none;
|
|
1576
|
+
border-color: var(--ls-status-connected);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/* Settings dropdown */
|
|
1580
|
+
.settings-dropdown {
|
|
1581
|
+
position: relative;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.settings-menu {
|
|
1585
|
+
position: absolute;
|
|
1586
|
+
top: 100%;
|
|
1587
|
+
right: 0;
|
|
1588
|
+
margin-top: 4px;
|
|
1589
|
+
min-width: 180px;
|
|
1590
|
+
background: var(--ls-bg-header);
|
|
1591
|
+
border: 1px solid var(--ls-border);
|
|
1592
|
+
border-radius: 4px;
|
|
1593
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
1594
|
+
z-index: 100;
|
|
1595
|
+
padding: 8px 0;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
.settings-menu-item {
|
|
1599
|
+
display: flex;
|
|
1600
|
+
align-items: center;
|
|
1601
|
+
justify-content: space-between;
|
|
1602
|
+
padding: 8px 12px;
|
|
1603
|
+
font-size: 13px;
|
|
1604
|
+
cursor: pointer;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
.settings-menu-item:hover {
|
|
1608
|
+
background: var(--ls-btn-hover);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
.settings-menu-item select {
|
|
1612
|
+
padding: 4px 8px;
|
|
1613
|
+
border: 1px solid var(--ls-border);
|
|
1614
|
+
border-radius: 3px;
|
|
1615
|
+
background: var(--ls-bg);
|
|
1616
|
+
color: var(--ls-text);
|
|
1617
|
+
font-size: 12px;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
.settings-divider {
|
|
1621
|
+
height: 1px;
|
|
1622
|
+
background: var(--ls-border);
|
|
1623
|
+
margin: 4px 0;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/* Status bar */
|
|
1627
|
+
.status-bar {
|
|
1628
|
+
display: flex;
|
|
1629
|
+
align-items: center;
|
|
1630
|
+
justify-content: space-between;
|
|
1631
|
+
padding: 4px 12px;
|
|
1632
|
+
background: var(--ls-bg-header);
|
|
1633
|
+
border-top: 1px solid var(--ls-border);
|
|
1634
|
+
font-size: 12px;
|
|
1635
|
+
color: var(--ls-text-muted);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
.status-bar-left {
|
|
1639
|
+
display: flex;
|
|
1640
|
+
align-items: center;
|
|
1641
|
+
gap: 12px;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
.status-bar-right {
|
|
1645
|
+
display: flex;
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
gap: 8px;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
.status-bar-error {
|
|
1651
|
+
color: #ef4444;
|
|
1652
|
+
display: flex;
|
|
1653
|
+
align-items: center;
|
|
1654
|
+
gap: 4px;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
.status-bar-success {
|
|
1658
|
+
color: var(--ls-status-connected);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/* Tab bar styles */
|
|
1662
|
+
.tab-bar {
|
|
1663
|
+
display: flex;
|
|
1664
|
+
align-items: center;
|
|
1665
|
+
background: var(--ls-bg-header);
|
|
1666
|
+
border-bottom: 1px solid var(--ls-border);
|
|
1667
|
+
padding: 0 4px;
|
|
1668
|
+
gap: 2px;
|
|
1669
|
+
min-height: 32px;
|
|
1670
|
+
overflow-x: auto;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
.tab-bar::-webkit-scrollbar {
|
|
1674
|
+
height: 4px;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
.tab-bar::-webkit-scrollbar-thumb {
|
|
1678
|
+
background: var(--ls-border);
|
|
1679
|
+
border-radius: 2px;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
.tab {
|
|
1683
|
+
display: flex;
|
|
1684
|
+
align-items: center;
|
|
1685
|
+
gap: 6px;
|
|
1686
|
+
padding: 6px 12px;
|
|
1687
|
+
background: transparent;
|
|
1688
|
+
border: none;
|
|
1689
|
+
border-bottom: 2px solid transparent;
|
|
1690
|
+
color: var(--ls-text-muted);
|
|
1691
|
+
font-size: 12px;
|
|
1692
|
+
cursor: pointer;
|
|
1693
|
+
white-space: nowrap;
|
|
1694
|
+
transition: all 0.15s ease;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
.tab:hover {
|
|
1698
|
+
background: var(--ls-btn-hover);
|
|
1699
|
+
color: var(--ls-text);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.tab.active {
|
|
1703
|
+
color: var(--ls-text);
|
|
1704
|
+
border-bottom-color: var(--ls-status-connected);
|
|
1705
|
+
background: var(--ls-bg);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
.tab-status {
|
|
1709
|
+
width: 6px;
|
|
1710
|
+
height: 6px;
|
|
1711
|
+
border-radius: 50%;
|
|
1712
|
+
background: var(--ls-status-disconnected);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
.tab-status.connected {
|
|
1716
|
+
background: var(--ls-status-connected);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.tab-close {
|
|
1720
|
+
display: flex;
|
|
1721
|
+
align-items: center;
|
|
1722
|
+
justify-content: center;
|
|
1723
|
+
width: 16px;
|
|
1724
|
+
height: 16px;
|
|
1725
|
+
border-radius: 3px;
|
|
1726
|
+
background: none;
|
|
1727
|
+
border: none;
|
|
1728
|
+
color: var(--ls-text-muted);
|
|
1729
|
+
font-size: 14px;
|
|
1730
|
+
cursor: pointer;
|
|
1731
|
+
opacity: 0;
|
|
1732
|
+
transition: opacity 0.15s ease;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
.tab:hover .tab-close {
|
|
1736
|
+
opacity: 1;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
.tab-close:hover {
|
|
1740
|
+
background: var(--ls-btn-hover);
|
|
1741
|
+
color: var(--ls-text);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
.tab-add {
|
|
1745
|
+
display: flex;
|
|
1746
|
+
align-items: center;
|
|
1747
|
+
justify-content: center;
|
|
1748
|
+
width: 28px;
|
|
1749
|
+
height: 28px;
|
|
1750
|
+
border-radius: 4px;
|
|
1751
|
+
background: none;
|
|
1752
|
+
border: none;
|
|
1753
|
+
color: var(--ls-text-muted);
|
|
1754
|
+
font-size: 18px;
|
|
1755
|
+
cursor: pointer;
|
|
1756
|
+
margin-left: 4px;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
.tab-add:hover {
|
|
1760
|
+
background: var(--ls-btn-hover);
|
|
1761
|
+
color: var(--ls-text);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/* Multi-terminal container */
|
|
1765
|
+
.terminals-wrapper {
|
|
1766
|
+
flex: 1;
|
|
1767
|
+
position: relative;
|
|
1768
|
+
overflow: hidden;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
.tab-terminal-container {
|
|
1772
|
+
position: absolute;
|
|
1773
|
+
top: 0;
|
|
1774
|
+
left: 0;
|
|
1775
|
+
right: 0;
|
|
1776
|
+
bottom: 0;
|
|
1777
|
+
padding: 4px;
|
|
1778
|
+
background: var(--ls-terminal-bg);
|
|
1779
|
+
display: none;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
.tab-terminal-container.active {
|
|
1783
|
+
display: block;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
.tab-terminal-container .xterm {
|
|
1787
|
+
height: 100%;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/* Reconnect dialog */
|
|
1791
|
+
.reconnect-dialog-overlay {
|
|
1792
|
+
position: absolute;
|
|
1793
|
+
top: 0;
|
|
1794
|
+
left: 0;
|
|
1795
|
+
right: 0;
|
|
1796
|
+
bottom: 0;
|
|
1797
|
+
background: rgba(0, 0, 0, 0.7);
|
|
1798
|
+
display: flex;
|
|
1799
|
+
align-items: center;
|
|
1800
|
+
justify-content: center;
|
|
1801
|
+
z-index: 1000;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
.reconnect-dialog {
|
|
1805
|
+
background: var(--ls-bg-header);
|
|
1806
|
+
border: 1px solid var(--ls-border);
|
|
1807
|
+
border-radius: 8px;
|
|
1808
|
+
padding: 24px;
|
|
1809
|
+
max-width: 320px;
|
|
1810
|
+
text-align: center;
|
|
1811
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
.reconnect-dialog h3 {
|
|
1815
|
+
margin: 0 0 12px 0;
|
|
1816
|
+
font-size: 16px;
|
|
1817
|
+
color: var(--ls-text);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
.reconnect-dialog p {
|
|
1821
|
+
margin: 0 0 20px 0;
|
|
1822
|
+
font-size: 14px;
|
|
1823
|
+
color: var(--ls-text-muted);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
.reconnect-dialog-buttons {
|
|
1827
|
+
display: flex;
|
|
1828
|
+
gap: 12px;
|
|
1829
|
+
justify-content: center;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
.reconnect-dialog-buttons button {
|
|
1833
|
+
padding: 8px 20px;
|
|
1834
|
+
border-radius: 4px;
|
|
1835
|
+
font-size: 14px;
|
|
1836
|
+
cursor: pointer;
|
|
1837
|
+
transition: background 0.15s ease;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
.reconnect-dialog-buttons .btn-primary {
|
|
1841
|
+
background: var(--ls-status-connected);
|
|
1842
|
+
color: white;
|
|
1843
|
+
border: none;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
.reconnect-dialog-buttons .btn-primary:hover {
|
|
1847
|
+
background: #1da34d;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
.reconnect-dialog-buttons .btn-secondary {
|
|
1851
|
+
background: transparent;
|
|
1852
|
+
color: var(--ls-text-muted);
|
|
1853
|
+
border: 1px solid var(--ls-border);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
.reconnect-dialog-buttons .btn-secondary:hover {
|
|
1857
|
+
background: var(--ls-btn-hover);
|
|
1858
|
+
color: var(--ls-text);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/* Touch keyboard for mobile */
|
|
1862
|
+
.touch-keyboard {
|
|
1863
|
+
display: flex;
|
|
1864
|
+
flex-direction: column;
|
|
1865
|
+
background: var(--ls-bg-header);
|
|
1866
|
+
border-top: 1px solid var(--ls-border);
|
|
1867
|
+
padding: 4px;
|
|
1868
|
+
gap: 4px;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.touch-keyboard-row {
|
|
1872
|
+
display: flex;
|
|
1873
|
+
gap: 4px;
|
|
1874
|
+
justify-content: center;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.touch-key {
|
|
1878
|
+
display: flex;
|
|
1879
|
+
align-items: center;
|
|
1880
|
+
justify-content: center;
|
|
1881
|
+
min-width: 40px;
|
|
1882
|
+
height: 36px;
|
|
1883
|
+
padding: 0 8px;
|
|
1884
|
+
background: var(--ls-bg);
|
|
1885
|
+
border: 1px solid var(--ls-border);
|
|
1886
|
+
border-radius: 4px;
|
|
1887
|
+
color: var(--ls-text);
|
|
1888
|
+
font-size: 12px;
|
|
1889
|
+
font-family: inherit;
|
|
1890
|
+
cursor: pointer;
|
|
1891
|
+
touch-action: manipulation;
|
|
1892
|
+
user-select: none;
|
|
1893
|
+
-webkit-user-select: none;
|
|
1894
|
+
transition: background 0.1s ease;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
.touch-key:active {
|
|
1898
|
+
background: var(--ls-btn-hover);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
.touch-key.wide {
|
|
1902
|
+
min-width: 50px;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
.touch-key.danger {
|
|
1906
|
+
color: #ef4444;
|
|
1907
|
+
border-color: #ef4444;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
.touch-key.toggle-btn {
|
|
1911
|
+
background: var(--ls-bg-header);
|
|
1912
|
+
min-width: 30px;
|
|
1913
|
+
font-size: 14px;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.touch-key.toggle-btn.active {
|
|
1917
|
+
background: var(--ls-status-connected);
|
|
1918
|
+
color: white;
|
|
1919
|
+
border-color: var(--ls-status-connected);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.touch-keyboard-toggle {
|
|
1923
|
+
display: flex;
|
|
1924
|
+
align-items: center;
|
|
1925
|
+
justify-content: center;
|
|
1926
|
+
padding: 2px;
|
|
1927
|
+
background: var(--ls-bg-header);
|
|
1928
|
+
border-top: 1px solid var(--ls-border);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
.touch-keyboard-toggle button {
|
|
1932
|
+
background: none;
|
|
1933
|
+
border: none;
|
|
1934
|
+
color: var(--ls-text-muted);
|
|
1935
|
+
font-size: 18px;
|
|
1936
|
+
padding: 4px 12px;
|
|
1937
|
+
cursor: pointer;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
.touch-keyboard-toggle button:active {
|
|
1941
|
+
color: var(--ls-text);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/* Extra key rows (collapsible) */
|
|
1945
|
+
.touch-keyboard-extra {
|
|
1946
|
+
overflow: hidden;
|
|
1947
|
+
max-height: 0;
|
|
1948
|
+
opacity: 0;
|
|
1949
|
+
transition: max-height 0.2s ease, opacity 0.2s ease;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
.touch-keyboard-extra.expanded {
|
|
1953
|
+
max-height: 100px;
|
|
1954
|
+
opacity: 1;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/* Hide touch keyboard on desktop */
|
|
1958
|
+
:host(:not([mobile])) .touch-keyboard,
|
|
1959
|
+
:host(:not([mobile])) .touch-keyboard-toggle {
|
|
1960
|
+
display: none;
|
|
1961
|
+
}
|
|
1962
|
+
`,
|
|
1963
|
+
];
|
|
1964
|
+
__decorate([
|
|
1965
|
+
property({ type: String })
|
|
1966
|
+
], LitShellTerminal.prototype, "url", void 0);
|
|
1967
|
+
__decorate([
|
|
1968
|
+
property({ type: String })
|
|
1969
|
+
], LitShellTerminal.prototype, "shell", void 0);
|
|
1970
|
+
__decorate([
|
|
516
1971
|
property({ type: String })
|
|
517
1972
|
], LitShellTerminal.prototype, "cwd", void 0);
|
|
518
1973
|
__decorate([
|
|
@@ -533,6 +1988,30 @@ __decorate([
|
|
|
533
1988
|
__decorate([
|
|
534
1989
|
property({ type: Boolean, attribute: 'auto-spawn' })
|
|
535
1990
|
], LitShellTerminal.prototype, "autoSpawn", void 0);
|
|
1991
|
+
__decorate([
|
|
1992
|
+
property({ type: String })
|
|
1993
|
+
], LitShellTerminal.prototype, "container", void 0);
|
|
1994
|
+
__decorate([
|
|
1995
|
+
property({ type: String, attribute: 'container-shell' })
|
|
1996
|
+
], LitShellTerminal.prototype, "containerShell", void 0);
|
|
1997
|
+
__decorate([
|
|
1998
|
+
property({ type: String, attribute: 'container-user' })
|
|
1999
|
+
], LitShellTerminal.prototype, "containerUser", void 0);
|
|
2000
|
+
__decorate([
|
|
2001
|
+
property({ type: String, attribute: 'container-cwd' })
|
|
2002
|
+
], LitShellTerminal.prototype, "containerCwd", void 0);
|
|
2003
|
+
__decorate([
|
|
2004
|
+
property({ type: Boolean, attribute: 'show-connection-panel' })
|
|
2005
|
+
], LitShellTerminal.prototype, "showConnectionPanel", void 0);
|
|
2006
|
+
__decorate([
|
|
2007
|
+
property({ type: Boolean, attribute: 'show-settings' })
|
|
2008
|
+
], LitShellTerminal.prototype, "showSettings", void 0);
|
|
2009
|
+
__decorate([
|
|
2010
|
+
property({ type: Boolean, attribute: 'show-status-bar' })
|
|
2011
|
+
], LitShellTerminal.prototype, "showStatusBar", void 0);
|
|
2012
|
+
__decorate([
|
|
2013
|
+
property({ type: Boolean, attribute: 'show-tabs' })
|
|
2014
|
+
], LitShellTerminal.prototype, "showTabs", void 0);
|
|
536
2015
|
__decorate([
|
|
537
2016
|
property({ type: Number, attribute: 'font-size' })
|
|
538
2017
|
], LitShellTerminal.prototype, "fontSize", void 0);
|
|
@@ -563,6 +2042,72 @@ __decorate([
|
|
|
563
2042
|
__decorate([
|
|
564
2043
|
state()
|
|
565
2044
|
], LitShellTerminal.prototype, "sessionInfo", void 0);
|
|
2045
|
+
__decorate([
|
|
2046
|
+
state()
|
|
2047
|
+
], LitShellTerminal.prototype, "containers", void 0);
|
|
2048
|
+
__decorate([
|
|
2049
|
+
state()
|
|
2050
|
+
], LitShellTerminal.prototype, "serverInfo", void 0);
|
|
2051
|
+
__decorate([
|
|
2052
|
+
state()
|
|
2053
|
+
], LitShellTerminal.prototype, "selectedContainer", void 0);
|
|
2054
|
+
__decorate([
|
|
2055
|
+
state()
|
|
2056
|
+
], LitShellTerminal.prototype, "selectedShell", void 0);
|
|
2057
|
+
__decorate([
|
|
2058
|
+
state()
|
|
2059
|
+
], LitShellTerminal.prototype, "connectionMode", void 0);
|
|
2060
|
+
__decorate([
|
|
2061
|
+
state()
|
|
2062
|
+
], LitShellTerminal.prototype, "orphanTimeout", void 0);
|
|
2063
|
+
__decorate([
|
|
2064
|
+
state()
|
|
2065
|
+
], LitShellTerminal.prototype, "useTmux", void 0);
|
|
2066
|
+
__decorate([
|
|
2067
|
+
state()
|
|
2068
|
+
], LitShellTerminal.prototype, "availableSessions", void 0);
|
|
2069
|
+
__decorate([
|
|
2070
|
+
state()
|
|
2071
|
+
], LitShellTerminal.prototype, "selectedSessionId", void 0);
|
|
2072
|
+
__decorate([
|
|
2073
|
+
state()
|
|
2074
|
+
], LitShellTerminal.prototype, "clientCount", void 0);
|
|
2075
|
+
__decorate([
|
|
2076
|
+
state()
|
|
2077
|
+
], LitShellTerminal.prototype, "settingsMenuOpen", void 0);
|
|
2078
|
+
__decorate([
|
|
2079
|
+
state()
|
|
2080
|
+
], LitShellTerminal.prototype, "showReconnectDialog", void 0);
|
|
2081
|
+
__decorate([
|
|
2082
|
+
state()
|
|
2083
|
+
], LitShellTerminal.prototype, "reconnectSessionId", void 0);
|
|
2084
|
+
__decorate([
|
|
2085
|
+
state()
|
|
2086
|
+
], LitShellTerminal.prototype, "isMobile", void 0);
|
|
2087
|
+
__decorate([
|
|
2088
|
+
state()
|
|
2089
|
+
], LitShellTerminal.prototype, "showTouchKeyboard", void 0);
|
|
2090
|
+
__decorate([
|
|
2091
|
+
state()
|
|
2092
|
+
], LitShellTerminal.prototype, "showExtraKeyRows", void 0);
|
|
2093
|
+
__decorate([
|
|
2094
|
+
state()
|
|
2095
|
+
], LitShellTerminal.prototype, "statusMessage", void 0);
|
|
2096
|
+
__decorate([
|
|
2097
|
+
state()
|
|
2098
|
+
], LitShellTerminal.prototype, "statusType", void 0);
|
|
2099
|
+
__decorate([
|
|
2100
|
+
state()
|
|
2101
|
+
], LitShellTerminal.prototype, "tabs", void 0);
|
|
2102
|
+
__decorate([
|
|
2103
|
+
state()
|
|
2104
|
+
], LitShellTerminal.prototype, "activeTabId", void 0);
|
|
2105
|
+
__decorate([
|
|
2106
|
+
state()
|
|
2107
|
+
], LitShellTerminal.prototype, "ctrlPressed", void 0);
|
|
2108
|
+
__decorate([
|
|
2109
|
+
state()
|
|
2110
|
+
], LitShellTerminal.prototype, "altPressed", void 0);
|
|
566
2111
|
LitShellTerminal = __decorate([
|
|
567
2112
|
customElement('lit-shell-terminal')
|
|
568
2113
|
], LitShellTerminal);
|