@bakapiano/ccsm 0.19.3 → 0.20.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/lib/config.js +9 -0
- package/lib/tunnel.js +149 -3
- package/package.json +1 -1
- package/public/css/dark.css +120 -0
- package/public/css/forms.css +30 -0
- package/public/css/layout.css +4 -5
- package/public/css/terminals.css +71 -38
- package/public/css/widgets.css +42 -0
- package/public/favicon.svg +8 -1
- package/public/index.html +56 -22
- package/public/js/components/TerminalView.js +109 -28
- package/public/js/icons.js +1 -1
- package/public/js/pages/ConfigurePage.js +23 -2
- package/public/js/pages/LaunchPage.js +51 -6
- package/public/js/pages/RemotePage.js +88 -20
- package/public/js/state.js +107 -38
- package/server.js +20 -0
package/public/favicon.svg
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<!-- The terminal-window body lifts on a dark browser/OS chrome so the
|
|
3
|
+
near-black mark doesn't vanish in the tab strip. Mirrors the in-app
|
|
4
|
+
BrandMark's [data-theme="dark"] .brand-rect treatment. -->
|
|
5
|
+
<style>
|
|
6
|
+
.body { fill: #1a1815; }
|
|
7
|
+
@media (prefers-color-scheme: dark) { .body { fill: #38342f; } }
|
|
8
|
+
</style>
|
|
2
9
|
<!-- terminal window body -->
|
|
3
|
-
<rect x="2" y="4" width="28" height="24" rx="3"
|
|
10
|
+
<rect class="body" x="2" y="4" width="28" height="24" rx="3"/>
|
|
4
11
|
<!-- title bar divider -->
|
|
5
12
|
<line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
|
|
6
13
|
<!-- traffic-light dots -->
|
package/public/index.html
CHANGED
|
@@ -27,37 +27,69 @@
|
|
|
27
27
|
redirect to ../ on backend upgrade would fall out of scope and
|
|
28
28
|
the OS would re-show an address bar. -->
|
|
29
29
|
<link rel="manifest" href="../manifest.webmanifest" />
|
|
30
|
-
<!-- Apply accent
|
|
31
|
-
of the default
|
|
32
|
-
applyAccentCssVars() in state.js
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
<!-- Apply theme (accent + light/dark) BEFORE stylesheets/paint to
|
|
31
|
+
avoid a flash of the default light tokens.css bg. Mirrors
|
|
32
|
+
applyTheme()/applyAccentCssVars() in state.js — keep the two in
|
|
33
|
+
sync. Resolves 'system' against the OS, sets data-theme so the
|
|
34
|
+
[data-theme="dark"] CSS overrides apply from the first frame, and
|
|
35
|
+
derives the accent-tinted palette for the chosen ground. -->
|
|
35
36
|
<script>
|
|
36
37
|
(function () {
|
|
37
38
|
try {
|
|
38
39
|
var hex = localStorage.getItem('ccsm.accent');
|
|
39
40
|
if (!/^#[0-9a-fA-F]{6}$/.test(hex || '')) hex = '#2f6fa3';
|
|
41
|
+
var mode = localStorage.getItem('ccsm.theme');
|
|
42
|
+
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
|
|
43
|
+
var dark = mode === 'dark' || (mode === 'system'
|
|
44
|
+
&& window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
45
|
+
|
|
40
46
|
var n = parseInt(hex.slice(1), 16);
|
|
41
|
-
var
|
|
47
|
+
var A = { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
42
48
|
var toHex = function (v) { v = Math.max(0, Math.min(255, Math.round(v))); var s = v.toString(16); return s.length < 2 ? '0' + s : s; };
|
|
43
|
-
var
|
|
44
|
-
var
|
|
45
|
-
var
|
|
49
|
+
var rgb = function (c) { return '#' + toHex(c.r) + toHex(c.g) + toHex(c.b); };
|
|
50
|
+
var lerp = function (c1, c2, t) { return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t }; };
|
|
51
|
+
var WHITE = { r: 255, g: 255, b: 255 }, BLACK = { r: 0, g: 0, b: 0 };
|
|
52
|
+
var DARK_BASE = { r: 0x18, g: 0x16, b: 0x12 }, LIGHT_INK = { r: 0xec, g: 0xe7, b: 0xda };
|
|
53
|
+
|
|
46
54
|
var root = document.documentElement.style;
|
|
47
|
-
root.setProperty(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
var set = function (o) { for (var k in o) root.setProperty(k, o[k]); };
|
|
56
|
+
var vars;
|
|
57
|
+
if (dark) {
|
|
58
|
+
var bg = lerp(DARK_BASE, A, 0.06);
|
|
59
|
+
var lift = function (t) { return rgb(lerp(bg, LIGHT_INK, t)); };
|
|
60
|
+
vars = {
|
|
61
|
+
'--accent': hex,
|
|
62
|
+
'--accent-deep': rgb(lerp(A, LIGHT_INK, 0.18)),
|
|
63
|
+
'--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.18)',
|
|
64
|
+
'--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.07)',
|
|
65
|
+
'--bg': rgb(bg), '--bg-elev': lift(0.05), '--sidebar-bg': rgb(bg),
|
|
66
|
+
'--sidebar-hover': lift(0.09), '--sidebar-active': lift(0.15),
|
|
67
|
+
'--border': lift(0.14), '--border-soft': lift(0.09), '--border-strong': lift(0.24),
|
|
68
|
+
'--ui-bg': lift(0.05), '--ui-border': lift(0.16), '--ui-border-soft': lift(0.10),
|
|
69
|
+
'--ink': rgb(LIGHT_INK),
|
|
70
|
+
'--ink-mid': rgb(lerp(LIGHT_INK, DARK_BASE, 0.28)),
|
|
71
|
+
'--ink-muted': rgb(lerp(LIGHT_INK, DARK_BASE, 0.45)),
|
|
72
|
+
'--ink-faint': rgb(lerp(LIGHT_INK, DARK_BASE, 0.60)),
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
var mix = function (t) { return rgb(lerp(WHITE, A, t)); };
|
|
76
|
+
vars = {
|
|
77
|
+
'--accent': hex,
|
|
78
|
+
'--accent-deep': rgb(lerp(A, BLACK, 0.2)),
|
|
79
|
+
'--accent-soft': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.10)',
|
|
80
|
+
'--accent-softer': 'rgba(' + A.r + ',' + A.g + ',' + A.b + ',0.04)',
|
|
81
|
+
'--bg': mix(0.04), '--bg-elev': '#ffffff', '--sidebar-bg': mix(0.04),
|
|
82
|
+
'--sidebar-hover': mix(0.10), '--sidebar-active': mix(0.15),
|
|
83
|
+
'--border': mix(0.15), '--border-soft': mix(0.12), '--border-strong': mix(0.25),
|
|
84
|
+
'--ui-bg': mix(0.10), '--ui-border': '#d8d4c6', '--ui-border-soft': '#e6e2d4',
|
|
85
|
+
'--ink': '#1a1815', '--ink-mid': '#534e44', '--ink-muted': '#8a8475', '--ink-faint': '#b5af9d',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
89
|
+
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
|
90
|
+
set(vars);
|
|
59
91
|
var meta = document.querySelector('meta[name="theme-color"]');
|
|
60
|
-
if (meta) meta.setAttribute('content', bg);
|
|
92
|
+
if (meta) meta.setAttribute('content', vars['--bg']);
|
|
61
93
|
} catch (_) {}
|
|
62
94
|
})();
|
|
63
95
|
</script>
|
|
@@ -80,6 +112,8 @@
|
|
|
80
112
|
<link rel="stylesheet" href="./css/terminals.css" />
|
|
81
113
|
<link rel="stylesheet" href="./css/wco.css" />
|
|
82
114
|
<link rel="stylesheet" href="./css/responsive.css" />
|
|
115
|
+
<!-- Loaded last so its [data-theme="dark"] rules win the cascade. -->
|
|
116
|
+
<link rel="stylesheet" href="./css/dark.css" />
|
|
83
117
|
|
|
84
118
|
<script type="importmap">
|
|
85
119
|
{
|
|
@@ -11,29 +11,50 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
|
11
11
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
12
12
|
import { WebglAddon } from '@xterm/addon-webgl';
|
|
13
13
|
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
14
|
+
import { isDarkTheme, themeMode } from '../state.js';
|
|
14
15
|
import { TerminalKeyBar } from './TerminalKeyBar.js';
|
|
15
16
|
|
|
16
|
-
// Dark xterm theme
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
cyan: '#6fb0b0', brightCyan: '#90c8c8',
|
|
34
|
-
white: '#e8e3d5', brightWhite: '#faf9f5',
|
|
17
|
+
// Dark xterm theme — VSCode's Dark+ terminal palette, verbatim (see
|
|
18
|
+
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
|
|
19
|
+
// #1e1e1e ground, #ccc ink, the standard saturated ANSI set.
|
|
20
|
+
const THEME_DARK = {
|
|
21
|
+
background: '#1e1e1e',
|
|
22
|
+
foreground: '#cccccc',
|
|
23
|
+
cursor: '#aeafad',
|
|
24
|
+
cursorAccent: '#1e1e1e',
|
|
25
|
+
selectionBackground: '#264f78',
|
|
26
|
+
black: '#000000', brightBlack: '#666666',
|
|
27
|
+
red: '#cd3131', brightRed: '#f14c4c',
|
|
28
|
+
green: '#0dbc79', brightGreen: '#23d18b',
|
|
29
|
+
yellow: '#e5e510', brightYellow: '#f5f543',
|
|
30
|
+
blue: '#2472c8', brightBlue: '#3b8eea',
|
|
31
|
+
magenta: '#bc3fbc', brightMagenta: '#d670d6',
|
|
32
|
+
cyan: '#11a8cd', brightCyan: '#29b8db',
|
|
33
|
+
white: '#e5e5e5', brightWhite: '#e5e5e5',
|
|
35
34
|
};
|
|
36
35
|
|
|
36
|
+
// Light xterm theme — VSCode's Light+ terminal palette, verbatim (see
|
|
37
|
+
// microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts). Pure
|
|
38
|
+
// white ground, #333 ink, the classic saturated ANSI set tuned for legible
|
|
39
|
+
// contrast on white. The surrounding chrome (terminals.css --term-* light
|
|
40
|
+
// defaults) follows the same neutral light grays so it reads as one panel.
|
|
41
|
+
const THEME_LIGHT = {
|
|
42
|
+
background: '#ffffff',
|
|
43
|
+
foreground: '#333333',
|
|
44
|
+
cursor: '#000000',
|
|
45
|
+
cursorAccent: '#ffffff',
|
|
46
|
+
selectionBackground: '#add6ff',
|
|
47
|
+
black: '#000000', brightBlack: '#666666',
|
|
48
|
+
red: '#cd3131', brightRed: '#cd3131',
|
|
49
|
+
green: '#107c10', brightGreen: '#14ce14',
|
|
50
|
+
yellow: '#949800', brightYellow: '#b5ba00',
|
|
51
|
+
blue: '#0451a5', brightBlue: '#0451a5',
|
|
52
|
+
magenta: '#bc05bc', brightMagenta: '#bc05bc',
|
|
53
|
+
cyan: '#0598bc', brightCyan: '#0598bc',
|
|
54
|
+
white: '#555555', brightWhite: '#a5a5a5',
|
|
55
|
+
};
|
|
56
|
+
const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
|
|
57
|
+
|
|
37
58
|
export function TerminalView({ terminalId, cliType }) {
|
|
38
59
|
const hostRef = useRef(null);
|
|
39
60
|
const termRef = useRef(null);
|
|
@@ -45,6 +66,11 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
45
66
|
// currently holds the session.
|
|
46
67
|
const [displaced, setDisplaced] = useState(false);
|
|
47
68
|
const [reattachNonce, setReattach] = useState(0);
|
|
69
|
+
// Subscribe to the theme signal so a Settings toggle re-renders us and
|
|
70
|
+
// the theme-sync effect below re-runs. Holds the xterm theme currently
|
|
71
|
+
// applied so the IME handlers can re-issue it with a transparent cursor.
|
|
72
|
+
const mode = themeMode.value;
|
|
73
|
+
const themeRef = useRef(themeFor(isDarkTheme()));
|
|
48
74
|
|
|
49
75
|
// Raw escape-sequence injector for the mobile key bar. Reads wsRef at
|
|
50
76
|
// call time so it stays valid across reattaches without re-binding.
|
|
@@ -53,6 +79,24 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
53
79
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
54
80
|
};
|
|
55
81
|
|
|
82
|
+
// Swap the xterm canvas palette when the resolved theme flips — both on
|
|
83
|
+
// an explicit Settings toggle (mode dep) and on an OS change while in
|
|
84
|
+
// 'system' mode (matchMedia listener). No remount: xterm re-rasterizes
|
|
85
|
+
// its glyph atlas from the new options.theme in place.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const apply = () => {
|
|
88
|
+
const term = termRef.current;
|
|
89
|
+
if (!term) return;
|
|
90
|
+
const theme = themeFor(isDarkTheme());
|
|
91
|
+
themeRef.current = theme;
|
|
92
|
+
try { term.options.theme = theme; } catch {}
|
|
93
|
+
};
|
|
94
|
+
apply();
|
|
95
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
96
|
+
mq.addEventListener('change', apply);
|
|
97
|
+
return () => mq.removeEventListener('change', apply);
|
|
98
|
+
}, [mode, reattachNonce]);
|
|
99
|
+
|
|
56
100
|
useEffect(() => {
|
|
57
101
|
if (!terminalId || !hostRef.current) return;
|
|
58
102
|
|
|
@@ -63,6 +107,8 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
63
107
|
// next mount (rare; users typically don't rotate mid-session).
|
|
64
108
|
const isMobile = window.matchMedia('(max-width: 640px)').matches;
|
|
65
109
|
const baseFontSize = isMobile ? 11 : 13;
|
|
110
|
+
const initialTheme = themeFor(isDarkTheme());
|
|
111
|
+
themeRef.current = initialTheme;
|
|
66
112
|
const term = new Terminal({
|
|
67
113
|
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
68
114
|
fontSize: baseFontSize,
|
|
@@ -71,7 +117,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
71
117
|
cursorStyle: 'bar',
|
|
72
118
|
scrollback: 5000,
|
|
73
119
|
allowProposedApi: true,
|
|
74
|
-
theme:
|
|
120
|
+
theme: initialTheme,
|
|
75
121
|
// Modern keyboard protocols. Without these, xterm.js encodes
|
|
76
122
|
// Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
|
|
77
123
|
// unmodified versions (e.g. both Enter and Shift+Enter send \r),
|
|
@@ -137,8 +183,26 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
137
183
|
|
|
138
184
|
const host = hostRef.current;
|
|
139
185
|
term.open(host);
|
|
140
|
-
//
|
|
141
|
-
|
|
186
|
+
// Robust fit scheduler. A single requestAnimationFrame works most
|
|
187
|
+
// of the time but races on tab/session switches: the .tab-panel
|
|
188
|
+
// just flipped from display:none to display:flex and although the
|
|
189
|
+
// browser has laid the element out by the next frame, xterm's
|
|
190
|
+
// canvas measurement occasionally still reports the pre-display
|
|
191
|
+
// size (Chromium quirk — the WebGL renderer caches its viewport
|
|
192
|
+
// before the layout flush propagates through ResizeObserver).
|
|
193
|
+
// Result: visible "wrong cols/rows until I resize the window" bug.
|
|
194
|
+
// Spraying fits at 0 / one rAF / 60ms / 200ms covers every
|
|
195
|
+
// measurement-arrival path without being expensive — fit.fit() is
|
|
196
|
+
// a no-op when cols/rows match the previous call.
|
|
197
|
+
const scheduleFit = () => {
|
|
198
|
+
try { fit.fit(); } catch {}
|
|
199
|
+
requestAnimationFrame(() => {
|
|
200
|
+
try { fit.fit(); } catch {}
|
|
201
|
+
setTimeout(() => { try { fit.fit(); } catch {} }, 60);
|
|
202
|
+
setTimeout(() => { try { fit.fit(); } catch {} }, 200);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
scheduleFit();
|
|
142
206
|
termRef.current = term;
|
|
143
207
|
|
|
144
208
|
// Browser WS API can't set Authorization headers — token + device
|
|
@@ -163,7 +227,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
163
227
|
// wrapped at 80 cols, and the follow-up resize from the rAF fit
|
|
164
228
|
// wouldn't reflow the already-emitted bytes. Visible as squeezed
|
|
165
229
|
// text on every session switch.
|
|
166
|
-
|
|
230
|
+
scheduleFit();
|
|
167
231
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
168
232
|
};
|
|
169
233
|
ws.onmessage = (ev) => {
|
|
@@ -202,6 +266,20 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
202
266
|
const ro = new ResizeObserver(() => { try { fit.fit(); } catch {} });
|
|
203
267
|
ro.observe(hostRef.current);
|
|
204
268
|
|
|
269
|
+
// Mobile soft-keyboard resize. When the IME slides up on iOS /
|
|
270
|
+
// Android, the layout viewport doesn't change but `visualViewport`
|
|
271
|
+
// does — the page now has less vertical room before the keyboard
|
|
272
|
+
// covers the bottom. xterm's host element keeps its old layout
|
|
273
|
+
// height (we use 100vh-derived sizing) so half the terminal sits
|
|
274
|
+
// behind the keyboard with no resize callback fired. Listening
|
|
275
|
+
// here covers it: any visualViewport size change triggers a fit
|
|
276
|
+
// so the cell grid matches the visible area. Cheap; fit.fit() is
|
|
277
|
+
// a no-op when nothing changed.
|
|
278
|
+
const vv = window.visualViewport;
|
|
279
|
+
const onVisualResize = () => scheduleFit();
|
|
280
|
+
vv?.addEventListener?.('resize', onVisualResize);
|
|
281
|
+
vv?.addEventListener?.('scroll', onVisualResize);
|
|
282
|
+
|
|
205
283
|
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
206
284
|
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
207
285
|
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
@@ -217,7 +295,7 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
217
295
|
if (panel.hasAttribute('data-active')) {
|
|
218
296
|
requestAnimationFrame(() => {
|
|
219
297
|
try { term.clearTextureAtlas?.(); } catch {}
|
|
220
|
-
|
|
298
|
+
scheduleFit();
|
|
221
299
|
try { term.refresh(0, term.rows - 1); } catch {}
|
|
222
300
|
});
|
|
223
301
|
}
|
|
@@ -349,16 +427,17 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
349
427
|
// the CSS in terminals.css does the rest.
|
|
350
428
|
const onCompStart = () => {
|
|
351
429
|
if (host) host.classList.add('is-composing');
|
|
352
|
-
// The terminal cursor is rendered on canvas (
|
|
430
|
+
// The terminal cursor is rendered on canvas (theme.cursor), so CSS
|
|
353
431
|
// can't hide it. Theme swap alone doesn't reliably stop the blink
|
|
354
432
|
// frame loop, so also issue the DECTCEM hide sequence which the
|
|
355
|
-
// renderer honours immediately.
|
|
356
|
-
|
|
433
|
+
// renderer honours immediately. Use the live theme (themeRef) so the
|
|
434
|
+
// restore on compEnd matches whatever light/dark is current.
|
|
435
|
+
try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
357
436
|
try { term.write('\x1b[?25l'); } catch {}
|
|
358
437
|
};
|
|
359
438
|
const onCompEnd = () => {
|
|
360
439
|
if (host) host.classList.remove('is-composing');
|
|
361
|
-
try { term.options.theme =
|
|
440
|
+
try { term.options.theme = themeRef.current; } catch {}
|
|
362
441
|
try { term.write('\x1b[?25h'); } catch {}
|
|
363
442
|
};
|
|
364
443
|
const helper = host?.querySelector('.xterm-helper-textarea');
|
|
@@ -377,6 +456,8 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
377
456
|
}
|
|
378
457
|
ro.disconnect();
|
|
379
458
|
if (panelMo) panelMo.disconnect();
|
|
459
|
+
vv?.removeEventListener?.('resize', onVisualResize);
|
|
460
|
+
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
380
461
|
try { ws.close(); } catch {}
|
|
381
462
|
try { term.dispose(); } catch {}
|
|
382
463
|
termRef.current = null;
|
package/public/js/icons.js
CHANGED
|
@@ -199,7 +199,7 @@ export const StarSmallFilled = ({ size = 14 } = {}) => html`
|
|
|
199
199
|
// brand mark (terminal window + ccsm text — matches /favicon.svg)
|
|
200
200
|
export const BrandMark = () => html`
|
|
201
201
|
<svg viewBox="0 0 32 32" width="32" height="32">
|
|
202
|
-
<rect x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
|
|
202
|
+
<rect class="brand-rect" x="2" y="4" width="28" height="24" rx="3" fill="#1a1815"/>
|
|
203
203
|
<line x1="2" y1="10" x2="30" y2="10" stroke="#faf9f5" stroke-width="0.6" opacity="0.45"/>
|
|
204
204
|
<!-- macOS traffic-light style: red / yellow / green -->
|
|
205
205
|
<circle cx="6" cy="7" r="1" fill="#ed6a5e"/>
|
|
@@ -6,8 +6,8 @@ import { html } from '../html.js';
|
|
|
6
6
|
import { useEffect, useState } from 'preact/hooks';
|
|
7
7
|
import {
|
|
8
8
|
config, configDirty, accentColor, folders, workspaces, serverHealth,
|
|
9
|
-
restartInFlight,
|
|
10
|
-
setAccentColor, ACCENT_DEFAULT,
|
|
9
|
+
restartInFlight, themeMode,
|
|
10
|
+
setAccentColor, ACCENT_DEFAULT, setThemeMode,
|
|
11
11
|
} from '../state.js';
|
|
12
12
|
import {
|
|
13
13
|
api, loadConfig, loadWorkspaces, loadFolders,
|
|
@@ -182,6 +182,10 @@ export function ConfigurePage() {
|
|
|
182
182
|
|
|
183
183
|
<${Section} title="General">
|
|
184
184
|
<div class="config-grid">
|
|
185
|
+
<div class="field">
|
|
186
|
+
<span class="label">Appearance</span>
|
|
187
|
+
<${ThemeToggle} />
|
|
188
|
+
</div>
|
|
185
189
|
<div class="field">
|
|
186
190
|
<span class="label">Theme accent</span>
|
|
187
191
|
<${AccentPicker} />
|
|
@@ -577,6 +581,23 @@ function RestartButton() {
|
|
|
577
581
|
`;
|
|
578
582
|
}
|
|
579
583
|
|
|
584
|
+
function ThemeToggle() {
|
|
585
|
+
const mode = themeMode.value;
|
|
586
|
+
const opts = [
|
|
587
|
+
{ id: 'light', label: 'Light' },
|
|
588
|
+
{ id: 'dark', label: 'Dark' },
|
|
589
|
+
{ id: 'system', label: 'System' },
|
|
590
|
+
];
|
|
591
|
+
return html`
|
|
592
|
+
<div class="seg" role="group" aria-label="Appearance">
|
|
593
|
+
${opts.map((o) => html`
|
|
594
|
+
<button key=${o.id} type="button"
|
|
595
|
+
class=${`seg-btn${mode === o.id ? ' is-active' : ''}`}
|
|
596
|
+
aria-pressed=${mode === o.id}
|
|
597
|
+
onClick=${() => setThemeMode(o.id)}>${o.label}</button>`)}
|
|
598
|
+
</div>`;
|
|
599
|
+
}
|
|
600
|
+
|
|
580
601
|
function AccentPicker() {
|
|
581
602
|
const current = (accentColor.value || '').toLowerCase();
|
|
582
603
|
const matchedPreset = PRESETS.find((p) => p.hex.toLowerCase() === current);
|
|
@@ -21,7 +21,32 @@ import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconCh
|
|
|
21
21
|
const ROOT_ID = 'newSessionProgress';
|
|
22
22
|
const selectedRepos = signal(new Set());
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Persist the user's last Launch picks (CLI / folder / mode / cwd /
|
|
25
|
+
// selected repos) so the form stays as they left it across reloads
|
|
26
|
+
// and tab switches. localStorage is best-effort — any access failure
|
|
27
|
+
// falls back silently.
|
|
28
|
+
const LS_KEY = 'ccsm.launch-state';
|
|
29
|
+
function loadLaunchState() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
const j = JSON.parse(raw);
|
|
34
|
+
if (j && typeof j === 'object') return j;
|
|
35
|
+
} catch {}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function saveLaunchState(s) {
|
|
39
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function initRepoSelection(repos, override) {
|
|
43
|
+
if (override && Array.isArray(override)) {
|
|
44
|
+
// Only honour names that still exist in the user's repo list;
|
|
45
|
+
// anything else was deleted between sessions.
|
|
46
|
+
const valid = new Set(repos.map((r) => r.name));
|
|
47
|
+
selectedRepos.value = new Set(override.filter((n) => valid.has(n)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
25
50
|
const want = new Set(repos.filter((r) => r.defaultSelected).map((r) => r.name));
|
|
26
51
|
selectedRepos.value = want;
|
|
27
52
|
}
|
|
@@ -31,11 +56,15 @@ function LaunchHero() {
|
|
|
31
56
|
const clis = cfg.clis || [];
|
|
32
57
|
const repos = cfg.repos || [];
|
|
33
58
|
const defaultCli = cfg.defaultCliId || clis[0]?.id || '';
|
|
59
|
+
const saved = loadLaunchState();
|
|
34
60
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const [
|
|
61
|
+
// Initial values pull from localStorage first (last-used picks),
|
|
62
|
+
// then fall back to config defaults. cliId is validated below in
|
|
63
|
+
// the useEffect once `clis` arrives.
|
|
64
|
+
const [cliId, setCliId] = useState(saved?.cliId || defaultCli);
|
|
65
|
+
const [folderId, setFolderId] = useState(saved?.folderId || '');
|
|
66
|
+
const [mode, setMode] = useState(saved?.mode === 'cwd' ? 'cwd' : 'auto');
|
|
67
|
+
const [cwd, setCwd] = useState(saved?.cwd || ''); // only used when mode === 'cwd'
|
|
39
68
|
const [busy, setBusy] = useState(false);
|
|
40
69
|
const [result, setResult] = useState('');
|
|
41
70
|
const [openPicker, setOpenPicker] = useState(null); // 'cli' | 'folder' | 'workdir' | null
|
|
@@ -50,6 +79,22 @@ function LaunchHero() {
|
|
|
50
79
|
}
|
|
51
80
|
}, [defaultCli, clis.length]);
|
|
52
81
|
|
|
82
|
+
// Validate the persisted folder id against the live folders list
|
|
83
|
+
// — folders deleted between sessions snap back to "no folder".
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!folderId) return;
|
|
86
|
+
if (!folders.value.find((f) => f.id === folderId)) setFolderId('');
|
|
87
|
+
}, [folderId, folders.value.length]);
|
|
88
|
+
|
|
89
|
+
// Persist every change. JSON-stringifying a Set isn't useful, so
|
|
90
|
+
// we materialize selectedRepos to an array here.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
saveLaunchState({
|
|
93
|
+
cliId, folderId, mode, cwd,
|
|
94
|
+
repos: [...selectedRepos.value],
|
|
95
|
+
});
|
|
96
|
+
}, [cliId, folderId, mode, cwd, selectedRepos.value]);
|
|
97
|
+
|
|
53
98
|
const folderDnd = useDragSort(
|
|
54
99
|
folders.value.map((f) => f.id),
|
|
55
100
|
async (nextIds) => {
|
|
@@ -59,7 +104,7 @@ function LaunchHero() {
|
|
|
59
104
|
);
|
|
60
105
|
|
|
61
106
|
const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
|
|
62
|
-
useStateOnce(sig, () => initRepoSelection(repos));
|
|
107
|
+
useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
|
|
63
108
|
|
|
64
109
|
const cli = clis.find((c) => c.id === cliId) || clis[0];
|
|
65
110
|
const folder = folders.value.find((f) => f.id === folderId);
|
|
@@ -104,6 +104,32 @@ function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
|
|
|
104
104
|
</button>`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Tiny inline row shown under the signed-in Microsoft Dev Tunnel
|
|
108
|
+
// status. Displays the persisted (named) tunnel id ccsm reuses across
|
|
109
|
+
// restarts so the public URL stays stable — and lets the user rotate
|
|
110
|
+
// it on demand. Reset requires the tunnel to be stopped first; the
|
|
111
|
+
// server-side route also enforces this.
|
|
112
|
+
function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
|
|
113
|
+
if (!tunnelId) {
|
|
114
|
+
return html`
|
|
115
|
+
<div class="tunnel-id-row is-empty">
|
|
116
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
117
|
+
<span class="tunnel-id-value-empty">none yet · minted on next Start</span>
|
|
118
|
+
</div>`;
|
|
119
|
+
}
|
|
120
|
+
return html`
|
|
121
|
+
<div class="tunnel-id-row">
|
|
122
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
123
|
+
<code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
|
|
124
|
+
<button type="button" class="action subtle small tunnel-id-reset"
|
|
125
|
+
disabled=${running}
|
|
126
|
+
title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
|
|
127
|
+
onClick=${onReset}>
|
|
128
|
+
<${IconRecycle} /> Reset
|
|
129
|
+
</button>
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
|
|
108
134
|
if (!info) return html`<span class="provider-status-muted">probing…</span>`;
|
|
109
135
|
if (!info.installed) {
|
|
@@ -261,9 +287,26 @@ function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
|
|
|
261
287
|
|
|
262
288
|
export function RemotePage() {
|
|
263
289
|
clockTick.value; // re-tick fmtAgo "last seen" labels
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
290
|
+
// Hydrate from a localStorage cache so the page renders the same
|
|
291
|
+
// shape it had at the end of the previous visit — provider tiles,
|
|
292
|
+
// signed-in state, tunnel id, share URL — instead of empty / placeholder
|
|
293
|
+
// chrome that fills in after the slow /api/tunnel/status round-trip
|
|
294
|
+
// (700ms+ on a cold probe). The cached snapshot is overwritten by
|
|
295
|
+
// refresh() the moment the live response lands.
|
|
296
|
+
const cachedStatus = (() => {
|
|
297
|
+
try {
|
|
298
|
+
const raw = localStorage.getItem('ccsm.remote-status-cache');
|
|
299
|
+
return raw ? JSON.parse(raw) : null;
|
|
300
|
+
} catch { return null; }
|
|
301
|
+
})();
|
|
302
|
+
const [status, setStatus] = useState(cachedStatus);
|
|
303
|
+
const [provider, setProvider] = useState(() => {
|
|
304
|
+
if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
|
|
305
|
+
if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
|
|
306
|
+
if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
|
|
307
|
+
return 'devtunnel';
|
|
308
|
+
});
|
|
309
|
+
const [token, setTokenLocal] = useState(cachedStatus?.token || '');
|
|
267
310
|
const [busy, setBusy] = useState(false);
|
|
268
311
|
const [deviceList, setDeviceList] = useState([]);
|
|
269
312
|
const pollRef = useRef(null);
|
|
@@ -280,10 +323,17 @@ export function RemotePage() {
|
|
|
280
323
|
setProvider((cur) => {
|
|
281
324
|
if (s.running && s.provider) return s.provider;
|
|
282
325
|
if (cur) return cur;
|
|
283
|
-
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
284
326
|
if (s.providers?.devtunnel?.installed) return 'devtunnel';
|
|
285
|
-
|
|
327
|
+
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
328
|
+
return cur || 'devtunnel';
|
|
286
329
|
});
|
|
330
|
+
// Snapshot for the next mount. Skip the per-call `log` so the
|
|
331
|
+
// cache stays small.
|
|
332
|
+
try {
|
|
333
|
+
localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
|
|
334
|
+
...s, log: undefined,
|
|
335
|
+
}));
|
|
336
|
+
} catch {}
|
|
287
337
|
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
288
338
|
}
|
|
289
339
|
|
|
@@ -400,6 +450,18 @@ export function RemotePage() {
|
|
|
400
450
|
try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
|
|
401
451
|
catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
|
|
402
452
|
}
|
|
453
|
+
async function onResetDevtunnelId() {
|
|
454
|
+
const ok = await ccsmConfirm(
|
|
455
|
+
`Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
|
|
456
|
+
{ title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
|
|
457
|
+
);
|
|
458
|
+
if (!ok) return;
|
|
459
|
+
try {
|
|
460
|
+
await api('POST', '/api/tunnel/devtunnel/reset');
|
|
461
|
+
refresh();
|
|
462
|
+
setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
|
|
463
|
+
} catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
|
|
464
|
+
}
|
|
403
465
|
|
|
404
466
|
const running = status?.running;
|
|
405
467
|
const url = status?.url;
|
|
@@ -426,30 +488,21 @@ export function RemotePage() {
|
|
|
426
488
|
<div class="field">
|
|
427
489
|
<span class="label">Provider</span>
|
|
428
490
|
<div class="provider-tile-row">
|
|
429
|
-
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
430
|
-
hint="Anonymous · no login"
|
|
431
|
-
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
432
|
-
selected=${provider === 'cloudflared'}
|
|
433
|
-
disabled=${running}
|
|
434
|
-
onSelect=${setProvider} />
|
|
435
491
|
<${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
|
|
436
492
|
hint="Requires sign-in"
|
|
437
493
|
icon=${html`<${IconMicrosoftColor} size=${32} />`}
|
|
438
494
|
selected=${provider === 'devtunnel'}
|
|
439
495
|
disabled=${running}
|
|
440
496
|
onSelect=${setProvider} />
|
|
497
|
+
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
498
|
+
hint="Anonymous · no login"
|
|
499
|
+
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
500
|
+
selected=${provider === 'cloudflared'}
|
|
501
|
+
disabled=${running}
|
|
502
|
+
onSelect=${setProvider} />
|
|
441
503
|
</div>
|
|
442
504
|
${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
|
|
443
505
|
</div>
|
|
444
|
-
${provider === 'cloudflared' ? html`
|
|
445
|
-
<div class="field">
|
|
446
|
-
<span class="label">Cloudflare Tunnel</span>
|
|
447
|
-
<div class="remote-status-line">
|
|
448
|
-
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
449
|
-
onInstall=${() => onInstall('cloudflared')} />
|
|
450
|
-
</div>
|
|
451
|
-
</div>
|
|
452
|
-
` : null}
|
|
453
506
|
${provider === 'devtunnel' ? html`
|
|
454
507
|
<div class="field">
|
|
455
508
|
<span class="label">Microsoft Dev Tunnel</span>
|
|
@@ -466,6 +519,21 @@ export function RemotePage() {
|
|
|
466
519
|
onDismiss=${onLoginDismiss}
|
|
467
520
|
onRetry=${() => onLogin('devtunnel')} />
|
|
468
521
|
` : null}
|
|
522
|
+
${dt?.loggedIn ? html`
|
|
523
|
+
<${DevtunnelTunnelIdRow}
|
|
524
|
+
tunnelId=${status?.tunnelId}
|
|
525
|
+
running=${running && status?.provider === 'devtunnel'}
|
|
526
|
+
onReset=${onResetDevtunnelId} />
|
|
527
|
+
` : null}
|
|
528
|
+
</div>
|
|
529
|
+
` : null}
|
|
530
|
+
${provider === 'cloudflared' ? html`
|
|
531
|
+
<div class="field">
|
|
532
|
+
<span class="label">Cloudflare Tunnel</span>
|
|
533
|
+
<div class="remote-status-line">
|
|
534
|
+
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
535
|
+
onInstall=${() => onInstall('cloudflared')} />
|
|
536
|
+
</div>
|
|
469
537
|
</div>
|
|
470
538
|
` : null}
|
|
471
539
|
</div>
|