cchost 0.4.4 → 0.6.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/CHANGELOG.md +112 -0
- package/README.md +114 -44
- package/bin/cc.js +6 -1
- package/lib/actions.js +290 -0
- package/lib/banner.js +3 -1
- package/lib/ccforward.js +77 -0
- package/lib/cli.js +363 -104
- package/lib/color.js +3 -1
- package/lib/copy.js +43 -41
- package/lib/doctor.js +211 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +124 -6
- package/lib/spawn.js +2 -1
- package/lib/ui/login.html +117 -0
- package/lib/ui/page.html +1171 -0
- package/lib/ui/server.js +384 -0
- package/lib/ui/tokens.css +80 -0
- package/package.json +3 -2
package/lib/ui/page.html
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<title>cchost</title>
|
|
9
|
+
<style nonce="__NONCE__">
|
|
10
|
+
/*__TOKENS__*/
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
[hidden] { display: none !important; }
|
|
13
|
+
html, body { margin: 0; }
|
|
14
|
+
body {
|
|
15
|
+
background: var(--bg);
|
|
16
|
+
color: var(--text);
|
|
17
|
+
font: 14px/1.5 var(--sans);
|
|
18
|
+
font-variant-numeric: tabular-nums;
|
|
19
|
+
-webkit-font-smoothing: antialiased;
|
|
20
|
+
}
|
|
21
|
+
::selection { background: color-mix(in srgb, var(--primary) 28%, transparent); }
|
|
22
|
+
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
|
|
23
|
+
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
|
24
|
+
a { color: var(--primary); text-underline-offset: 3px; }
|
|
25
|
+
h1, h2, h3 { margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
|
|
26
|
+
p { margin: 0; }
|
|
27
|
+
code, .mono { font-family: var(--mono); font-size: 12.5px; }
|
|
28
|
+
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
29
|
+
svg.i { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
|
30
|
+
|
|
31
|
+
/* Shell */
|
|
32
|
+
.app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; transition: grid-template-columns 260ms var(--ease); }
|
|
33
|
+
.side { overflow: hidden; }
|
|
34
|
+
.brand .collapse { margin-left: auto; width: 28px; height: 28px; color: var(--text-3); }
|
|
35
|
+
.fold { transition: opacity 180ms var(--ease); white-space: nowrap; }
|
|
36
|
+
.who { display: flex; align-items: center; gap: 8px; }
|
|
37
|
+
.who > .fold { display: grid; min-width: 0; font-size: 12.5px; line-height: 1.35; }
|
|
38
|
+
.who b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
|
39
|
+
.who .btn { margin-left: auto; color: var(--text-3); }
|
|
40
|
+
/* Collapsed rail: icons only, labels as tooltips. */
|
|
41
|
+
.app.collapsed { grid-template-columns: 64px minmax(0, 1fr); }
|
|
42
|
+
.app.collapsed .side { padding-left: 10px; padding-right: 10px; }
|
|
43
|
+
.app.collapsed .fold { opacity: 0; pointer-events: none; width: 0; overflow: hidden; }
|
|
44
|
+
.app.collapsed .nav .count { display: none; }
|
|
45
|
+
.app.collapsed .brand .logo { margin: 0 auto; }
|
|
46
|
+
.app.collapsed .theme.fold, .app.collapsed .side-foot > .fold:first-child { display: none; }
|
|
47
|
+
.app.collapsed .brand { flex-direction: column; gap: 14px; padding-left: 4px; padding-right: 4px; }
|
|
48
|
+
.app.collapsed .brand .collapse { margin-left: 0; transform: scaleX(-1); }
|
|
49
|
+
.app.collapsed .nav a { justify-content: center; gap: 0; padding: 9px 0; }
|
|
50
|
+
.app.collapsed .who { justify-content: center; }
|
|
51
|
+
.app.collapsed .who .btn { margin-left: 0; }
|
|
52
|
+
.app.collapsed .side-foot { padding-left: 0; padding-right: 0; }
|
|
53
|
+
.side {
|
|
54
|
+
position: sticky; top: 0; height: 100vh;
|
|
55
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
56
|
+
padding: 16px 12px; background: var(--surface); border-right: 1px solid var(--border);
|
|
57
|
+
}
|
|
58
|
+
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 4px 18px 8px; font-weight: 650; font-size: 15px; }
|
|
59
|
+
.brand svg { width: 22px; height: 22px; }
|
|
60
|
+
.brand .mark { fill: var(--primary); opacity: 0.9; }
|
|
61
|
+
.brand small { margin-left: 6px; color: var(--text-3); font-weight: 500; font-size: 12px; }
|
|
62
|
+
.nav a {
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 6px;
|
|
65
|
+
color: var(--text-2); text-decoration: none; font-weight: 500;
|
|
66
|
+
}
|
|
67
|
+
.nav a { transition: background 140ms, color 140ms; }
|
|
68
|
+
.nav a:hover { background: var(--surface-2); color: var(--text); }
|
|
69
|
+
.nav a[aria-current="page"] { background: var(--shared-bg); color: var(--shared-fg); }
|
|
70
|
+
.nav a[aria-current="page"] .count { color: inherit; }
|
|
71
|
+
.nav a .count { margin-left: auto; font-size: 12px; color: var(--text-3); }
|
|
72
|
+
.nav a .count.warn { color: var(--warn); }
|
|
73
|
+
.nav a .count.err { color: var(--err); }
|
|
74
|
+
.side-foot { margin-top: auto; display: grid; gap: 10px; padding: 12px 8px 4px; border-top: 1px solid var(--border); }
|
|
75
|
+
.side-foot .label { font-size: 12px; color: var(--text-3); }
|
|
76
|
+
.side-foot .path { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
|
77
|
+
.theme { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; padding: 2px; gap: 2px; width: max-content; }
|
|
78
|
+
.theme button { border: 0; background: transparent; color: var(--text-3); padding: 4px 8px; border-radius: 5px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
79
|
+
.theme button { transition: background 140ms, color 140ms; }
|
|
80
|
+
.theme button[aria-pressed="true"] { background: var(--shared-bg); color: var(--shared-fg); }
|
|
81
|
+
|
|
82
|
+
main { min-width: 0; }
|
|
83
|
+
.top {
|
|
84
|
+
position: sticky; top: 0; z-index: 5;
|
|
85
|
+
display: flex; align-items: center; gap: 12px;
|
|
86
|
+
height: 60px; padding: 0 32px;
|
|
87
|
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
88
|
+
backdrop-filter: saturate(1.4) blur(8px);
|
|
89
|
+
border-bottom: 1px solid var(--border);
|
|
90
|
+
}
|
|
91
|
+
.top h1 { font-size: 20px; font-weight: 650; }
|
|
92
|
+
.top .sync { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--text-3); font-size: 12.5px; }
|
|
93
|
+
.content { padding: 24px 32px 64px; max-width: 1280px; }
|
|
94
|
+
|
|
95
|
+
/* Buttons */
|
|
96
|
+
.btn {
|
|
97
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
98
|
+
height: 32px; padding: 0 12px; border-radius: 6px;
|
|
99
|
+
border: 1px solid var(--border-strong); background: var(--surface); color: var(--text);
|
|
100
|
+
font: inherit; font-weight: 550; font-size: 13px; cursor: pointer; white-space: nowrap;
|
|
101
|
+
box-shadow: var(--shadow-sm);
|
|
102
|
+
transition: background 120ms, border-color 120ms, color 120ms, transform 120ms;
|
|
103
|
+
}
|
|
104
|
+
.btn:hover { background: var(--surface-2); }
|
|
105
|
+
.btn:active:not(:disabled) { transform: translateY(1px); }
|
|
106
|
+
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
|
107
|
+
.btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
|
108
|
+
.btn.danger { color: var(--err-fg); }
|
|
109
|
+
.btn.danger:hover { background: var(--err-bg); border-color: var(--foreign-bd); }
|
|
110
|
+
.btn.danger.solid { background: var(--danger-solid); border-color: var(--danger-solid); color: var(--primary-fg); }
|
|
111
|
+
.btn.ghost { border-color: transparent; background: transparent; box-shadow: none; color: var(--text-2); }
|
|
112
|
+
.btn.ghost:hover { background: var(--surface-2); color: var(--text); }
|
|
113
|
+
.btn.icon { width: 32px; padding: 0; }
|
|
114
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
115
|
+
.btn .spin { animation: spin 0.8s linear infinite; }
|
|
116
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
117
|
+
|
|
118
|
+
/* Profiles */
|
|
119
|
+
.toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
120
|
+
.toolbar p { color: var(--text-2); }
|
|
121
|
+
.legend { display: flex; gap: 16px; flex-wrap: wrap; margin-left: auto; font-size: 12.5px; color: var(--text-2); }
|
|
122
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
123
|
+
.swatch { width: 10px; height: 10px; border-radius: 3px; border: 1px solid; }
|
|
124
|
+
.swatch.shared { background: var(--shared-bg); border-color: var(--shared-bd); }
|
|
125
|
+
.swatch.private { background: var(--private-bg); border-color: var(--private-bd); }
|
|
126
|
+
.swatch.missing { border-style: dashed; border-color: var(--missing-bd); }
|
|
127
|
+
|
|
128
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
|
129
|
+
.card {
|
|
130
|
+
transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease), transform 180ms var(--ease);
|
|
131
|
+
display: flex; flex-direction: column; gap: 14px;
|
|
132
|
+
padding: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
133
|
+
box-shadow: var(--shadow-sm);
|
|
134
|
+
}
|
|
135
|
+
.card:hover { border-color: var(--border-strong); box-shadow: 0 4px 14px rgba(9, 9, 11, 0.06); transform: translateY(-1px); }
|
|
136
|
+
.card-head { display: flex; align-items: flex-start; gap: 10px; }
|
|
137
|
+
.card-head h2 { font-size: 15px; font-weight: 650; }
|
|
138
|
+
.meta { color: var(--text-2); font-size: 13px; overflow-wrap: anywhere; }
|
|
139
|
+
.pill {
|
|
140
|
+
margin-left: auto; display: inline-flex; align-items: center; gap: 6px;
|
|
141
|
+
padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 550; white-space: nowrap;
|
|
142
|
+
background: var(--surface-2); color: var(--text-2);
|
|
143
|
+
}
|
|
144
|
+
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
145
|
+
.pill.on { background: var(--ok-bg); color: var(--ok); }
|
|
146
|
+
.pill.ep { background: var(--shared-bg); color: var(--shared-fg); }
|
|
147
|
+
.groups { display: grid; gap: 8px; }
|
|
148
|
+
.group { display: grid; grid-template-columns: 64px 1fr; align-items: start; gap: 8px; }
|
|
149
|
+
.group > span { font-size: 12px; color: var(--text-3); line-height: 24px; }
|
|
150
|
+
.badges { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
151
|
+
.badge {
|
|
152
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
153
|
+
height: 24px; padding: 0 8px; border-radius: 6px; border: 1px solid;
|
|
154
|
+
font-size: 12px; font-weight: 500; white-space: nowrap;
|
|
155
|
+
}
|
|
156
|
+
.badge svg.i { width: 12px; height: 12px; stroke-width: 2; }
|
|
157
|
+
.badge.shared { background: var(--shared-bg); color: var(--shared-fg); border-color: var(--shared-bd); }
|
|
158
|
+
.badge.private, .badge.empty { background: var(--private-bg); color: var(--private-fg); border-color: var(--private-bd); }
|
|
159
|
+
.badge.missing { background: var(--missing-bg); color: var(--missing-fg); border-color: var(--missing-bd); border-style: dashed; }
|
|
160
|
+
.badge.foreign-link { background: var(--foreign-bg); color: var(--foreign-fg); border-color: var(--foreign-bd); }
|
|
161
|
+
.badge.changed { animation: changed 1.6s var(--ease); }
|
|
162
|
+
@keyframes changed {
|
|
163
|
+
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 0%, transparent); }
|
|
164
|
+
20% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 35%, transparent); }
|
|
165
|
+
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 0%, transparent); }
|
|
166
|
+
}
|
|
167
|
+
.card-actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
168
|
+
.btn.quiet:hover { color: var(--err-fg); border-color: var(--foreign-bd); background: var(--err-bg); }
|
|
169
|
+
|
|
170
|
+
.empty {
|
|
171
|
+
display: grid; justify-items: start; gap: 12px; padding: 40px;
|
|
172
|
+
border: 1px dashed var(--border-strong); border-radius: 10px; background: var(--surface);
|
|
173
|
+
}
|
|
174
|
+
.empty h2 { font-size: 15px; }
|
|
175
|
+
.empty p { color: var(--text-2); max-width: 60ch; }
|
|
176
|
+
|
|
177
|
+
/* Tables (pool, doctor) */
|
|
178
|
+
.table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
179
|
+
.table th { text-align: left; font-size: 12px; font-weight: 550; color: var(--text-3); padding: 10px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
|
180
|
+
.table td { padding: 12px 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
181
|
+
.table tr:last-child td { border-bottom: 0; }
|
|
182
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
183
|
+
.chip { padding: 1px 8px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); font-size: 12px; }
|
|
184
|
+
.muted { color: var(--text-3); }
|
|
185
|
+
|
|
186
|
+
.summary { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
|
187
|
+
.status { display: inline-flex; align-items: center; gap: 6px; font-weight: 550; }
|
|
188
|
+
.status.ok { color: var(--ok); }
|
|
189
|
+
.status.warn { color: var(--warn); }
|
|
190
|
+
.status.error { color: var(--err); }
|
|
191
|
+
.check-group { margin-bottom: 4px; }
|
|
192
|
+
.check-group[open] { margin-bottom: 16px; }
|
|
193
|
+
.check-group summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 6px 0; margin-bottom: 6px; cursor: pointer; font-size: 13px; }
|
|
194
|
+
.check-group summary::-webkit-details-marker { display: none; }
|
|
195
|
+
.check-group summary::before { content: ""; width: 6px; height: 6px; border-right: 1.5px solid var(--text-3); border-bottom: 1.5px solid var(--text-3); transform: rotate(-45deg); transition: transform 160ms var(--ease); }
|
|
196
|
+
.check-group[open] summary::before { transform: rotate(45deg); }
|
|
197
|
+
.check-group h2 { font-size: 13px; font-weight: 600; color: var(--text-2); }
|
|
198
|
+
.check-group summary .status { font-weight: 500; font-size: 12.5px; }
|
|
199
|
+
.check { display: grid; grid-template-columns: 20px minmax(160px, 260px) 1fr auto; gap: 10px; align-items: start; }
|
|
200
|
+
.row-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
201
|
+
.row-actions:empty { display: none; }
|
|
202
|
+
.confirm-note { display: flex; gap: 8px; padding: 10px 12px; border: 1px solid var(--private-bd); border-radius: 8px; background: var(--private-bg); color: var(--private-fg); font-size: 13px; margin-bottom: 4px; }
|
|
203
|
+
.check .hint { display: block; margin-top: 4px; color: var(--text-3); font-size: 12.5px; }
|
|
204
|
+
|
|
205
|
+
/* Activity */
|
|
206
|
+
.entries { display: grid; gap: 12px; }
|
|
207
|
+
.entry { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
208
|
+
.entry summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 16px; cursor: pointer; }
|
|
209
|
+
.entry summary::-webkit-details-marker { display: none; }
|
|
210
|
+
.entry summary .when { margin-left: auto; color: var(--text-3); font-size: 12.5px; }
|
|
211
|
+
.entry[open] summary { border-bottom: 1px solid var(--border); }
|
|
212
|
+
.entry .lines { padding: 8px 16px 12px; }
|
|
213
|
+
.entry > .muted { padding: 8px 16px 12px; }
|
|
214
|
+
|
|
215
|
+
/* Change lines (preview + result) */
|
|
216
|
+
.lines { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
|
217
|
+
.lines li {
|
|
218
|
+
display: grid; grid-template-columns: 18px 110px 1fr; gap: 8px; align-items: start;
|
|
219
|
+
padding: 5px 0; font-size: 13px;
|
|
220
|
+
}
|
|
221
|
+
.lines li .res { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
|
|
222
|
+
.lines li.same { color: var(--text-3); }
|
|
223
|
+
.lines li.same .res { color: var(--text-3); }
|
|
224
|
+
.lines li .k { padding-top: 2px; }
|
|
225
|
+
.lines li.change .k { color: var(--primary); }
|
|
226
|
+
.lines li.warn .k { color: var(--warn); }
|
|
227
|
+
.lines li.error .k, .lines li.error { color: var(--err); }
|
|
228
|
+
.lines li.info .k { color: var(--text-3); }
|
|
229
|
+
.lines.result li.change .k { color: var(--ok); }
|
|
230
|
+
.lines.reveal li { animation: rise 420ms var(--ease) both; animation-delay: calc(var(--i) * 70ms); }
|
|
231
|
+
@keyframes rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
232
|
+
|
|
233
|
+
/* Panel */
|
|
234
|
+
.scrim { position: fixed; inset: 0; background: var(--overlay); opacity: 0; pointer-events: none; transition: opacity 200ms; z-index: 20; }
|
|
235
|
+
.scrim.open { opacity: 1; pointer-events: auto; }
|
|
236
|
+
.panel {
|
|
237
|
+
position: fixed; top: 0; right: 0; bottom: 0; z-index: 21;
|
|
238
|
+
width: min(480px, 100vw); display: flex; flex-direction: column;
|
|
239
|
+
background: var(--surface); box-shadow: var(--shadow-panel);
|
|
240
|
+
transform: translateX(104%); transition: transform 320ms var(--ease);
|
|
241
|
+
visibility: hidden;
|
|
242
|
+
}
|
|
243
|
+
.panel.open { transform: none; visibility: visible; }
|
|
244
|
+
.panel-head { display: flex; align-items: flex-start; gap: 12px; padding: 20px 24px 16px; border-bottom: 1px solid var(--border); }
|
|
245
|
+
.panel-head h2 { font-size: 17px; font-weight: 650; }
|
|
246
|
+
.panel-head .meta { margin-top: 2px; }
|
|
247
|
+
.panel-head .btn { margin-left: auto; }
|
|
248
|
+
.panel-body { flex: 1; overflow-y: auto; padding: 8px 24px 24px; }
|
|
249
|
+
.section { padding: 18px 0; border-bottom: 1px solid var(--border); }
|
|
250
|
+
.section:last-child { border-bottom: 0; }
|
|
251
|
+
.section h3 { font-size: 13.5px; font-weight: 600; margin-bottom: 4px; }
|
|
252
|
+
.section > p { color: var(--text-2); font-size: 13px; margin-bottom: 12px; }
|
|
253
|
+
.share-row { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center; padding: 10px 0; }
|
|
254
|
+
.share-row + .share-row { border-top: 1px dashed var(--border); }
|
|
255
|
+
.share-row .name { font-weight: 550; }
|
|
256
|
+
.share-row .help, .section .actions { margin-top: 2px; }
|
|
257
|
+
.help { font-size: 12.5px; color: var(--text-3); }
|
|
258
|
+
.section .actions { margin-top: 12px; }
|
|
259
|
+
.danger-box .muted { font-size: 13px; }
|
|
260
|
+
.spacer { margin-left: auto; }
|
|
261
|
+
.share-row .badges { margin-top: 6px; }
|
|
262
|
+
.seg { display: inline-flex; padding: 2px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-2); }
|
|
263
|
+
.seg button {
|
|
264
|
+
border: 0; background: transparent; color: var(--text-2); font: inherit; font-size: 12.5px; font-weight: 550;
|
|
265
|
+
padding: 4px 10px; border-radius: 5px; cursor: pointer;
|
|
266
|
+
}
|
|
267
|
+
.seg button { transition: background 160ms var(--ease), color 160ms var(--ease), box-shadow 160ms var(--ease); }
|
|
268
|
+
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--shared-fg); box-shadow: var(--shadow-sm); }
|
|
269
|
+
.seg button.target[aria-pressed="true"] { background: var(--primary); color: var(--primary-fg); }
|
|
270
|
+
.now { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: var(--text-3); }
|
|
271
|
+
.seg button:disabled { cursor: default; }
|
|
272
|
+
|
|
273
|
+
.plan {
|
|
274
|
+
margin-top: 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2);
|
|
275
|
+
}
|
|
276
|
+
.plan.appear { animation: rise 260ms var(--ease) both; }
|
|
277
|
+
.panel.entering .panel-body > .section { animation: rise 320ms var(--ease) both; }
|
|
278
|
+
.panel.entering .panel-body > .section:nth-child(2) { animation-delay: 50ms; }
|
|
279
|
+
.panel.entering .panel-body > .section:nth-child(3) { animation-delay: 100ms; }
|
|
280
|
+
#view.enter > * { animation: rise 300ms var(--ease) both; }
|
|
281
|
+
#view.enter > *:nth-child(2) { animation-delay: 40ms; }
|
|
282
|
+
#view.enter > *:nth-child(3) { animation-delay: 80ms; }
|
|
283
|
+
#view.enter .card { animation: rise 340ms var(--ease) both; animation-delay: calc(60ms + var(--i, 0) * 35ms); }
|
|
284
|
+
:root { interpolate-size: allow-keywords; }
|
|
285
|
+
details.check-group::details-content, details.entry::details-content {
|
|
286
|
+
height: 0; overflow: clip; transition: height 260ms var(--ease), content-visibility 260ms allow-discrete;
|
|
287
|
+
}
|
|
288
|
+
details.check-group[open]::details-content, details.entry[open]::details-content { height: auto; }
|
|
289
|
+
.status, .count { transition: color 200ms; }
|
|
290
|
+
.plan-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 600; }
|
|
291
|
+
.plan-head .tag { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--text-3); }
|
|
292
|
+
.plan.done .plan-head { color: var(--ok); }
|
|
293
|
+
.plan.failed .plan-head { color: var(--err); }
|
|
294
|
+
.plan .lines { padding: 8px 12px; }
|
|
295
|
+
.plan-foot {
|
|
296
|
+
position: sticky; bottom: -24px; display: flex; gap: 8px; align-items: center;
|
|
297
|
+
padding: 10px 12px; border-top: 1px solid var(--border); border-radius: 0 0 8px 8px; background: var(--surface);
|
|
298
|
+
}
|
|
299
|
+
.plan-hint { margin-top: 14px; padding: 12px; border: 1px dashed var(--border-strong); border-radius: 8px; color: var(--text-2); font-size: 13px; }
|
|
300
|
+
.plan-foot .note { font-size: 12.5px; color: var(--text-3); margin-right: auto; }
|
|
301
|
+
|
|
302
|
+
.field { display: grid; gap: 6px; margin-bottom: 14px; }
|
|
303
|
+
.field label, .field .label { font-size: 13px; font-weight: 550; }
|
|
304
|
+
.field .help { font-size: 12.5px; color: var(--text-3); }
|
|
305
|
+
.input {
|
|
306
|
+
height: 34px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border-strong);
|
|
307
|
+
background: var(--surface); color: var(--text); font: inherit;
|
|
308
|
+
}
|
|
309
|
+
.input::placeholder { color: var(--text-3); }
|
|
310
|
+
.input:focus-visible { outline: 2px solid var(--focus); outline-offset: 0; border-color: var(--focus); }
|
|
311
|
+
.input[aria-invalid="true"] { border-color: var(--err); }
|
|
312
|
+
.err { color: var(--err-fg); font-size: 12.5px; }
|
|
313
|
+
.choice { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
314
|
+
.choice label {
|
|
315
|
+
display: grid; gap: 2px; font-weight: 400; padding: 10px 12px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer;
|
|
316
|
+
}
|
|
317
|
+
.choice label:has(input:checked) { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
|
318
|
+
.choice input { position: absolute; opacity: 0; }
|
|
319
|
+
.choice label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
320
|
+
.choice b { font-weight: 600; font-size: 13px; }
|
|
321
|
+
.choice span { font-size: 12.5px; color: var(--text-2); }
|
|
322
|
+
.checks { display: grid; gap: 10px; }
|
|
323
|
+
.checks label { display: grid; grid-template-columns: 18px 1fr; gap: 8px; align-items: start; cursor: pointer; }
|
|
324
|
+
.checks input { margin: 3px 0 0; accent-color: var(--primary); }
|
|
325
|
+
.checks b { font-weight: 550; }
|
|
326
|
+
.checks span { display: block; font-size: 12.5px; color: var(--text-2); }
|
|
327
|
+
|
|
328
|
+
.danger-box { border: 1px solid var(--foreign-bd); border-radius: 8px; padding: 14px; display: grid; gap: 10px; }
|
|
329
|
+
.danger-box h3 { color: var(--err-fg); }
|
|
330
|
+
|
|
331
|
+
.toast {
|
|
332
|
+
position: fixed; left: 50%; bottom: 24px; z-index: 30; transform: translate(-50%, 16px); opacity: 0;
|
|
333
|
+
display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 8px;
|
|
334
|
+
background: var(--text); color: var(--bg); font-size: 13px; box-shadow: var(--shadow-toast);
|
|
335
|
+
transition: opacity 200ms, transform 300ms var(--ease); pointer-events: none;
|
|
336
|
+
}
|
|
337
|
+
.toast.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
|
|
338
|
+
.toast a { color: inherit; font-weight: 600; }
|
|
339
|
+
|
|
340
|
+
.banner { display: flex; gap: 10px; align-items: center; padding: 10px 14px; border-radius: 8px; background: var(--err-bg); color: var(--err-fg); margin-bottom: 16px; }
|
|
341
|
+
|
|
342
|
+
@media (max-width: 860px) {
|
|
343
|
+
.app { grid-template-columns: 1fr; }
|
|
344
|
+
.side {
|
|
345
|
+
position: static; height: auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
|
|
346
|
+
gap: 8px; padding: 10px 16px; border-right: 0; border-bottom: 1px solid var(--border);
|
|
347
|
+
}
|
|
348
|
+
.brand { padding: 0; }
|
|
349
|
+
.nav { grid-column: 1 / -1; grid-row: 2; display: grid; grid-template-columns: repeat(4, auto); justify-content: space-between; gap: 2px; min-width: 0; }
|
|
350
|
+
.nav a { gap: 5px; padding: 7px 6px; font-size: 13px; }
|
|
351
|
+
.nav a .count { display: none; }
|
|
352
|
+
.side-foot { grid-column: 2; grid-row: 1; margin: 0; padding: 0 0 0 8px; border-top: none; }
|
|
353
|
+
.side-foot > div:first-child { display: none; }
|
|
354
|
+
.brand .collapse, .who > .fold { display: none; }
|
|
355
|
+
.side-foot { display: flex; align-items: center; gap: 4px; }
|
|
356
|
+
.top, .content { padding-left: 16px; padding-right: 16px; }
|
|
357
|
+
.legend { margin-left: 0; }
|
|
358
|
+
.cards { grid-template-columns: minmax(0, 1fr); }
|
|
359
|
+
.check { grid-template-columns: 20px minmax(0, 1fr); }
|
|
360
|
+
.check > span:nth-child(3) { grid-column: 2; color: var(--text-2); }
|
|
361
|
+
.check > .row-actions { grid-column: 2; justify-content: flex-start; }
|
|
362
|
+
code { overflow-wrap: anywhere; }
|
|
363
|
+
.table.stack thead { display: none; }
|
|
364
|
+
.table.stack tr { display: grid; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
|
365
|
+
.table.stack tr:last-child { border-bottom: 0; }
|
|
366
|
+
.table.stack td { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 8px; padding: 0; border: 0; }
|
|
367
|
+
.table.stack td::before { content: attr(data-label); font-size: 12px; color: var(--text-3); }
|
|
368
|
+
}
|
|
369
|
+
@media (max-width: 520px) {
|
|
370
|
+
#sync-text { display: none; }
|
|
371
|
+
}
|
|
372
|
+
@media (max-width: 380px) {
|
|
373
|
+
.nav a svg.i { display: none; }
|
|
374
|
+
}
|
|
375
|
+
@media (pointer: coarse) {
|
|
376
|
+
.btn { height: 44px; padding: 0 14px; }
|
|
377
|
+
.btn.icon { width: 44px; }
|
|
378
|
+
.seg button { padding: 10px 14px; }
|
|
379
|
+
.theme button { padding: 10px 12px; }
|
|
380
|
+
.nav a { padding-top: 10px; padding-bottom: 10px; }
|
|
381
|
+
}
|
|
382
|
+
@media (prefers-reduced-motion: reduce) {
|
|
383
|
+
/* No movement; state changes still show, as instant swaps and a static ring. */
|
|
384
|
+
.panel, .toast { transition: none; }
|
|
385
|
+
.lines.reveal li { animation: none; }
|
|
386
|
+
.btn .spin { animation-duration: 2.4s; }
|
|
387
|
+
.badge.changed { animation: none; outline: 2px solid var(--primary); outline-offset: 1px; }
|
|
388
|
+
.check-group summary::before { transition: none; }
|
|
389
|
+
.app, .card, .fold, .seg button, .btn { transition: none; }
|
|
390
|
+
.card:hover { transform: none; }
|
|
391
|
+
.plan.appear, .panel.entering .panel-body > .section, #view.enter > *, #view.enter .card { animation: none; }
|
|
392
|
+
details.check-group::details-content, details.entry::details-content { transition: none; }
|
|
393
|
+
}
|
|
394
|
+
</style>
|
|
395
|
+
</head>
|
|
396
|
+
<body>
|
|
397
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
398
|
+
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
|
|
399
|
+
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
|
|
400
|
+
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
|
|
401
|
+
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol>
|
|
402
|
+
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
|
403
|
+
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
|
|
404
|
+
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
|
405
|
+
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
|
406
|
+
<symbol id="i-xcircle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
|
|
407
|
+
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
|
|
408
|
+
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
|
|
409
|
+
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
|
|
410
|
+
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
|
|
411
|
+
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
|
412
|
+
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
|
|
413
|
+
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></symbol>
|
|
414
|
+
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
|
415
|
+
<symbol id="i-loader" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></symbol>
|
|
416
|
+
<symbol id="i-panel" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></symbol>
|
|
417
|
+
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></symbol>
|
|
418
|
+
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></symbol>
|
|
419
|
+
<symbol id="i-sliders" viewBox="0 0 24 24"><path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 8h4"/><path d="M18 16h4"/></symbol>
|
|
420
|
+
</svg>
|
|
421
|
+
|
|
422
|
+
<div class="app">
|
|
423
|
+
<aside class="side" aria-label="Main">
|
|
424
|
+
<div class="brand">
|
|
425
|
+
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="6.5" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.75"/><rect class="mark" x="9.5" y="2.5" width="12" height="12" rx="3"/></svg>
|
|
426
|
+
<span class="fold">cchost <small>v__VERSION__</small></span>
|
|
427
|
+
<button class="btn ghost icon collapse" id="collapse" type="button" aria-label="Collapse sidebar" aria-expanded="true" title="Collapse sidebar"><svg class="i"><use href="#i-panel"/></svg></button>
|
|
428
|
+
</div>
|
|
429
|
+
<nav class="nav">
|
|
430
|
+
<a href="#profiles" data-view="profiles" title="Profiles"><svg class="i"><use href="#i-users"/></svg><span class="fold">Profiles</span><span class="count" id="count-profiles"></span></a>
|
|
431
|
+
<a href="#pool" data-view="pool" title="Shared pool"><svg class="i"><use href="#i-folder"/></svg><span class="fold">Shared pool</span></a>
|
|
432
|
+
<a href="#doctor" data-view="doctor" title="Doctor"><svg class="i"><use href="#i-shield"/></svg><span class="fold">Doctor</span><span class="count" id="count-doctor"></span></a>
|
|
433
|
+
<a href="#activity" data-view="activity" title="Activity"><svg class="i"><use href="#i-history"/></svg><span class="fold">Activity</span><span class="count" id="count-activity"></span></a>
|
|
434
|
+
</nav>
|
|
435
|
+
<div class="side-foot">
|
|
436
|
+
<div class="fold"><div class="label">Shared pool</div><div class="path">~/.claude</div></div>
|
|
437
|
+
<div class="theme fold" role="group" aria-label="Theme">
|
|
438
|
+
<button type="button" data-theme-set="system">System</button>
|
|
439
|
+
<button type="button" data-theme-set="light">Light</button>
|
|
440
|
+
<button type="button" data-theme-set="dark">Dark</button>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="who">
|
|
443
|
+
<span class="fold"><span class="label">Signed in as</span> <b id="who-user">cchost</b><span class="label" id="who-exp"></span></span>
|
|
444
|
+
<button class="btn ghost icon" type="button" data-act="logout" aria-label="Sign out" title="Sign out"><svg class="i"><use href="#i-logout"/></svg></button>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
</aside>
|
|
448
|
+
|
|
449
|
+
<main>
|
|
450
|
+
<header class="top">
|
|
451
|
+
<h1 id="title">Profiles</h1>
|
|
452
|
+
<div class="sync" aria-live="polite"><span id="sync-text">Loading…</span>
|
|
453
|
+
<button class="btn ghost icon" id="refresh" type="button" aria-label="Reload from disk"><svg class="i"><use href="#i-refresh"/></svg></button>
|
|
454
|
+
</div>
|
|
455
|
+
<button class="btn primary" id="top-add" type="button" data-act="add"><svg class="i"><use href="#i-plus"/></svg>Add profile</button>
|
|
456
|
+
</header>
|
|
457
|
+
<div class="content" id="view"></div>
|
|
458
|
+
</main>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<div class="scrim" id="scrim"></div>
|
|
462
|
+
<aside class="panel" id="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title"></aside>
|
|
463
|
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
464
|
+
<div class="sr" id="announce" aria-live="polite"></div>
|
|
465
|
+
|
|
466
|
+
<script nonce="__NONCE__">
|
|
467
|
+
(() => {
|
|
468
|
+
'use strict';
|
|
469
|
+
const $ = (s, el = document) => el.querySelector(s);
|
|
470
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
471
|
+
const icon = (id, cls = '') => `<svg class="i ${cls}" aria-hidden="true"><use href="#i-${id}"/></svg>`;
|
|
472
|
+
|
|
473
|
+
const STATE_TEXT = {
|
|
474
|
+
shared: 'shared with ~/.claude',
|
|
475
|
+
private: 'private to this profile',
|
|
476
|
+
empty: 'private, empty',
|
|
477
|
+
missing: 'missing',
|
|
478
|
+
'foreign-link': 'links outside ~/.claude',
|
|
479
|
+
};
|
|
480
|
+
const STATE_ICON = { shared: 'link', private: 'lock', empty: 'lock', missing: 'minus', 'foreign-link': 'alert' };
|
|
481
|
+
const GROUP_TEXT = { projects: 'Projects', tools: 'Tools', config: 'Config' };
|
|
482
|
+
const GROUP_HELP = {
|
|
483
|
+
projects: 'Session history (projects/)',
|
|
484
|
+
tools: 'skills/, agents/, commands/',
|
|
485
|
+
config: 'mcp.json, settings.json, CLAUDE.md — can run hooks and MCP servers',
|
|
486
|
+
};
|
|
487
|
+
const KIND_ICON = { change: 'arrow', same: 'minus', warn: 'alert', error: 'xcircle', info: 'info' };
|
|
488
|
+
|
|
489
|
+
let data = null;
|
|
490
|
+
let doctor = null;
|
|
491
|
+
let activity = [];
|
|
492
|
+
let lastSync = 0;
|
|
493
|
+
let view = 'profiles';
|
|
494
|
+
let changedBadges = new Set();
|
|
495
|
+
|
|
496
|
+
// --- API ---------------------------------------------------------------
|
|
497
|
+
async function api(path, body) {
|
|
498
|
+
const res = await fetch(path, {
|
|
499
|
+
method: body ? 'POST' : 'GET',
|
|
500
|
+
headers: body ? { 'Content-Type': 'application/json', 'X-CCHost': '1' } : { 'X-CCHost': '1' },
|
|
501
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
502
|
+
});
|
|
503
|
+
// Session expired or signed out elsewhere: reload shows the sign-in page.
|
|
504
|
+
if (res.status === 401) { location.replace('/'); throw new Error('Signed out'); }
|
|
505
|
+
let json = null;
|
|
506
|
+
try { json = await res.json(); } catch { /* non-JSON error */ }
|
|
507
|
+
if (!res.ok) throw new Error((json && json.error) || `Request failed (${res.status})`);
|
|
508
|
+
return json;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function loadState() {
|
|
512
|
+
try {
|
|
513
|
+
const next = await api('/api/state');
|
|
514
|
+
lastSync = Date.now();
|
|
515
|
+
renderSync();
|
|
516
|
+
// Re-render only when the disk actually changed, so polling never
|
|
517
|
+
// steals focus, hover or scroll from what the user is doing.
|
|
518
|
+
const changed = JSON.stringify(next) !== JSON.stringify(data);
|
|
519
|
+
data = next;
|
|
520
|
+
if (!changed) return;
|
|
521
|
+
$('#count-profiles').textContent = data.profiles.length || '';
|
|
522
|
+
if (view === 'profiles' || view === 'pool') render();
|
|
523
|
+
refreshOpenPanel();
|
|
524
|
+
} catch (err) {
|
|
525
|
+
$('#sync-text').textContent = 'Cannot reach cchost ui — is it still running?';
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async function loadDoctor() {
|
|
529
|
+
doctor = null;
|
|
530
|
+
if (view === 'doctor') render();
|
|
531
|
+
try {
|
|
532
|
+
doctor = (await api('/api/doctor')).checks;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
doctor = { error: err.message };
|
|
535
|
+
}
|
|
536
|
+
updateDoctorCount();
|
|
537
|
+
if (view === 'doctor') render();
|
|
538
|
+
}
|
|
539
|
+
async function loadActivity() {
|
|
540
|
+
activity = (await api('/api/activity')).activity;
|
|
541
|
+
$('#count-activity').textContent = activity.length || '';
|
|
542
|
+
if (view === 'activity') render();
|
|
543
|
+
}
|
|
544
|
+
function updateDoctorCount() {
|
|
545
|
+
const el = $('#count-doctor');
|
|
546
|
+
if (!Array.isArray(doctor)) { el.textContent = ''; return; }
|
|
547
|
+
const errs = doctor.filter((c) => c.status === 'error').length;
|
|
548
|
+
const warns = doctor.filter((c) => c.status === 'warn').length;
|
|
549
|
+
el.textContent = errs || warns || '';
|
|
550
|
+
el.className = 'count' + (errs ? ' err' : warns ? ' warn' : '');
|
|
551
|
+
}
|
|
552
|
+
function renderSync() {
|
|
553
|
+
if (!lastSync) return;
|
|
554
|
+
const s = Math.round((Date.now() - lastSync) / 1000);
|
|
555
|
+
$('#sync-text').textContent = s < 5 ? 'Read from disk just now' : `Read from disk ${s}s ago`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- Rendering helpers --------------------------------------------------
|
|
559
|
+
function badge(profile, r) {
|
|
560
|
+
const st = profile.resources[r.id];
|
|
561
|
+
const key = `${profile.name}:${r.id}`;
|
|
562
|
+
return `<span class="badge ${st}${changedBadges.has(key) ? ' changed' : ''}" title="${esc(r.label)}: ${esc(STATE_TEXT[st])}">${icon(STATE_ICON[st])}${esc(r.label)}<span class="sr">, ${esc(STATE_TEXT[st])}</span></span>`;
|
|
563
|
+
}
|
|
564
|
+
function groupsOf(profile) {
|
|
565
|
+
return Object.keys(data.groups).map((g) => {
|
|
566
|
+
const rs = data.resources.filter((r) => r.group === g);
|
|
567
|
+
return `<div class="group"><span>${GROUP_TEXT[g]}</span><div class="badges">${rs.map((r) => badge(profile, r)).join('')}</div></div>`;
|
|
568
|
+
}).join('');
|
|
569
|
+
}
|
|
570
|
+
function metaOf(p) {
|
|
571
|
+
if (p.kind === 'endpoint') return `Custom endpoint · ${esc(p.endpoint)}`;
|
|
572
|
+
return p.email ? `${esc(p.email)} · Subscription` : 'Subscription';
|
|
573
|
+
}
|
|
574
|
+
function loginPill(p) {
|
|
575
|
+
if (p.kind === 'endpoint') return `<span class="pill ep">Endpoint</span>`;
|
|
576
|
+
return p.loggedIn ? `<span class="pill on">Logged in</span>` : `<span class="pill">Not logged in</span>`;
|
|
577
|
+
}
|
|
578
|
+
function linesHtml(lines, result, reveal = result) {
|
|
579
|
+
if (!lines.length) return '<p class="muted">Nothing to do.</p>';
|
|
580
|
+
return `<ul class="lines${result ? ' result' : ''}${reveal ? ' reveal' : ''}">${lines.map((l, i) => {
|
|
581
|
+
const k = result && l.kind === 'change' ? 'check' : KIND_ICON[l.kind] || 'info';
|
|
582
|
+
return `<li class="${esc(l.kind)}" style="--i:${i}"><span class="k">${icon(k)}</span><span class="res">${esc(l.label || '')}</span><span>${esc(l.text)}</span></li>`;
|
|
583
|
+
}).join('')}</ul>`;
|
|
584
|
+
}
|
|
585
|
+
function relTime(iso) {
|
|
586
|
+
const s = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
587
|
+
if (s < 60) return 'just now';
|
|
588
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
589
|
+
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- Views --------------------------------------------------------------
|
|
593
|
+
let renderedView = null;
|
|
594
|
+
function render() {
|
|
595
|
+
const el = $('#view');
|
|
596
|
+
// Entrance motion only when switching views, never on background refresh.
|
|
597
|
+
if (renderedView !== view) {
|
|
598
|
+
el.classList.remove('enter');
|
|
599
|
+
void el.offsetWidth;
|
|
600
|
+
el.classList.add('enter');
|
|
601
|
+
clearTimeout(render.t);
|
|
602
|
+
render.t = setTimeout(() => el.classList.remove('enter'), 700);
|
|
603
|
+
renderedView = view;
|
|
604
|
+
}
|
|
605
|
+
const titles = { profiles: 'Profiles', pool: 'Shared pool', doctor: 'Doctor', activity: 'Activity' };
|
|
606
|
+
$('#title').textContent = titles[view];
|
|
607
|
+
$('#top-add').hidden = view !== 'profiles';
|
|
608
|
+
document.title = `${titles[view]} · cchost`;
|
|
609
|
+
document.querySelectorAll('.nav a').forEach((a) => {
|
|
610
|
+
if (a.dataset.view === view) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
|
611
|
+
});
|
|
612
|
+
if (view === 'profiles') el.innerHTML = viewProfiles();
|
|
613
|
+
if (view === 'pool') el.innerHTML = viewPool();
|
|
614
|
+
if (view === 'doctor') el.innerHTML = viewDoctor();
|
|
615
|
+
if (view === 'activity') el.innerHTML = viewActivity();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function viewProfiles() {
|
|
619
|
+
if (!data) return '<p class="muted">Reading profiles…</p>';
|
|
620
|
+
const head = `<div class="toolbar">
|
|
621
|
+
<p>${data.profiles.length} profile${data.profiles.length === 1 ? '' : 's'} in <code>${esc(data.profilesDir)}</code></p>
|
|
622
|
+
<div class="legend" aria-label="Legend">
|
|
623
|
+
<span><i class="swatch shared"></i>Shared with ~/.claude</span>
|
|
624
|
+
<span><i class="swatch private"></i>Private to the profile</span>
|
|
625
|
+
<span><i class="swatch missing"></i>Missing</span>
|
|
626
|
+
</div>
|
|
627
|
+
</div>`;
|
|
628
|
+
if (!data.profiles.length) {
|
|
629
|
+
return head + `<div class="empty"><h2>No profiles yet</h2>
|
|
630
|
+
<p>A profile is a separate Claude Code login with its own settings. Each one can share skills, settings and history with <code>~/.claude</code>, or keep them private.</p>
|
|
631
|
+
<button class="btn primary" type="button" data-act="add">${icon('plus')}Add your first profile</button></div>`;
|
|
632
|
+
}
|
|
633
|
+
return head + `<div class="cards">${data.profiles.map((p, i) => `
|
|
634
|
+
<article class="card" style="--i:${i}" aria-label="${esc(p.name)}">
|
|
635
|
+
<div class="card-head"><div><h2>${esc(p.name)}</h2><p class="meta">${metaOf(p)}</p></div>${loginPill(p)}</div>
|
|
636
|
+
<div class="groups">${groupsOf(p)}</div>
|
|
637
|
+
<div class="card-actions">
|
|
638
|
+
<button class="btn" type="button" data-act="manage" data-profile="${esc(p.name)}">${icon('sliders')}Manage sharing</button>
|
|
639
|
+
<button class="btn quiet" type="button" data-act="remove" data-profile="${esc(p.name)}">${icon('trash')}Remove</button>
|
|
640
|
+
</div>
|
|
641
|
+
</article>`).join('')}</div>`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function viewPool() {
|
|
645
|
+
if (!data) return '<p class="muted">Reading profiles…</p>';
|
|
646
|
+
const rows = data.resources.map((r) => {
|
|
647
|
+
const by = (st) => data.profiles.filter((p) => p.resources[r.id] === st || (st === 'private' && p.resources[r.id] === 'empty'));
|
|
648
|
+
const chips = (list) => list.length ? `<div class="chips">${list.map((p) => `<span class="chip">${esc(p.name)}</span>`).join('')}</div>` : '<span class="muted">—</span>';
|
|
649
|
+
return `<tr><td data-label="Resource"><code>~/.claude/${esc(r.label)}</code></td><td data-label="Group">${GROUP_TEXT[r.group]}</td><td data-label="Shared by">${chips(by('shared'))}</td><td data-label="Private in">${chips(by('private'))}</td></tr>`;
|
|
650
|
+
}).join('');
|
|
651
|
+
return `<div class="toolbar"><p>Everything in <code>~/.claude</code> is visible to every profile that shares it. Changes made in one sharing profile show up in all of them.</p></div>
|
|
652
|
+
<table class="table stack"><thead><tr><th>Resource</th><th>Group</th><th>Shared by</th><th>Private in</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function viewDoctor() {
|
|
656
|
+
if (!doctor) return '<p class="muted">Running checks…</p>';
|
|
657
|
+
if (doctor.error) return `<div class="banner">${icon('xcircle')}${esc(doctor.error)}</div>`;
|
|
658
|
+
const errs = doctor.filter((c) => c.status === 'error').length;
|
|
659
|
+
const warns = doctor.filter((c) => c.status === 'warn').length;
|
|
660
|
+
const fixable = doctor.filter((c) => c.fixable).length;
|
|
661
|
+
const statusIcon = { ok: 'check', warn: 'alert', error: 'xcircle' };
|
|
662
|
+
const groups = [...new Set(doctor.map((c) => c.group))];
|
|
663
|
+
const summary = errs || warns
|
|
664
|
+
? `<span class="status ${errs ? 'error' : 'warn'}">${icon(errs ? 'xcircle' : 'alert')}${errs} error${errs === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'}</span>`
|
|
665
|
+
: `<span class="status ok">${icon('check')}All ${doctor.length} checks passed</span>`;
|
|
666
|
+
return `<div class="summary">${summary}
|
|
667
|
+
<span class="spacer"></span>
|
|
668
|
+
<button class="btn" type="button" data-act="rerun">${icon('refresh')}Run again</button>
|
|
669
|
+
${fixable ? `<button class="btn primary" type="button" data-act="fix">Fix ${fixable} issue${fixable === 1 ? '' : 's'}…</button>` : ''}
|
|
670
|
+
</div>
|
|
671
|
+
${groups.map((g) => {
|
|
672
|
+
const checks = doctor.filter((c) => c.group === g);
|
|
673
|
+
const issues = checks.filter((c) => c.status !== 'ok').length;
|
|
674
|
+
// Passing groups collapse; groups with issues open with issues first.
|
|
675
|
+
const ordered = [...checks].sort((a, b) => (a.status === 'ok') - (b.status === 'ok'));
|
|
676
|
+
return `<details class="check-group"${issues ? ' open' : ''}><summary><h2>${esc(g)}</h2><span class="status ${issues ? 'warn' : 'ok'}">${issues ? `${issues} to look at` : `${checks.length} passed`}</span></summary><table class="table"><tbody>${ordered.map((c) => `
|
|
677
|
+
<tr><td><div class="check"><span class="status ${esc(c.status)}">${icon(statusIcon[c.status] || 'info')}<span class="sr">${esc(c.status)}</span></span>
|
|
678
|
+
<span>${esc(c.name)}</span>
|
|
679
|
+
<span>${/^[~/]|^[A-Za-z]:\\/.test(c.message) ? `<code>${esc(c.message)}</code>` : esc(c.message)}${c.status !== 'ok' && (c.fix || c.cmd) ? `<span class="hint">${esc(c.fix || '')}${c.cmd ? ` <code>${esc(c.cmd)}</code>` : ''}</span>` : ''}</span>
|
|
680
|
+
<span class="row-actions">${c.status === 'ok' ? '' : [
|
|
681
|
+
c.fixable ? `<button class="btn" type="button" data-act="fix-one" data-name="${esc(c.name)}">${icon('wrench')}Fix</button>` : '',
|
|
682
|
+
c.forceable ? `<button class="btn" type="button" data-act="fix-one" data-name="${esc(c.name)}" data-force="1">${icon('wrench')}Fix…</button>` : '',
|
|
683
|
+
c.cmd ? `<button class="btn ghost" type="button" data-act="copy-cmd" data-cmd="${esc(c.cmd)}">${icon('copy')}Copy command</button>` : '',
|
|
684
|
+
].join('')}</span></div></td></tr>`).join('')}
|
|
685
|
+
</tbody></table></details>`;
|
|
686
|
+
}).join('')}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function viewActivity() {
|
|
690
|
+
if (!activity.length) {
|
|
691
|
+
return `<div class="empty"><h2>No changes yet</h2><p>Every change you apply here is listed with each step it took, so you can see exactly what happened on disk. The list lasts until <code>cchost ui</code> stops.</p></div>`;
|
|
692
|
+
}
|
|
693
|
+
return `<div class="entries">${activity.map((e, i) => `
|
|
694
|
+
<details class="entry"${i === 0 ? ' open' : ''}>
|
|
695
|
+
<summary><span class="status ${e.ok ? 'ok' : 'error'}">${icon(e.ok ? 'check' : 'xcircle')}</span><b>${esc(e.title)}</b><span class="when">${esc(relTime(e.at))}</span></summary>
|
|
696
|
+
${linesHtml(e.lines, true, false)}
|
|
697
|
+
</details>`).join('')}</div>`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// --- Panel --------------------------------------------------------------
|
|
701
|
+
const panel = $('#panel');
|
|
702
|
+
const scrim = $('#scrim');
|
|
703
|
+
let panelState = null; // { type, profile, plan }
|
|
704
|
+
let lastFocus = null;
|
|
705
|
+
|
|
706
|
+
function openPanel(state) {
|
|
707
|
+
lastFocus = document.activeElement;
|
|
708
|
+
panelState = state;
|
|
709
|
+
panel.classList.add('entering');
|
|
710
|
+
renderPanel();
|
|
711
|
+
panel.classList.add('open');
|
|
712
|
+
scrim.classList.add('open');
|
|
713
|
+
clearTimeout(openPanel.t);
|
|
714
|
+
openPanel.t = setTimeout(() => panel.classList.remove('entering'), 600);
|
|
715
|
+
requestAnimationFrame(() => {
|
|
716
|
+
const target = panel.querySelector('[autofocus]') || panel.querySelector('button, input');
|
|
717
|
+
if (target) target.focus();
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function closePanel() {
|
|
721
|
+
panel.classList.remove('open');
|
|
722
|
+
scrim.classList.remove('open');
|
|
723
|
+
panelState = null;
|
|
724
|
+
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
725
|
+
}
|
|
726
|
+
function refreshOpenPanel() {
|
|
727
|
+
if (!panelState || panelState.busy) return;
|
|
728
|
+
if (panelState.profile && !data.profiles.find((p) => p.name === panelState.profile)) {
|
|
729
|
+
if (!(panelState.plan && panelState.plan.done)) closePanel();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!panelState.plan && panelState.type === 'manage') renderPanel();
|
|
733
|
+
}
|
|
734
|
+
function profileByName(n) { return data && data.profiles.find((p) => p.name === n); }
|
|
735
|
+
|
|
736
|
+
function groupState(p, g) {
|
|
737
|
+
const sts = data.resources.filter((r) => r.group === g).map((r) => p.resources[r.id]);
|
|
738
|
+
if (sts.every((s) => s === 'shared')) return 'shared';
|
|
739
|
+
if (sts.every((s) => s !== 'shared')) return 'private';
|
|
740
|
+
return 'mixed';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function planBox() {
|
|
744
|
+
const plan = panelState.plan;
|
|
745
|
+
if (!plan) return '';
|
|
746
|
+
if (plan.loading) return `<div class="plan"><div class="plan-head">${icon('loader', 'spin')}Working out what would change…</div></div>`;
|
|
747
|
+
if (plan.error) return `<div class="plan failed"><div class="plan-head" id="f-err" role="alert">${icon('xcircle')}${esc(plan.error)}</div><div class="plan-foot"><span class="note"></span><button class="btn" type="button" data-act="plan-cancel">Dismiss</button></div></div>`;
|
|
748
|
+
if (plan.done) {
|
|
749
|
+
const ok = plan.entry.ok;
|
|
750
|
+
return `<div class="plan ${ok ? 'done' : 'failed'}" tabindex="-1" id="plan-result">
|
|
751
|
+
<div class="plan-head">${icon(ok ? 'check' : 'xcircle')}${ok ? 'Done' : 'Finished with errors'} — ${esc(plan.title)}<span class="tag">${esc(relTime(plan.entry.at))}</span></div>
|
|
752
|
+
${linesHtml(plan.entry.lines, true)}
|
|
753
|
+
<div class="plan-foot"><span class="note">Also listed in Activity.</span><button class="btn" type="button" data-act="plan-cancel">Close</button></div></div>`;
|
|
754
|
+
}
|
|
755
|
+
const changes = plan.lines.filter((l) => l.kind === 'change').length;
|
|
756
|
+
const blocked = plan.lines.some((l) => l.kind === 'error');
|
|
757
|
+
const confirmName = plan.body.action === 'remove';
|
|
758
|
+
return `<div class="plan" id="plan-preview">
|
|
759
|
+
<div class="plan-head">${icon('info')}Preview — ${esc(plan.title)}<span class="tag">nothing changed yet</span></div>
|
|
760
|
+
${linesHtml(plan.lines, false)}
|
|
761
|
+
<div class="plan-foot">
|
|
762
|
+
<span class="note">${confirmName ? 'This cannot be undone' : changes ? `${changes} change${changes === 1 ? '' : 's'}` : 'No changes needed'}</span>
|
|
763
|
+
<button class="btn" type="button" data-act="plan-cancel">Cancel</button>
|
|
764
|
+
<button class="btn ${confirmName ? 'danger solid' : 'primary'}" type="button" data-act="plan-apply" ${plan.applying || blocked || (!changes && !confirmName) || (confirmName && panelState.confirm !== panelState.profile) ? 'disabled' : ''}>
|
|
765
|
+
${plan.applying ? icon('loader', 'spin') + 'Applying…' : confirmName ? 'Remove profile' : 'Apply'}
|
|
766
|
+
</button>
|
|
767
|
+
</div></div>`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function preview(body, title, section) {
|
|
771
|
+
// Remember what opened the preview so Cancel can return focus there.
|
|
772
|
+
panelState.returnFocus = focusKey(document.activeElement);
|
|
773
|
+
panelState.section = section || null;
|
|
774
|
+
panelState.plan = { loading: true };
|
|
775
|
+
renderPanel();
|
|
776
|
+
try {
|
|
777
|
+
const { lines } = await api('/api/preview', body);
|
|
778
|
+
panelState.plan = { body, title, lines };
|
|
779
|
+
const n = lines.filter((l) => l.kind === 'change').length;
|
|
780
|
+
announce(`Preview ready: ${title}. ${n} change${n === 1 ? '' : 's'}. Nothing has changed yet.`);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
panelState.plan = { error: err.message };
|
|
783
|
+
announce(err.message);
|
|
784
|
+
}
|
|
785
|
+
renderPanel();
|
|
786
|
+
const box = $('#plan-preview') || panel.querySelector('.plan');
|
|
787
|
+
if (box) box.classList.add('appear');
|
|
788
|
+
if (box && panelState && panelState.type !== 'add') {
|
|
789
|
+
box.setAttribute('tabindex', '-1');
|
|
790
|
+
box.focus({ preventScroll: true });
|
|
791
|
+
}
|
|
792
|
+
if (box) box.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function apply() {
|
|
796
|
+
const plan = panelState.plan;
|
|
797
|
+
const body = { ...plan.body };
|
|
798
|
+
if (body.action === 'remove') body.confirm = panelState.confirm;
|
|
799
|
+
plan.applying = true;
|
|
800
|
+
panelState.busy = true;
|
|
801
|
+
renderPanel();
|
|
802
|
+
try {
|
|
803
|
+
const res = await api('/api/apply', body);
|
|
804
|
+
const before = data;
|
|
805
|
+
data = res.state;
|
|
806
|
+
lastSync = Date.now();
|
|
807
|
+
markChanged(before, data);
|
|
808
|
+
panelState.plan = { done: true, title: plan.title, entry: res.entry };
|
|
809
|
+
activity.unshift(res.entry);
|
|
810
|
+
$('#count-activity').textContent = activity.length;
|
|
811
|
+
$('#count-profiles').textContent = data.profiles.length || '';
|
|
812
|
+
toast(res.entry.ok ? `${res.entry.title} — done` : `${res.entry.title} — finished with errors`);
|
|
813
|
+
render();
|
|
814
|
+
loadDoctor();
|
|
815
|
+
} catch (err) {
|
|
816
|
+
panelState.plan = { error: err.message };
|
|
817
|
+
}
|
|
818
|
+
panelState.busy = false;
|
|
819
|
+
renderPanel();
|
|
820
|
+
const r = $('#plan-result');
|
|
821
|
+
if (r) r.focus({ preventScroll: false });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function markChanged(before, after) {
|
|
825
|
+
changedBadges = new Set();
|
|
826
|
+
for (const p of after.profiles) {
|
|
827
|
+
const old = before && before.profiles.find((x) => x.name === p.name);
|
|
828
|
+
for (const r of after.resources) {
|
|
829
|
+
if (!old || old.resources[r.id] !== p.resources[r.id]) changedBadges.add(`${p.name}:${r.id}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
setTimeout(() => { changedBadges = new Set(); }, 1800);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Identify the focused control so a re-render can put focus back on its
|
|
836
|
+
// replacement (WCAG 2.4.3): by id, or by its data-act/group/to/profile.
|
|
837
|
+
function focusKey(el) {
|
|
838
|
+
if (!el || !panel.contains(el)) return null;
|
|
839
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
840
|
+
const d = el.dataset || {};
|
|
841
|
+
if (!d.act) return null;
|
|
842
|
+
return ['act', 'group', 'to', 'profile', 'share'].filter((k) => d[k])
|
|
843
|
+
.map((k) => `[data-${k}="${CSS.escape(d[k])}"]`).join('');
|
|
844
|
+
}
|
|
845
|
+
function renderPanel() {
|
|
846
|
+
if (!panelState) return;
|
|
847
|
+
const active = document.activeElement;
|
|
848
|
+
const key = focusKey(active);
|
|
849
|
+
const caret = active && typeof active.selectionStart === 'number' ? active.selectionStart : null;
|
|
850
|
+
renderPanelInner();
|
|
851
|
+
if (!panel.classList.contains('open')) return;
|
|
852
|
+
let target = key && panel.querySelector(key);
|
|
853
|
+
if (target && target.disabled) target = null;
|
|
854
|
+
if (!target && panelState.plan) target = panel.querySelector('#plan-result, #plan-preview, .plan');
|
|
855
|
+
if (!target && key) target = panel.querySelector('[data-act="close"]');
|
|
856
|
+
if (target) {
|
|
857
|
+
if (!target.hasAttribute('tabindex') && !/^(BUTTON|INPUT|A)$/.test(target.tagName)) target.setAttribute('tabindex', '-1');
|
|
858
|
+
target.focus({ preventScroll: true });
|
|
859
|
+
if (caret !== null) { try { target.setSelectionRange(caret, caret); } catch { /* not text */ } }
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function renderPanelInner() {
|
|
863
|
+
const t = panelState.type;
|
|
864
|
+
if (t === 'manage') panel.innerHTML = panelManage();
|
|
865
|
+
if (t === 'add') {
|
|
866
|
+
panel.innerHTML = panelAdd();
|
|
867
|
+
// The token is set as a property, never written into the markup.
|
|
868
|
+
const tok = document.getElementById('f-token');
|
|
869
|
+
if (tok) tok.value = panelState.form.endpoint.auth_token;
|
|
870
|
+
}
|
|
871
|
+
if (t === 'remove') panel.innerHTML = panelRemove();
|
|
872
|
+
if (t === 'fix') panel.innerHTML = panelFix();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function head(title, sub) {
|
|
876
|
+
return `<div class="panel-head"><div><h2 id="panel-title">${title}</h2>${sub ? `<p class="meta">${sub}</p>` : ''}</div>
|
|
877
|
+
<button class="btn ghost icon" type="button" data-act="close" aria-label="Close panel">${icon('x')}</button></div>`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function panelManage() {
|
|
881
|
+
const p = profileByName(panelState.profile);
|
|
882
|
+
if (!p) return head('Profile not found') + '<div class="panel-body"><p class="muted">It may have been removed from disk.</p></div>';
|
|
883
|
+
const busy = Boolean(panelState.plan && panelState.plan.applying);
|
|
884
|
+
const rows = Object.keys(data.groups).map((g) => {
|
|
885
|
+
const st = groupState(p, g);
|
|
886
|
+
const rs = data.resources.filter((r) => r.group === g);
|
|
887
|
+
// While a preview for this group is open, show the side the user picked.
|
|
888
|
+
const plan = panelState.section === 'share' && panelState.plan;
|
|
889
|
+
const pending = plan && plan.body && !plan.done && plan.body.groups[0] === g
|
|
890
|
+
? (plan.body.action === 'link' ? 'shared' : 'private') : null;
|
|
891
|
+
const shown = pending || st;
|
|
892
|
+
const segBtn = (to, text) => `<button type="button" class="${pending === to ? 'target' : ''}" aria-pressed="${shown === to}" data-act="set-group" data-group="${g}" data-to="${to}" ${busy ? 'disabled' : ''}>${text}</button>`;
|
|
893
|
+
return `<div class="share-row">
|
|
894
|
+
<div><div class="name">${GROUP_TEXT[g]}</div><div class="help">${esc(GROUP_HELP[g])}</div>
|
|
895
|
+
<div class="badges">${rs.map((r) => badge(p, r)).join('')}</div></div>
|
|
896
|
+
<div><div class="seg" role="group" aria-label="${GROUP_TEXT[g]} sharing">${segBtn('shared', 'Shared')}${segBtn('private', 'Private')}</div>
|
|
897
|
+
${pending ? `<span class="now">Now: ${st === 'mixed' ? 'mixed' : st}</span>` : ''}</div></div>`;
|
|
898
|
+
}).join('');
|
|
899
|
+
return head(esc(p.name), metaOf(p)) + `<div class="panel-body">
|
|
900
|
+
<div class="section"><h3>Sharing</h3><p>Shared resources live in <code>~/.claude</code> and every sharing profile sees the same content. Private ones belong to this profile only. Pick a side to preview the change.</p>${rows}
|
|
901
|
+
${panelState.section === 'share' ? planBox() : ''}</div>
|
|
902
|
+
<div class="section"><h3>Copy from ~/.claude</h3><p>Add what <code>~/.claude</code> has into this profile's private resources. Shared resources are skipped — they already see it.</p>
|
|
903
|
+
<div class="checks"><label><input type="checkbox" id="copy-force"><span><b>Overwrite items this profile already has</b><span>Off: existing items are kept and skipped.</span></span></label></div>
|
|
904
|
+
<div class="actions"><button class="btn" type="button" data-act="copy" ${busy ? 'disabled' : ''}>${icon('copy')}Preview copy</button></div>
|
|
905
|
+
${panelState.section === 'copy' ? planBox() : ''}</div>
|
|
906
|
+
<div class="section"><div class="danger-box"><h3>Remove profile</h3><p class="muted">Deletes this profile's login, sessions and private resources. Shared content in <code>~/.claude</code> is kept.</p>
|
|
907
|
+
<div><button class="btn danger" type="button" data-act="remove" data-profile="${esc(p.name)}">${icon('trash')}Remove ${esc(p.name)}…</button></div></div></div>
|
|
908
|
+
</div>`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function panelRemove() {
|
|
912
|
+
const p = profileByName(panelState.profile);
|
|
913
|
+
const name = panelState.profile;
|
|
914
|
+
const done = panelState.plan && panelState.plan.done;
|
|
915
|
+
return head(`Remove ${esc(name)}`, p ? metaOf(p) : '') + `<div class="panel-body">
|
|
916
|
+
<div class="section"><p>This permanently deletes <code>~/.claude-profiles/${esc(name)}</code>: its login, sessions and anything private. Shared content in <code>~/.claude</code> stays for the other profiles.</p>
|
|
917
|
+
${done ? '' : `<div class="field"><label for="confirm-name">Type <b>${esc(name)}</b> to confirm</label>
|
|
918
|
+
<input class="input" id="confirm-name" autocomplete="off" spellcheck="false" value="${esc(panelState.confirm || '')}" autofocus></div>`}
|
|
919
|
+
${planBox()}</div></div>`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function panelAdd() {
|
|
923
|
+
const f = panelState.form;
|
|
924
|
+
const nameErr = Boolean(panelState.plan && panelState.plan.error && /name/i.test(panelState.plan.error));
|
|
925
|
+
const e = f.endpoint;
|
|
926
|
+
return head('Add profile', 'Creates a new Claude Code login under ~/.claude-profiles') + `<div class="panel-body">
|
|
927
|
+
<div class="section">
|
|
928
|
+
<div class="field"><label for="f-name">Name</label>
|
|
929
|
+
<input class="input" id="f-name" value="${esc(f.name)}" placeholder="work" autocomplete="off" spellcheck="false" autofocus required
|
|
930
|
+
aria-describedby="f-name-help${nameErr ? ' f-err' : ''}"${nameErr ? ' aria-invalid="true"' : ''}>
|
|
931
|
+
<span class="help" id="f-name-help">Used as <code>cchost use ${esc(f.name || '<name>')}</code>. No spaces or slashes.</span></div>
|
|
932
|
+
<div class="field"><span class="label">Login</span>
|
|
933
|
+
<div class="choice" role="radiogroup" aria-label="Login type">
|
|
934
|
+
<label><input type="radio" name="f-kind" value="subscription" ${!f.custom ? 'checked' : ''}><b>Anthropic subscription</b><span>Log in with your Claude account on first use.</span></label>
|
|
935
|
+
<label><input type="radio" name="f-kind" value="endpoint" ${f.custom ? 'checked' : ''}><b>Custom endpoint</b><span>Any Anthropic-compatible API with a token.</span></label>
|
|
936
|
+
</div></div>
|
|
937
|
+
${f.custom ? `
|
|
938
|
+
<div class="field"><label for="f-url">Base URL</label><input class="input" id="f-url" value="${esc(e.base_url)}" placeholder="https://api.moonshot.ai/anthropic" autocomplete="off" spellcheck="false"></div>
|
|
939
|
+
<div class="field"><label for="f-token">API token</label><input class="input" id="f-token" type="password" autocomplete="off" spellcheck="false">
|
|
940
|
+
<span class="help">Stored in the profile with user-only permissions. Never shown again here.</span></div>
|
|
941
|
+
<div class="field"><label for="f-model">Model <span class="muted">(optional)</span></label><input class="input" id="f-model" value="${esc(e.model)}" placeholder="kimi-k2.5" autocomplete="off" spellcheck="false"></div>` : ''}
|
|
942
|
+
</div>
|
|
943
|
+
<div class="section"><h3>Sharing</h3><p>Unchecked groups start private and empty — nothing is copied in.</p>
|
|
944
|
+
<div class="checks">${Object.keys(data.groups).map((g) => `
|
|
945
|
+
<label><input type="checkbox" data-share="${g}" ${f.share[g] ? 'checked' : ''}><span><b>Share ${GROUP_TEXT[g].toLowerCase()}</b><span>${esc(GROUP_HELP[g])}</span></span></label>`).join('')}
|
|
946
|
+
</div>
|
|
947
|
+
${panelState.plan ? planBox() : `<p class="plan-hint">${f.custom ? 'Enter a name, base URL and token' : 'Enter a name'} to see exactly what will be created.</p>`}
|
|
948
|
+
</div></div>`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function panelFix() {
|
|
952
|
+
const f = panelState.fix || {};
|
|
953
|
+
const title = f.name ? `Fix ${esc(f.name)}` : 'Fix doctor issues';
|
|
954
|
+
const sub = f.name ? esc(f.description || '') : 'Applies the safe fixes <code>cchost doctor --fix</code> would';
|
|
955
|
+
const note = f.force
|
|
956
|
+
? `<div class="confirm-note">${icon('alert')}<span>This fix changes something cchost won't change on its own (the CLI needs <code>--force</code>). Read the preview, then apply to confirm.</span></div>`
|
|
957
|
+
: '';
|
|
958
|
+
return head(title, sub) + `<div class="panel-body"><div class="section">${note}${planBox()}</div></div>`;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Add form → preview, debounced as the user types.
|
|
962
|
+
let addTimer = null;
|
|
963
|
+
function addBody() {
|
|
964
|
+
const f = panelState.form;
|
|
965
|
+
const body = { action: 'add', name: f.name.trim(), private: Object.keys(f.share).filter((g) => !f.share[g]) };
|
|
966
|
+
if (f.custom) body.endpoint = { base_url: f.endpoint.base_url, auth_token: f.endpoint.auth_token, model: f.endpoint.model };
|
|
967
|
+
return body;
|
|
968
|
+
}
|
|
969
|
+
function schedulePreviewAdd() {
|
|
970
|
+
clearTimeout(addTimer);
|
|
971
|
+
addTimer = setTimeout(() => {
|
|
972
|
+
if (!panelState || panelState.type !== 'add') return;
|
|
973
|
+
const body = addBody();
|
|
974
|
+
const f = panelState.form;
|
|
975
|
+
const incomplete = !body.name || (f.custom && (!f.endpoint.base_url.trim() || !f.endpoint.auth_token.trim()));
|
|
976
|
+
if (incomplete) { panelState.plan = null; renderPanelKeepFocus(); return; }
|
|
977
|
+
api('/api/preview', body).then(({ lines }) => {
|
|
978
|
+
if (!panelState || panelState.type !== 'add') return;
|
|
979
|
+
panelState.plan = { body, title: `Add profile ${body.name}`, lines };
|
|
980
|
+
renderPanelKeepFocus();
|
|
981
|
+
}).catch((err) => {
|
|
982
|
+
if (!panelState || panelState.type !== 'add') return;
|
|
983
|
+
panelState.plan = { error: err.message };
|
|
984
|
+
renderPanelKeepFocus();
|
|
985
|
+
});
|
|
986
|
+
}, 250);
|
|
987
|
+
}
|
|
988
|
+
const renderPanelKeepFocus = () => renderPanel();
|
|
989
|
+
|
|
990
|
+
// --- Events -------------------------------------------------------------
|
|
991
|
+
document.addEventListener('click', (ev) => {
|
|
992
|
+
const t = ev.target.closest('[data-act], [data-theme-set]');
|
|
993
|
+
if (!t) return;
|
|
994
|
+
if (t.dataset.themeSet) { setTheme(t.dataset.themeSet); return; }
|
|
995
|
+
const act = t.dataset.act;
|
|
996
|
+
if (act === 'manage') openPanel({ type: 'manage', profile: t.dataset.profile });
|
|
997
|
+
if (act === 'remove') {
|
|
998
|
+
const name = t.dataset.profile;
|
|
999
|
+
openPanel({ type: 'remove', profile: name, confirm: '' });
|
|
1000
|
+
preview({ action: 'remove', profile: name }, `Remove ${name}`);
|
|
1001
|
+
}
|
|
1002
|
+
if (act === 'add') {
|
|
1003
|
+
openPanel({ type: 'add', form: { name: '', custom: false, endpoint: { base_url: '', auth_token: '', model: '' }, share: { projects: true, tools: true, config: true } } });
|
|
1004
|
+
}
|
|
1005
|
+
if (act === 'close') closePanel();
|
|
1006
|
+
if (act === 'set-group') {
|
|
1007
|
+
const p = profileByName(panelState.profile);
|
|
1008
|
+
const g = t.dataset.group;
|
|
1009
|
+
const to = t.dataset.to;
|
|
1010
|
+
const plan = panelState.plan;
|
|
1011
|
+
const open = panelState.section === 'share' && plan && !plan.done && !plan.applying;
|
|
1012
|
+
if (to === groupState(p, g)) {
|
|
1013
|
+
// Clicking back to the current side cancels the pending change.
|
|
1014
|
+
if (open) {
|
|
1015
|
+
panelState.plan = null;
|
|
1016
|
+
panelState.section = null;
|
|
1017
|
+
renderPanel();
|
|
1018
|
+
announce('Change cancelled. Nothing was changed.');
|
|
1019
|
+
}
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (open && plan.body && plan.body.groups[0] === g && (plan.body.action === 'link' ? 'shared' : 'private') === to) return;
|
|
1023
|
+
preview({ action: to === 'shared' ? 'link' : 'unlink', profile: p.name, groups: [g] },
|
|
1024
|
+
`${to === 'shared' ? 'Share' : 'Make private'}: ${GROUP_TEXT[g].toLowerCase()}`, 'share');
|
|
1025
|
+
}
|
|
1026
|
+
if (act === 'copy') {
|
|
1027
|
+
const force = $('#copy-force').checked;
|
|
1028
|
+
preview({ action: 'copy', profile: panelState.profile, force }, `Copy from ~/.claude${force ? ' (overwrite)' : ''}`, 'copy');
|
|
1029
|
+
}
|
|
1030
|
+
if (act === 'plan-cancel') {
|
|
1031
|
+
const wasRemoveDone = panelState.type === 'remove' && panelState.plan && panelState.plan.done;
|
|
1032
|
+
panelState.plan = null;
|
|
1033
|
+
if (panelState.type === 'remove' || panelState.type === 'fix') { closePanel(); return; }
|
|
1034
|
+
if (wasRemoveDone) { closePanel(); return; }
|
|
1035
|
+
const back = panelState.returnFocus;
|
|
1036
|
+
renderPanel();
|
|
1037
|
+
const el = back && panel.querySelector(back);
|
|
1038
|
+
if (el) el.focus();
|
|
1039
|
+
}
|
|
1040
|
+
if (act === 'plan-apply') apply();
|
|
1041
|
+
if (act === 'rerun') loadDoctor();
|
|
1042
|
+
if (act === 'fix') {
|
|
1043
|
+
openPanel({ type: 'fix' });
|
|
1044
|
+
preview({ action: 'doctor-fix' }, 'Apply safe doctor fixes');
|
|
1045
|
+
}
|
|
1046
|
+
if (act === 'fix-one') {
|
|
1047
|
+
const name = t.dataset.name;
|
|
1048
|
+
const force = t.dataset.force === '1';
|
|
1049
|
+
const check = Array.isArray(doctor) && doctor.find((c) => c.name === name);
|
|
1050
|
+
openPanel({ type: 'fix', fix: { name, force, description: check && check.fix } });
|
|
1051
|
+
preview({ action: 'doctor-fix', name, force }, `Fix ${name}`);
|
|
1052
|
+
}
|
|
1053
|
+
if (act === 'copy-cmd') {
|
|
1054
|
+
const cmd = t.dataset.cmd;
|
|
1055
|
+
const done = () => { toast('Command copied', false); announce('Command copied to the clipboard.'); };
|
|
1056
|
+
if (navigator.clipboard) navigator.clipboard.writeText(cmd).then(done, () => toast(cmd, false));
|
|
1057
|
+
else toast(cmd, false);
|
|
1058
|
+
}
|
|
1059
|
+
if (act === 'logout') {
|
|
1060
|
+
fetch('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CCHost': '1' }, body: '{}' })
|
|
1061
|
+
.finally(() => location.replace('/'));
|
|
1062
|
+
}
|
|
1063
|
+
if (act === 'collapse') toggleCollapse();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
document.addEventListener('input', (ev) => {
|
|
1067
|
+
if (!panelState) return;
|
|
1068
|
+
const el = ev.target;
|
|
1069
|
+
if (panelState.type === 'remove' && el.id === 'confirm-name') {
|
|
1070
|
+
panelState.confirm = el.value;
|
|
1071
|
+
const btn = panel.querySelector('[data-act="plan-apply"]');
|
|
1072
|
+
if (btn && panelState.plan && !panelState.plan.applying) btn.disabled = el.value !== panelState.profile;
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (panelState.type !== 'add') return;
|
|
1076
|
+
const f = panelState.form;
|
|
1077
|
+
if (el.id === 'f-name') f.name = el.value;
|
|
1078
|
+
if (el.id === 'f-url') f.endpoint.base_url = el.value;
|
|
1079
|
+
if (el.id === 'f-token') f.endpoint.auth_token = el.value;
|
|
1080
|
+
if (el.id === 'f-model') f.endpoint.model = el.value;
|
|
1081
|
+
if (el.name === 'f-kind') { f.custom = el.value === 'endpoint'; renderPanelKeepFocus(); }
|
|
1082
|
+
if (el.dataset.share) f.share[el.dataset.share] = el.checked;
|
|
1083
|
+
schedulePreviewAdd();
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
document.addEventListener('keydown', (ev) => {
|
|
1087
|
+
if (ev.key === 'Escape' && panelState && !panelState.busy) closePanel();
|
|
1088
|
+
if (ev.key === 'Tab' && panelState) {
|
|
1089
|
+
const items = [...panel.querySelectorAll('button:not([disabled]), input, [tabindex="0"]')];
|
|
1090
|
+
if (!items.length) return;
|
|
1091
|
+
const first = items[0];
|
|
1092
|
+
const last = items[items.length - 1];
|
|
1093
|
+
if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
|
|
1094
|
+
else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
scrim.addEventListener('click', () => { if (panelState && !panelState.busy) closePanel(); });
|
|
1098
|
+
$('#refresh').addEventListener('click', () => { loadState(); if (view === 'doctor') loadDoctor(); });
|
|
1099
|
+
|
|
1100
|
+
// --- Sidebar ----------------------------------------------------------------
|
|
1101
|
+
function setCollapsed(on) {
|
|
1102
|
+
$('.app').classList.toggle('collapsed', on);
|
|
1103
|
+
const b = $('#collapse');
|
|
1104
|
+
b.setAttribute('aria-expanded', String(!on));
|
|
1105
|
+
b.setAttribute('aria-label', on ? 'Expand sidebar' : 'Collapse sidebar');
|
|
1106
|
+
b.title = on ? 'Expand sidebar' : 'Collapse sidebar';
|
|
1107
|
+
try { localStorage.setItem('cchost-sidebar', on ? 'collapsed' : 'open'); } catch { /* storage blocked */ }
|
|
1108
|
+
}
|
|
1109
|
+
function toggleCollapse() { setCollapsed(!$('.app').classList.contains('collapsed')); }
|
|
1110
|
+
$('#collapse').dataset.act = 'collapse';
|
|
1111
|
+
try { if (localStorage.getItem('cchost-sidebar') === 'collapsed') setCollapsed(true); } catch { /* storage blocked */ }
|
|
1112
|
+
|
|
1113
|
+
async function loadSession() {
|
|
1114
|
+
try {
|
|
1115
|
+
const s = await api('/api/session');
|
|
1116
|
+
$('#who-user').textContent = s.user;
|
|
1117
|
+
const t = new Date(s.expires).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1118
|
+
$('#who-exp').textContent = `Session ends ${t}`;
|
|
1119
|
+
} catch { /* api() already handles sign-out */ }
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// --- Theme ----------------------------------------------------------------
|
|
1123
|
+
function setTheme(mode) {
|
|
1124
|
+
try { localStorage.setItem('cchost-theme', mode); } catch { /* storage blocked */ }
|
|
1125
|
+
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
|
|
1126
|
+
else document.documentElement.setAttribute('data-theme', mode);
|
|
1127
|
+
document.querySelectorAll('[data-theme-set]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themeSet === mode)));
|
|
1128
|
+
}
|
|
1129
|
+
let savedTheme = 'system';
|
|
1130
|
+
try { savedTheme = localStorage.getItem('cchost-theme') || 'system'; } catch { /* storage blocked */ }
|
|
1131
|
+
setTheme(savedTheme);
|
|
1132
|
+
|
|
1133
|
+
function announce(msg) {
|
|
1134
|
+
const el = $('#announce');
|
|
1135
|
+
el.textContent = '';
|
|
1136
|
+
setTimeout(() => { el.textContent = msg; }, 30);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// --- Toast ----------------------------------------------------------------
|
|
1140
|
+
let toastTimer = null;
|
|
1141
|
+
function toast(msg, withLink = true) {
|
|
1142
|
+
const el = $('#toast');
|
|
1143
|
+
el.innerHTML = `${esc(msg)}${withLink ? ' <a href="#activity">View</a>' : ''}`;
|
|
1144
|
+
el.classList.add('show');
|
|
1145
|
+
clearTimeout(toastTimer);
|
|
1146
|
+
toastTimer = setTimeout(() => el.classList.remove('show'), 4000);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// --- Routing & refresh ------------------------------------------------------
|
|
1150
|
+
function route() {
|
|
1151
|
+
const v = (location.hash || '#profiles').slice(1);
|
|
1152
|
+
view = ['profiles', 'pool', 'doctor', 'activity'].includes(v) ? v : 'profiles';
|
|
1153
|
+
render();
|
|
1154
|
+
if (view === 'doctor' && !Array.isArray(doctor)) loadDoctor();
|
|
1155
|
+
if (view === 'activity') loadActivity();
|
|
1156
|
+
}
|
|
1157
|
+
window.addEventListener('hashchange', route);
|
|
1158
|
+
setInterval(() => {
|
|
1159
|
+
renderSync();
|
|
1160
|
+
if (document.visibilityState === 'visible' && !(panelState && panelState.busy) && Date.now() - lastSync > 5000) loadState();
|
|
1161
|
+
}, 1000);
|
|
1162
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadState(); });
|
|
1163
|
+
|
|
1164
|
+
route();
|
|
1165
|
+
loadSession();
|
|
1166
|
+
loadState();
|
|
1167
|
+
loadDoctor();
|
|
1168
|
+
})();
|
|
1169
|
+
</script>
|
|
1170
|
+
</body>
|
|
1171
|
+
</html>
|