@ctrl-spc/cli 1.3.1 → 1.3.3

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.
@@ -0,0 +1,1203 @@
1
+ import { ROLE_SUGGESTIONS } from './roles.js';
2
+ export function renderCompanionUi(token, kind, runtimePort = kind === 'local' ? 4572 : 4571) {
3
+ const safeToken = JSON.stringify(token);
4
+ const roleSuggestions = JSON.stringify(ROLE_SUGGESTIONS);
5
+ const managerLabel = kind === 'local' ? 'Local development' : 'Installed package';
6
+ const managerDescription = kind === 'local'
7
+ ? 'This window controls the build from this project folder.'
8
+ : 'This window controls the globally installed npm package.';
9
+ const managerFooter = `${kind === 'local' ? 'dev' : 'npm'} · port ${runtimePort}`;
10
+ return `<!doctype html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="utf-8" />
14
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
15
+ <meta name="color-scheme" content="light dark" />
16
+ <title>CTRL+SPC Connection Manager</title>
17
+ <style>
18
+ :root {
19
+ color-scheme: light;
20
+ --bg: #f8f8f8;
21
+ --surface: #ffffff;
22
+ --surface-subtle: #f0f0f0;
23
+ --ink: #242424;
24
+ --muted: #686868;
25
+ --faint: #8b8b8b;
26
+ --line: #e2e2e2;
27
+ --line-strong: #b9b9b9;
28
+ --primary: #242424;
29
+ --primary-hover: #444444;
30
+ --primary-soft: rgba(36, 36, 36, .08);
31
+ --on-primary: #ffffff;
32
+ --success: #333333;
33
+ --success-soft: rgba(36, 36, 36, .08);
34
+ --warning: #555555;
35
+ --warning-soft: rgba(36, 36, 36, .08);
36
+ --danger: #242424;
37
+ --shadow: 0 16px 44px rgba(20, 24, 31, .10);
38
+ }
39
+ @media (prefers-color-scheme: dark) {
40
+ :root {
41
+ color-scheme: dark;
42
+ --bg: #282828;
43
+ --surface: #303030;
44
+ --surface-subtle: #383838;
45
+ --ink: #ededed;
46
+ --muted: #b6b6b6;
47
+ --faint: #888888;
48
+ --line: #494949;
49
+ --line-strong: #626262;
50
+ --primary: #ededed;
51
+ --primary-hover: #d8d8d8;
52
+ --primary-soft: rgba(237, 237, 237, .10);
53
+ --on-primary: #202020;
54
+ --success: #d8d8d8;
55
+ --success-soft: rgba(237, 237, 237, .10);
56
+ --warning: #c2c2c2;
57
+ --warning-soft: rgba(237, 237, 237, .10);
58
+ --danger: #ededed;
59
+ --shadow: 0 18px 48px rgba(0, 0, 0, .32);
60
+ }
61
+ }
62
+ * { box-sizing: border-box; }
63
+ body {
64
+ margin: 0;
65
+ min-width: 320px;
66
+ min-height: 100vh;
67
+ background: var(--bg);
68
+ color: var(--ink);
69
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
70
+ font-size: 14px;
71
+ line-height: 1.45;
72
+ }
73
+ button { font: inherit; }
74
+ .app {
75
+ width: min(680px, calc(100% - 32px));
76
+ margin: 42px auto;
77
+ background: var(--surface);
78
+ border: 1px solid var(--line);
79
+ border-radius: 16px;
80
+ box-shadow: var(--shadow);
81
+ overflow: hidden;
82
+ }
83
+ .titlebar {
84
+ min-height: 68px;
85
+ padding: 16px 20px;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ gap: 16px;
90
+ border-bottom: 1px solid var(--line);
91
+ }
92
+ .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
93
+ .logo { display: inline-flex; align-items: center; gap: 4px; user-select: none; }
94
+ .logo-key {
95
+ font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
96
+ font-size: 11px;
97
+ font-weight: 700;
98
+ letter-spacing: .04em;
99
+ padding: 2px 7px 3px;
100
+ border: 1px solid var(--line-strong);
101
+ border-bottom-width: 2.5px;
102
+ border-radius: 5px;
103
+ background: var(--surface);
104
+ color: var(--ink);
105
+ }
106
+ .logo-space { background: var(--primary); color: var(--on-primary); border-color: transparent; border-bottom-color: rgba(0, 0, 0, .25); }
107
+ .logo-plus { color: var(--muted); font-size: 11px; font-weight: 600; }
108
+ .manager-name { color: var(--muted); font-size: 12px; }
109
+ .account {
110
+ flex: none;
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: 8px;
114
+ color: var(--muted);
115
+ font-size: 12px;
116
+ }
117
+ .account-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
118
+ .account.is-signed-in .account-dot { background: var(--success); }
119
+ .content { padding: 22px 20px 20px; }
120
+ .summary {
121
+ display: flex;
122
+ align-items: flex-start;
123
+ justify-content: space-between;
124
+ gap: 20px;
125
+ padding: 4px 2px 22px;
126
+ }
127
+ .summary h1 { margin: 0; font-size: 20px; line-height: 1.25; letter-spacing: -.015em; }
128
+ .summary p { margin: 6px 0 0; color: var(--muted); max-width: 49ch; }
129
+ .button {
130
+ flex: none;
131
+ min-height: 34px;
132
+ padding: 7px 11px;
133
+ border: 1px solid var(--line-strong);
134
+ border-radius: 8px;
135
+ background: var(--surface);
136
+ color: var(--ink);
137
+ cursor: pointer;
138
+ font-weight: 600;
139
+ font-size: 12px;
140
+ }
141
+ .button:hover { background: var(--surface-subtle); }
142
+ .button.primary { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
143
+ .button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
144
+ .button:focus-visible, .switch:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }
145
+ .button:disabled { opacity: .55; cursor: not-allowed; }
146
+ .section-label {
147
+ margin: 0 0 9px 2px;
148
+ color: var(--muted);
149
+ font-weight: 650;
150
+ font-size: 12px;
151
+ }
152
+ .connections { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
153
+ .connection {
154
+ min-height: 102px;
155
+ padding: 17px 16px;
156
+ display: flex;
157
+ align-items: flex-start;
158
+ justify-content: space-between;
159
+ gap: 22px;
160
+ background: var(--surface);
161
+ }
162
+ .connection + .connection { border-top: 1px solid var(--line); }
163
+ .connection.is-ready { background: color-mix(in srgb, var(--success-soft) 48%, var(--surface)); }
164
+ .connection-main { min-width: 0; }
165
+ .connection-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
166
+ .connection-heading strong { font-size: 14px; }
167
+ .tag {
168
+ display: inline-flex;
169
+ align-items: center;
170
+ min-height: 20px;
171
+ padding: 2px 7px;
172
+ border-radius: 999px;
173
+ background: var(--surface-subtle);
174
+ border: 1px solid var(--line);
175
+ color: var(--muted);
176
+ font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
177
+ }
178
+ .state { margin: 7px 0 0; font-size: 13px; font-weight: 600; }
179
+ .state.ready { color: var(--success); }
180
+ .state.conflict { color: var(--warning); }
181
+ .details { margin: 3px 0 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
182
+ .switch {
183
+ position: relative;
184
+ flex: none;
185
+ width: 42px;
186
+ height: 24px;
187
+ margin-top: 2px;
188
+ padding: 0;
189
+ border: 1px solid var(--line-strong);
190
+ border-radius: 999px;
191
+ background: var(--line-strong);
192
+ cursor: pointer;
193
+ transition: background 180ms ease-out, border-color 180ms ease-out;
194
+ }
195
+ .switch::after {
196
+ content: "";
197
+ position: absolute;
198
+ top: 3px;
199
+ left: 3px;
200
+ width: 16px;
201
+ height: 16px;
202
+ border-radius: 50%;
203
+ background: white;
204
+ box-shadow: 0 1px 3px rgba(0,0,0,.2);
205
+ transition: transform 180ms ease-out;
206
+ }
207
+ .switch[aria-checked="true"] { background: var(--success); border-color: var(--success); }
208
+ .switch[aria-checked="true"]::after { transform: translateX(18px); }
209
+ .switch:disabled { opacity: .45; cursor: not-allowed; }
210
+ .notice {
211
+ margin-top: 12px;
212
+ padding: 11px 12px;
213
+ border-radius: 9px;
214
+ background: var(--surface-subtle);
215
+ color: var(--muted);
216
+ font-size: 12px;
217
+ }
218
+ .notice.error { background: var(--warning-soft); color: var(--warning); }
219
+ .projects-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); }
220
+ .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
221
+ .section-heading .section-label { margin: 0; }
222
+ .project-list { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
223
+ .project-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 14px; }
224
+ .project-row + .project-row { border-top: 1px solid var(--line); }
225
+ .project-copy { min-width: 0; }
226
+ .project-copy strong { display: block; font-size: 13px; }
227
+ .project-path { display: block; margin-top: 3px; color: var(--muted); font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
228
+ .project-meta { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; }
229
+ .project-state { flex: none; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
230
+ .project-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
231
+ .project-state.is-available::before { background: var(--success); }
232
+ .project-state.is-warning { color: var(--warning); }
233
+ .project-state.is-warning::before { background: var(--warning); }
234
+ .empty-state { padding: 22px 16px; text-align: center; color: var(--muted); }
235
+ .empty-state strong { display: block; color: var(--ink); font-size: 13px; }
236
+ .empty-state span { display: block; margin-top: 4px; font-size: 12px; }
237
+ .project-dialog {
238
+ width: min(600px, calc(100vw - 32px));
239
+ max-height: calc(100dvh - 32px);
240
+ margin: auto;
241
+ padding: 0;
242
+ border: 1px solid var(--line-strong);
243
+ border-radius: 14px;
244
+ background: var(--surface);
245
+ color: var(--ink);
246
+ box-shadow: var(--shadow);
247
+ overflow: hidden;
248
+ }
249
+ .project-dialog::backdrop { background: rgba(0, 0, 0, .42); }
250
+ .dialog-shell { display: flex; max-height: calc(100dvh - 34px); flex-direction: column; }
251
+ .dialog-header {
252
+ flex: none;
253
+ display: flex;
254
+ align-items: flex-start;
255
+ justify-content: space-between;
256
+ gap: 20px;
257
+ padding: 18px 20px 15px;
258
+ border-bottom: 1px solid var(--line);
259
+ }
260
+ .dialog-header h2 { margin: 0; font-size: 16px; line-height: 1.3; }
261
+ .dialog-header p { max-width: 60ch; margin: 5px 0 0; color: var(--muted); font-size: 12px; text-wrap: pretty; }
262
+ .dialog-close {
263
+ flex: none;
264
+ width: 30px;
265
+ height: 30px;
266
+ padding: 0;
267
+ border: 0;
268
+ border-radius: 7px;
269
+ background: transparent;
270
+ color: var(--muted);
271
+ cursor: pointer;
272
+ font-size: 21px;
273
+ line-height: 1;
274
+ }
275
+ .dialog-close:hover { background: var(--surface-subtle); color: var(--ink); }
276
+ .dialog-close:focus-visible, .text-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 24%, transparent); outline-offset: 2px; }
277
+ .dialog-body { min-height: 0; padding: 14px 20px; overflow: auto; }
278
+ .review[hidden], .manual-path[hidden], .field[hidden], .dialog-activity[hidden], .link-resolution[hidden] { display: none; }
279
+ .picker-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
280
+ .picker-actions[hidden] { display: none; }
281
+ .picker-actions .cancel-action { margin-right: auto; }
282
+ .text-button { padding: 6px 4px; border: 0; background: transparent; color: var(--ink); cursor: pointer; font-weight: 600; font-size: 12px; }
283
+ .text-button:hover { text-decoration: underline; }
284
+ .manual-path { display: flex; gap: 8px; margin-top: 10px; }
285
+ .input, .select {
286
+ width: 100%;
287
+ min-height: 36px;
288
+ padding: 7px 9px;
289
+ border: 1px solid var(--line-strong);
290
+ border-radius: 8px;
291
+ background: var(--surface);
292
+ color: var(--ink);
293
+ font: inherit;
294
+ }
295
+ .input:focus, .select:focus { outline: 3px solid color-mix(in srgb, var(--primary) 24%, transparent); border-color: var(--primary); }
296
+ .manual-path .input { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
297
+ .review h3 { margin: 0; font-size: 13px; }
298
+ .review-summary { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
299
+ .review { margin: 0; }
300
+ .review-path { margin: 5px 0 10px; color: var(--muted); font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
301
+ .repository-list { margin: 0 0 14px; padding: 0; list-style: none; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: var(--surface); }
302
+ .repository-list li { padding: 9px 10px; }
303
+ .repository-list li + li { border-top: 1px solid var(--line); }
304
+ .repository-list strong { display: block; font-size: 12px; }
305
+ .repository-list span { display: block; margin-top: 2px; color: var(--muted); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
306
+ .repository-list small { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
307
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
308
+ .field { min-width: 0; }
309
+ .field label, .field legend { display: block; margin: 0 0 5px; color: var(--muted); font-size: 11px; font-weight: 650; }
310
+ .field fieldset { margin: 0; padding: 0; border: 0; }
311
+ .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
312
+ .project-feedback { margin-top: 10px; color: var(--success); font-size: 12px; }
313
+ .project-feedback.error { color: var(--warning); }
314
+ .dialog-feedback { margin-top: 12px; color: var(--warning); font-size: 12px; }
315
+ .dialog-feedback:empty { display: none; }
316
+ .dialog-activity {
317
+ flex: none;
318
+ padding: 10px 20px;
319
+ border-top: 1px solid var(--line);
320
+ background: var(--surface-subtle);
321
+ color: var(--ink);
322
+ font-size: 12px;
323
+ font-weight: 600;
324
+ }
325
+ .footer {
326
+ margin-top: 18px;
327
+ padding-top: 16px;
328
+ border-top: 1px solid var(--line);
329
+ display: flex;
330
+ align-items: center;
331
+ justify-content: space-between;
332
+ gap: 16px;
333
+ color: var(--muted);
334
+ font-size: 12px;
335
+ }
336
+ .footer code { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
337
+ .activity {
338
+ display: none;
339
+ position: fixed;
340
+ inset: 0;
341
+ background: rgba(0,0,0,.28);
342
+ place-items: center;
343
+ z-index: 10;
344
+ }
345
+ .activity.is-visible { display: grid; }
346
+ .activity span { padding: 10px 13px; border-radius: 9px; background: var(--surface); box-shadow: var(--shadow); font-weight: 600; }
347
+ @media (max-width: 520px) {
348
+ .app { width: 100%; min-height: 100vh; margin: 0; border: 0; border-radius: 0; }
349
+ .titlebar, .content { padding-left: 16px; padding-right: 16px; }
350
+ .summary { display: block; }
351
+ .summary .button { margin-top: 14px; }
352
+ .form-grid { grid-template-columns: 1fr; }
353
+ .project-row { align-items: flex-start; }
354
+ .footer { align-items: flex-start; flex-direction: column; }
355
+ .project-dialog { width: 100%; max-height: 100dvh; border-radius: 0; }
356
+ .dialog-shell { max-height: 100dvh; }
357
+ .dialog-header, .dialog-body { padding-left: 16px; padding-right: 16px; }
358
+ }
359
+ @media (prefers-reduced-motion: reduce) {
360
+ *, *::before, *::after { transition-duration: 0.01ms !important; }
361
+ }
362
+ </style>
363
+ </head>
364
+ <body>
365
+ <main class="app">
366
+ <header class="titlebar">
367
+ <div class="brand">
368
+ <span class="logo" aria-label="CTRL+SPC">
369
+ <span class="logo-key">ctrl</span>
370
+ <span class="logo-plus">+</span>
371
+ <span class="logo-key logo-space">spc</span>
372
+ </span>
373
+ <span class="manager-name">${managerLabel} connection</span>
374
+ </div>
375
+ <div class="account" id="account"><span class="account-dot"></span><span>Checking session…</span></div>
376
+ </header>
377
+
378
+ <section class="content">
379
+ <div class="summary">
380
+ <div>
381
+ <h1 id="summary-title">Checking connections…</h1>
382
+ <p id="summary-copy">Only one connection is used by your coding agents at a time.</p>
383
+ </div>
384
+ <button class="button primary" id="session-button" type="button" disabled>Sign in</button>
385
+ </div>
386
+
387
+ <p class="section-label">Connections</p>
388
+ <div class="connections" id="connections" aria-live="polite"></div>
389
+ <div class="notice" id="notice">${managerDescription}</div>
390
+
391
+ <section class="projects-section" aria-labelledby="projects-label">
392
+ <div class="section-heading">
393
+ <p class="section-label" id="projects-label">Projects on this computer</p>
394
+ <button class="button" id="add-project-button" type="button" disabled>Add project</button>
395
+ </div>
396
+ <div class="project-list" id="project-list" aria-live="polite">
397
+ <div class="empty-state"><strong>Checking projects…</strong></div>
398
+ </div>
399
+ <div class="project-feedback" id="project-feedback" aria-live="polite"></div>
400
+ </section>
401
+
402
+ <dialog class="project-dialog" id="add-project-dialog" aria-labelledby="add-project-title" aria-describedby="add-project-description">
403
+ <div class="dialog-shell">
404
+ <header class="dialog-header">
405
+ <div>
406
+ <h2 id="add-project-title">Add project</h2>
407
+ <p id="add-project-description">Choose one or more project folders. Nothing changes until you confirm.</p>
408
+ </div>
409
+ <button class="dialog-close" id="close-add-project-button" type="button" aria-label="Close Add project dialog">×</button>
410
+ </header>
411
+ <div class="dialog-body">
412
+ <div class="picker-actions" id="picker-actions">
413
+ <button class="button cancel-action" id="cancel-add-button" type="button">Cancel</button>
414
+ <button class="text-button" id="manual-path-button" type="button">Enter a path instead</button>
415
+ <button class="button primary" id="choose-folder-button" type="button">Choose folder</button>
416
+ </div>
417
+ <div class="link-resolution" id="link-resolution" hidden>
418
+ <ul class="repository-list" id="link-folder-list"></ul>
419
+ <div class="form-actions">
420
+ <button class="button" id="choose-unresolved-button" type="button">I copied a folder here</button>
421
+ <button class="button primary" id="fetch-missing-button" type="button">Get missing folders</button>
422
+ </div>
423
+ </div>
424
+ <div class="manual-path" id="manual-path" hidden>
425
+ <input class="input" id="path-input" type="text" autocomplete="off" placeholder="/Users/you/Documents/project" aria-label="Local project folder path" />
426
+ <button class="button" id="scan-path-button" type="button">Inspect</button>
427
+ </div>
428
+
429
+ <div class="review" id="project-review" hidden>
430
+ <h3 id="review-title" tabindex="-1">Folders found</h3>
431
+ <p class="review-summary" id="review-summary"></p>
432
+ <button class="text-button" id="separate-projects-button" type="button" hidden>Create a new project instead</button>
433
+ <p class="review-path" id="review-path"></p>
434
+ <ul class="repository-list" id="repository-list"></ul>
435
+ <button class="text-button" id="add-another-folder-button" type="button">+ Add another folder</button>
436
+ <div class="form-grid">
437
+ <div class="field" id="organization-field">
438
+ <label for="organization-select">Organization</label>
439
+ <select class="select" id="organization-select"></select>
440
+ </div>
441
+ <div class="field" id="project-action-field">
442
+ <label for="existing-project-select">Add as</label>
443
+ <select class="select" id="existing-project-select"></select>
444
+ </div>
445
+ <div class="field" id="name-field">
446
+ <label for="project-name-input">Project name</label>
447
+ <input class="input" id="project-name-input" type="text" autocomplete="off" />
448
+ </div>
449
+ </div>
450
+ <div class="form-actions">
451
+ <button class="button" id="back-to-picker-button" type="button">Choose another folder</button>
452
+ <button class="button primary" id="create-project-button" type="button">Add project</button>
453
+ </div>
454
+ </div>
455
+ <div class="dialog-feedback" id="dialog-feedback" role="alert" tabindex="-1" aria-live="assertive"></div>
456
+ </div>
457
+ <div class="dialog-activity" id="dialog-activity" role="status" aria-live="polite" hidden>Working…</div>
458
+ </div>
459
+ </dialog>
460
+
461
+ <div class="footer">
462
+ <span>Status refreshes automatically.</span>
463
+ <code>${managerFooter}</code>
464
+ </div>
465
+ </section>
466
+ </main>
467
+ <div class="activity" id="activity" role="status"><span id="activity-label">Working…</span></div>
468
+
469
+ <script>
470
+ const TOKEN = ${safeToken};
471
+ const ROLE_SUGGESTIONS = ${roleSuggestions};
472
+ const HANDOFF_PROJECT_ID = new URLSearchParams(window.location.search).get('attachProject');
473
+ const LINK_PROJECT_ID = new URLSearchParams(window.location.search).get('linkProject');
474
+ const MANAGER_DESCRIPTION = ${JSON.stringify(managerDescription)};
475
+ const MANAGER_LABEL = ${JSON.stringify(managerLabel)};
476
+ const connections = document.getElementById('connections');
477
+ const account = document.getElementById('account');
478
+ const sessionButton = document.getElementById('session-button');
479
+ const summaryTitle = document.getElementById('summary-title');
480
+ const summaryCopy = document.getElementById('summary-copy');
481
+ const notice = document.getElementById('notice');
482
+ const activity = document.getElementById('activity');
483
+ const activityLabel = document.getElementById('activity-label');
484
+ const addProjectButton = document.getElementById('add-project-button');
485
+ const addProjectDialog = document.getElementById('add-project-dialog');
486
+ const closeAddProjectButton = document.getElementById('close-add-project-button');
487
+ const addProjectTitle = document.getElementById('add-project-title');
488
+ const addProjectDescription = document.getElementById('add-project-description');
489
+ const dialogActivity = document.getElementById('dialog-activity');
490
+ const dialogFeedback = document.getElementById('dialog-feedback');
491
+ const pickerActions = document.getElementById('picker-actions');
492
+ const chooseFolderButton = document.getElementById('choose-folder-button');
493
+ const linkResolution = document.getElementById('link-resolution');
494
+ const linkFolderList = document.getElementById('link-folder-list');
495
+ const chooseUnresolvedButton = document.getElementById('choose-unresolved-button');
496
+ const fetchMissingButton = document.getElementById('fetch-missing-button');
497
+ const manualPathButton = document.getElementById('manual-path-button');
498
+ const manualPath = document.getElementById('manual-path');
499
+ const pathInput = document.getElementById('path-input');
500
+ const scanPathButton = document.getElementById('scan-path-button');
501
+ const cancelAddButton = document.getElementById('cancel-add-button');
502
+ const backToPickerButton = document.getElementById('back-to-picker-button');
503
+ const projectReview = document.getElementById('project-review');
504
+ const reviewTitle = document.getElementById('review-title');
505
+ const reviewSummary = document.getElementById('review-summary');
506
+ const separateProjectsButton = document.getElementById('separate-projects-button');
507
+ const reviewPath = document.getElementById('review-path');
508
+ const repositoryList = document.getElementById('repository-list');
509
+ const addAnotherFolderButton = document.getElementById('add-another-folder-button');
510
+ const organizationSelect = document.getElementById('organization-select');
511
+ const organizationField = document.getElementById('organization-field');
512
+ const existingProjectSelect = document.getElementById('existing-project-select');
513
+ const projectActionField = document.getElementById('project-action-field');
514
+ const projectNameInput = document.getElementById('project-name-input');
515
+ const nameField = document.getElementById('name-field');
516
+ const createProjectButton = document.getElementById('create-project-button');
517
+ const projectList = document.getElementById('project-list');
518
+ const projectFeedback = document.getElementById('project-feedback');
519
+ let snapshot = null;
520
+ let projectSnapshot = null;
521
+ let selectedDiscovery = null;
522
+ let selectedPaths = [];
523
+ let busy = false;
524
+ let lastError = '';
525
+ let modalOperation = 0;
526
+ let attachmentProjectId = HANDOFF_PROJECT_ID || LINK_PROJECT_ID;
527
+ let linkHandoff = Boolean(LINK_PROJECT_ID);
528
+ let handoffOpened = false;
529
+ let pendingProjectRequest = null;
530
+
531
+ async function api(path, options) {
532
+ let response;
533
+ try {
534
+ response = await fetch(path, {
535
+ ...options,
536
+ headers: { 'x-ctrl-spc-token': TOKEN, ...(options && options.headers ? options.headers : {}) },
537
+ });
538
+ } catch {
539
+ throw new Error('The local connection manager is not running. Run ctrl-spc open, then use the newly opened window.');
540
+ }
541
+ const body = await response.json();
542
+ if (!response.ok) throw new Error(body.error || 'Request failed.');
543
+ return body;
544
+ }
545
+
546
+ function delay(milliseconds) {
547
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
548
+ }
549
+
550
+ function setBusy(value, label) {
551
+ busy = value;
552
+ const dialogBusy = value && addProjectDialog.open;
553
+ activityLabel.textContent = label || 'Working…';
554
+ dialogActivity.textContent = label || 'Working…';
555
+ dialogActivity.hidden = !dialogBusy;
556
+ addProjectDialog.setAttribute('aria-busy', String(dialogBusy));
557
+ activity.classList.toggle('is-visible', value && !addProjectDialog.open);
558
+ document.querySelectorAll('button').forEach((button) => { button.disabled = value || button.dataset.unavailable === 'true'; });
559
+ addProjectDialog.querySelectorAll('input, select').forEach((control) => { control.disabled = value; });
560
+ scanPathButton.disabled = value || !pathInput.value.trim();
561
+ }
562
+
563
+ function showDialogError(error) {
564
+ dialogFeedback.textContent = error instanceof Error ? error.message : String(error);
565
+ dialogFeedback.focus();
566
+ }
567
+
568
+ function escapeHtml(value) {
569
+ return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[char]));
570
+ }
571
+
572
+ function renderProfile(profile, blockedByOther) {
573
+ const ready = profile.state === 'ready';
574
+ const conflict = profile.state === 'conflict';
575
+ const unavailable = profile.state === 'unavailable';
576
+ const version = profile.health && profile.health.version ? profile.health.version : profile.version;
577
+ let stateLabel = ready ? 'On and connected' : conflict ? 'Needs attention' : unavailable ? 'Unavailable' : 'Off';
578
+ let detail = profile.description;
579
+ if (conflict) detail = 'This port is being used by a CTRL+SPC process that cannot identify its source.';
580
+ if (unavailable && profile.unavailableReason) detail = profile.unavailableReason;
581
+ const disabled = unavailable || conflict || blockedByOther;
582
+ return '<article class="connection ' + (ready ? 'is-ready' : '') + '">' +
583
+ '<div class="connection-main">' +
584
+ '<div class="connection-heading"><strong>' + escapeHtml(profile.label) + '</strong>' +
585
+ '<span class="tag">' + escapeHtml(profile.kind === 'npm' ? 'npm' : 'dev') + '</span>' +
586
+ (version ? '<span class="tag">v' + escapeHtml(version) + '</span>' : '') + '</div>' +
587
+ '<p class="state ' + (ready ? 'ready' : conflict ? 'conflict' : '') + '">' + stateLabel + '</p>' +
588
+ '<p class="details">' + escapeHtml(detail) + '</p>' +
589
+ '</div>' +
590
+ '<button class="switch" type="button" role="switch" aria-label="' + (ready ? 'Turn off ' : 'Turn on ') + escapeHtml(profile.label) + '" aria-checked="' + ready + '" data-kind="' + profile.kind + '" data-unavailable="' + disabled + '" ' + (disabled || busy ? 'disabled' : '') + '></button>' +
591
+ '</article>';
592
+ }
593
+
594
+ function render(data) {
595
+ snapshot = data;
596
+ account.classList.toggle('is-signed-in', data.signedIn);
597
+ account.querySelector('span:last-child').textContent = data.signedIn ? 'Signed in' : 'Signed out';
598
+ sessionButton.textContent = data.signedIn ? 'Sign out' : 'Sign in';
599
+ sessionButton.classList.toggle('primary', !data.signedIn);
600
+ sessionButton.disabled = busy;
601
+ addProjectButton.dataset.unavailable = String(!data.signedIn);
602
+ addProjectButton.disabled = busy || !data.signedIn;
603
+
604
+ const active = data.profiles.filter((profile) => profile.state === 'ready');
605
+ const conflicts = data.profiles.filter((profile) => profile.state === 'conflict');
606
+ if (conflicts.length) {
607
+ summaryTitle.textContent = 'A connection needs attention';
608
+ summaryCopy.textContent = 'An older or unlabeled CTRL+SPC process is already using a connection port.';
609
+ } else if (active.length === 1) {
610
+ summaryTitle.textContent = active[0].label + ' is connected';
611
+ summaryCopy.textContent = 'Your coding agents are using this CTRL+SPC connection.';
612
+ } else {
613
+ summaryTitle.textContent = MANAGER_LABEL + ' is off';
614
+ summaryCopy.textContent = !data.signedIn
615
+ ? 'Sign in before starting this connection.'
616
+ : data.otherConnection && data.otherConnection.active
617
+ ? data.otherConnection.label + (data.otherConnection.switchable
618
+ ? ' is currently connected. Turning this on will switch versions.'
619
+ : ' is currently connected but cannot identify itself. Stop it before switching versions.')
620
+ : 'Turn on this connection when you want your coding agents to use it.';
621
+ }
622
+ const blockedByOther = data.otherConnection && data.otherConnection.active && !data.otherConnection.switchable;
623
+ connections.innerHTML = data.profiles.map((profile) => renderProfile(
624
+ profile,
625
+ (!data.signedIn || blockedByOther) && profile.state !== 'ready',
626
+ )).join('');
627
+ connections.querySelectorAll('.switch').forEach((button) => button.addEventListener('click', () => toggleRuntime(button.dataset.kind)));
628
+ notice.className = lastError || conflicts.length ? 'notice error' : 'notice';
629
+ notice.textContent = lastError || (conflicts.length
630
+ ? 'Stop the existing process from its terminal, then refresh this page.'
631
+ : MANAGER_DESCRIPTION);
632
+ }
633
+
634
+ async function refresh(showErrors) {
635
+ try {
636
+ render(await api('/api/status'));
637
+ } catch (error) {
638
+ if (showErrors) {
639
+ lastError = error.message;
640
+ notice.className = 'notice error';
641
+ notice.textContent = lastError;
642
+ }
643
+ }
644
+ }
645
+
646
+ function renderProjects(data) {
647
+ projectSnapshot = data;
648
+ if (!snapshot || !snapshot.signedIn) {
649
+ projectList.innerHTML = '<div class="empty-state"><strong>Sign in to manage projects</strong><span>Your folders stay on this computer.</span></div>';
650
+ return;
651
+ }
652
+ if (!data.projects.length) {
653
+ projectList.innerHTML = '<div class="empty-state"><strong>No projects added yet</strong><span>Choose a folder to connect your first project.</span></div>';
654
+ return;
655
+ }
656
+ projectList.innerHTML = data.projects.map((project) => {
657
+ const empty = project.setupState === 'empty';
658
+ const warning = empty || project.needsRelink || !project.available;
659
+ const state = empty ? 'Choose a folder' : !project.onThisMachine ? 'Not set up here' : project.needsRelink ? 'Reconnect needed' : project.available ? 'Ready' : 'Folder missing';
660
+ return '<article class="project-row">' +
661
+ '<div class="project-copy"><strong>' + escapeHtml(project.name) + '</strong>' +
662
+ (project.workspaceRoot ? '<span class="project-path">' + escapeHtml(project.workspaceRoot) + '</span>' : '') +
663
+ '<span class="project-meta">' + escapeHtml(project.organizationName || 'Personal') + '</span></div>' +
664
+ '<span class="project-state ' + (warning ? 'is-warning' : 'is-available') + '">' + escapeHtml(state) + '</span>' +
665
+ '</article>';
666
+ }).join('');
667
+ }
668
+
669
+ async function loadProjects(showErrors) {
670
+ if (!snapshot || !snapshot.signedIn) {
671
+ renderProjects({ organizations: [], projects: [] });
672
+ return;
673
+ }
674
+ try {
675
+ renderProjects(await api('/api/projects'));
676
+ void maybeOpenAttachHandoff();
677
+ } catch (error) {
678
+ if (showErrors) {
679
+ projectFeedback.className = 'project-feedback error';
680
+ projectFeedback.textContent = error.message;
681
+ }
682
+ }
683
+ }
684
+
685
+ async function maybeOpenAttachHandoff() {
686
+ if (!attachmentProjectId || handoffOpened || !snapshot || !snapshot.signedIn || !projectSnapshot) return;
687
+ handoffOpened = true;
688
+ const project = projectSnapshot.projects.find((candidate) => candidate.id === attachmentProjectId);
689
+ resetAddProject();
690
+ if (!addProjectDialog.open) addProjectDialog.showModal();
691
+ if (!project) {
692
+ showDialogError(new Error('This project is unavailable or archived. Restore it in the web app, then try again.'));
693
+ return;
694
+ }
695
+ if (!linkHandoff && project.setupState !== 'empty') {
696
+ showDialogError(new Error('This project already has folders. Open it in the web app to make changes.'));
697
+ return;
698
+ }
699
+ addProjectTitle.textContent = linkHandoff ? 'Reconnect folders for ' + project.name : 'Add folders to ' + project.name;
700
+ addProjectDescription.textContent = linkHandoff ? 'Checking the folders already on this computer.' : 'Choose the folders this project uses.';
701
+ if (linkHandoff) {
702
+ pickerActions.hidden = true;
703
+ setBusy(true, 'Connecting folders…');
704
+ try {
705
+ const result = await api('/api/projects/link-auto', {
706
+ method: 'POST',
707
+ headers: { 'Content-Type': 'application/json' },
708
+ body: JSON.stringify({ projectId: project.id }),
709
+ });
710
+ const unresolved = (result.folders || []).filter((folder) => folder.status === 'unresolved');
711
+ const summary = /^(\d+) of (\d+) folders ready\.$/.exec(result.message);
712
+ const complete = result.folders ? unresolved.length === 0 : Boolean(summary && summary[1] === summary[2]);
713
+ if (complete) {
714
+ setBusy(false);
715
+ closeAddProject(true);
716
+ projectFeedback.textContent = result.message;
717
+ await loadProjects(false);
718
+ return;
719
+ }
720
+ setBusy(false);
721
+ linkResolution.hidden = false;
722
+ addProjectTitle.textContent = 'Finish connecting folders';
723
+ addProjectDescription.textContent = result.message;
724
+ renderUnresolvedFolders(result);
725
+ return;
726
+ } catch (error) {
727
+ setBusy(false);
728
+ pickerActions.hidden = false;
729
+ addProjectTitle.textContent = 'Choose the project folder';
730
+ addProjectDescription.textContent = 'Choose one folder from this project to continue.';
731
+ showDialogError(error);
732
+ }
733
+ }
734
+ requestAnimationFrame(() => chooseFolderButton.focus());
735
+ }
736
+
737
+ function resetAddProject() {
738
+ modalOperation += 1;
739
+ pickerActions.hidden = false;
740
+ projectReview.hidden = true;
741
+ linkResolution.hidden = true;
742
+ manualPath.hidden = true;
743
+ selectedDiscovery = null;
744
+ selectedPaths = [];
745
+ pendingProjectRequest = null;
746
+ fetchMissingButton.dataset.action = '';
747
+ pathInput.value = '';
748
+ scanPathButton.disabled = true;
749
+ dialogFeedback.textContent = '';
750
+ addProjectTitle.textContent = 'Add project';
751
+ addProjectDescription.textContent = 'Choose one or more project folders. Nothing changes until you confirm.';
752
+ }
753
+
754
+ function closeAddProject(force) {
755
+ if (busy && !force) return;
756
+ if (addProjectDialog.open) addProjectDialog.close();
757
+ }
758
+
759
+ function renderDiscovery(discovery) {
760
+ selectedDiscovery = discovery;
761
+ selectedPaths = discovery.repositories.map((folder) => folder.rootPath);
762
+ const attachmentProject = attachmentProjectId && projectSnapshot
763
+ ? projectSnapshot.projects.find((project) => project.id === attachmentProjectId)
764
+ : null;
765
+ addProjectTitle.textContent = attachmentProject
766
+ ? linkHandoff ? 'Reconnect folders for ' + attachmentProject.name : 'These folders will join ' + attachmentProject.name + '.'
767
+ : 'Review folders';
768
+ addProjectDescription.textContent = 'Everything is ready. Change a label only if needed.';
769
+ separateProjectsButton.hidden = !attachmentProject;
770
+ reviewTitle.textContent = 'We found ' + discovery.repositories.length + ' ' +
771
+ (discovery.repositories.length === 1 ? 'folder' : 'folders');
772
+ reviewSummary.textContent = 'Included folders are ready to add.';
773
+ reviewPath.textContent = discovery.scanRoot;
774
+ repositoryList.innerHTML = discovery.repositories.map((repository) => {
775
+ const currentIsCustom = repository.role && !ROLE_SUGGESTIONS.some((role) => role.slug === repository.role.slug);
776
+ const currentOption = currentIsCustom
777
+ ? '<option value="' + escapeHtml(repository.role.slug) + '" selected>' + escapeHtml(repository.role.label) + '</option>'
778
+ : '';
779
+ const options = '<option value="">Add a label</option>' + currentOption + ROLE_SUGGESTIONS.map((role) =>
780
+ '<option value="' + escapeHtml(role.slug) + '" ' + (repository.role && repository.role.slug === role.slug ? 'selected' : '') + '>' + escapeHtml(role.label) + '</option>'
781
+ ).join('');
782
+ return '<li data-target-id="' + escapeHtml(repository.targetId) + '" data-original-role="' + escapeHtml(repository.role ? repository.role.slug : '') + '" data-original-label="' + escapeHtml(repository.role ? repository.role.label : '') + '" data-original-source="' + escapeHtml(repository.role ? repository.role.source : '') + '">' +
783
+ '<label><input class="folder-include" type="checkbox" checked /> <strong>' + escapeHtml(repository.name) + '</strong></label>' +
784
+ '<span>' + escapeHtml(repository.rootPath) + '</span>' +
785
+ '<select class="select folder-role" aria-label="What is ' + escapeHtml(repository.name) + '?">' + options + '</select>' +
786
+ '<small>' + (repository.role ? 'Suggested' : 'Add a label') + '</small>' +
787
+ (!repository.hasRemote ? '<small>Not shared online yet — fine for this computer</small>' : '') +
788
+ repository.copiesFound.map((path) => '<small>Also found at ' + escapeHtml(path) + '</small>').join('') +
789
+ '</li>';
790
+ }).join('');
791
+ const organizations = projectSnapshot ? projectSnapshot.organizations : [];
792
+ organizationSelect.innerHTML = organizations.map((organization) =>
793
+ '<option value="' + escapeHtml(organization.id) + '">' + escapeHtml(organization.name + (organization.isPersonal ? ' (Personal)' : '')) + '</option>'
794
+ ).join('');
795
+ organizationField.hidden = organizations.length <= 1;
796
+ if (attachmentProject) organizationSelect.value = attachmentProject.orgId;
797
+ updateExistingProjectOptions();
798
+ if (attachmentProject) {
799
+ existingProjectSelect.value = attachmentProject.id;
800
+ if (linkHandoff) existingProjectSelect.selectedOptions[0].dataset.mode = 'link';
801
+ }
802
+ projectNameInput.value = discovery.suggestedName;
803
+ nameField.hidden = false;
804
+ updateProjectAction();
805
+ const offline = snapshot && !snapshot.supabaseReachable;
806
+ createProjectButton.dataset.unavailable = String(Boolean(offline));
807
+ createProjectButton.disabled = busy || Boolean(offline);
808
+ dialogFeedback.textContent = offline ? "You're offline. Connect to the internet to continue." : '';
809
+ projectReview.hidden = false;
810
+ pickerActions.hidden = true;
811
+ manualPath.hidden = true;
812
+ reviewTitle.focus();
813
+ }
814
+
815
+ function updateExistingProjectOptions() {
816
+ const projects = projectSnapshot ? projectSnapshot.projects : [];
817
+ const candidates = projects.filter((project) => (
818
+ project.orgId === organizationSelect.value && (project.id === attachmentProjectId ||
819
+ project.setupState === 'empty' || !project.onThisMachine || !project.available || project.needsRelink
820
+ )
821
+ ));
822
+ existingProjectSelect.innerHTML = '<option value="">Create a new project</option>' + candidates.map((project) =>
823
+ '<option value="' + escapeHtml(project.id) + '" data-mode="' + (project.setupState === 'empty' ? 'attach' : 'link') + '">' +
824
+ (project.setupState === 'empty' ? 'Add folders to ' : 'Reconnect to ') + escapeHtml(project.name) + '</option>'
825
+ ).join('');
826
+ projectActionField.hidden = candidates.length === 0;
827
+ updateProjectAction();
828
+ }
829
+
830
+ function updateProjectAction() {
831
+ const linking = Boolean(existingProjectSelect.value);
832
+ const mode = linking ? existingProjectSelect.selectedOptions[0]?.dataset.mode : null;
833
+ nameField.hidden = linking;
834
+ createProjectButton.textContent = mode === 'attach' ? 'Attach' : linking ? 'Link folder' : 'Add project';
835
+ }
836
+
837
+ organizationSelect.addEventListener('change', updateExistingProjectOptions);
838
+ existingProjectSelect.addEventListener('change', updateProjectAction);
839
+ separateProjectsButton.addEventListener('click', () => {
840
+ attachmentProjectId = null;
841
+ separateProjectsButton.hidden = true;
842
+ addProjectTitle.textContent = 'Review folders';
843
+ updateExistingProjectOptions();
844
+ });
845
+
846
+ async function inspectFolder(path, append) {
847
+ if (busy) return;
848
+ const operation = ++modalOperation;
849
+ setBusy(true, 'Inspecting folders…');
850
+ dialogFeedback.textContent = '';
851
+ try {
852
+ const body = await api('/api/projects/discover', {
853
+ method: 'POST',
854
+ headers: { 'Content-Type': 'application/json' },
855
+ body: JSON.stringify(append ? { paths: [...selectedPaths, path] } : { paths: [path] }),
856
+ });
857
+ if (!addProjectDialog.open || operation !== modalOperation) return;
858
+ renderDiscovery(body.discovery);
859
+ } catch (error) {
860
+ if (addProjectDialog.open && operation === modalOperation) showDialogError(error);
861
+ } finally {
862
+ if (operation === modalOperation) setBusy(false);
863
+ }
864
+ }
865
+
866
+ addProjectButton.addEventListener('click', () => {
867
+ projectFeedback.textContent = '';
868
+ attachmentProjectId = null;
869
+ resetAddProject();
870
+ if (!addProjectDialog.open) addProjectDialog.showModal();
871
+ requestAnimationFrame(() => chooseFolderButton.focus());
872
+ });
873
+
874
+ addProjectDialog.addEventListener('close', () => {
875
+ resetAddProject();
876
+ attachmentProjectId = null;
877
+ addProjectButton.focus();
878
+ });
879
+ addProjectDialog.addEventListener('cancel', (event) => {
880
+ if (busy) event.preventDefault();
881
+ });
882
+ addProjectDialog.addEventListener('click', (event) => {
883
+ if (event.target === addProjectDialog && !busy && !selectedDiscovery) closeAddProject();
884
+ });
885
+ closeAddProjectButton.addEventListener('click', () => closeAddProject());
886
+ cancelAddButton.addEventListener('click', () => closeAddProject());
887
+ backToPickerButton.addEventListener('click', () => {
888
+ selectedDiscovery = null;
889
+ selectedPaths = [];
890
+ projectReview.hidden = true;
891
+ pickerActions.hidden = false;
892
+ addProjectTitle.textContent = 'Add project';
893
+ addProjectDescription.textContent = 'Choose one or more project folders. Nothing changes until you confirm.';
894
+ dialogFeedback.textContent = '';
895
+ chooseFolderButton.focus();
896
+ });
897
+ manualPathButton.addEventListener('click', () => {
898
+ manualPath.hidden = false;
899
+ scanPathButton.disabled = !pathInput.value.trim();
900
+ pathInput.focus();
901
+ });
902
+ pathInput.addEventListener('input', () => { scanPathButton.disabled = busy || !pathInput.value.trim(); });
903
+ scanPathButton.addEventListener('click', () => inspectFolder(pathInput.value, false));
904
+ pathInput.addEventListener('keydown', (event) => {
905
+ if (event.key === 'Enter' && !event.isComposing) {
906
+ event.preventDefault();
907
+ inspectFolder(pathInput.value, false);
908
+ }
909
+ });
910
+
911
+ chooseFolderButton.addEventListener('click', async () => {
912
+ if (busy) return;
913
+ const operation = ++modalOperation;
914
+ let cancelled = false;
915
+ setBusy(true, 'Waiting for Finder…');
916
+ dialogFeedback.textContent = '';
917
+ try {
918
+ const body = await api('/api/folders/choose', { method: 'POST' });
919
+ if (!addProjectDialog.open || operation !== modalOperation) return;
920
+ cancelled = body.cancelled;
921
+ if (!cancelled) renderDiscovery(body.discovery);
922
+ } catch (error) {
923
+ if (addProjectDialog.open && operation === modalOperation) showDialogError(error);
924
+ } finally {
925
+ if (operation === modalOperation) {
926
+ setBusy(false);
927
+ if (cancelled && addProjectDialog.open) chooseFolderButton.focus();
928
+ }
929
+ }
930
+ });
931
+
932
+ addAnotherFolderButton.addEventListener('click', async () => {
933
+ if (busy) return;
934
+ const operation = ++modalOperation;
935
+ setBusy(true, 'Waiting for a folder…');
936
+ try {
937
+ const body = await api('/api/folders/choose', { method: 'POST' });
938
+ if (!body.cancelled && addProjectDialog.open && operation === modalOperation) {
939
+ const addedPaths = body.discovery.repositories.map((folder) => folder.rootPath);
940
+ const merged = await api('/api/projects/discover', {
941
+ method: 'POST',
942
+ headers: { 'Content-Type': 'application/json' },
943
+ body: JSON.stringify({ paths: [...selectedPaths, ...addedPaths] }),
944
+ });
945
+ renderDiscovery(merged.discovery);
946
+ }
947
+ } catch (error) {
948
+ if (addProjectDialog.open && operation === modalOperation) showDialogError(error);
949
+ } finally {
950
+ if (operation === modalOperation) setBusy(false);
951
+ }
952
+ });
953
+
954
+ createProjectButton.addEventListener('click', async () => {
955
+ if (!selectedDiscovery || busy) return;
956
+ const operation = ++modalOperation;
957
+ const existingProjectMode = existingProjectSelect.value
958
+ ? existingProjectSelect.selectedOptions[0]?.dataset.mode
959
+ : undefined;
960
+ setBusy(true, existingProjectMode === 'attach' ? 'Adding folders…' : 'Adding project…');
961
+ dialogFeedback.textContent = '';
962
+ try {
963
+ const includedTargetIds = Array.from(repositoryList.querySelectorAll('li')).filter((row) =>
964
+ row.querySelector('.folder-include')?.checked
965
+ ).map((row) => row.dataset.targetId);
966
+ const roles = Object.fromEntries(Array.from(repositoryList.querySelectorAll('li')).map((row) => {
967
+ const select = row.querySelector('.folder-role');
968
+ const slug = select && select.value;
969
+ const suggestion = ROLE_SUGGESTIONS.find((role) => role.slug === slug);
970
+ if (!slug) return [row.dataset.targetId, null];
971
+ const unchanged = slug === row.dataset.originalRole;
972
+ return [row.dataset.targetId, {
973
+ slug,
974
+ label: suggestion ? suggestion.label : row.dataset.originalLabel,
975
+ source: unchanged && row.dataset.originalSource === 'detected' ? 'detected' : 'user',
976
+ }];
977
+ }));
978
+ pendingProjectRequest = {
979
+ path: selectedDiscovery.selectedPath,
980
+ paths: selectedPaths,
981
+ orgId: organizationSelect.value,
982
+ name: projectNameInput.value,
983
+ existingProjectId: existingProjectSelect.value || undefined,
984
+ existingProjectMode,
985
+ includedTargetIds,
986
+ roles,
987
+ };
988
+ const result = await api('/api/projects/create', {
989
+ method: 'POST',
990
+ headers: { 'Content-Type': 'application/json' },
991
+ body: JSON.stringify(pendingProjectRequest),
992
+ });
993
+ if (!addProjectDialog.open || operation !== modalOperation) return;
994
+ if (result.promotionConflict) {
995
+ setBusy(false);
996
+ projectReview.hidden = true;
997
+ pickerActions.hidden = true;
998
+ linkResolution.hidden = false;
999
+ addProjectTitle.textContent = 'This folder is already online';
1000
+ addProjectDescription.textContent = 'Choose whether to use the online folder or pick a different one.';
1001
+ linkFolderList.innerHTML = '<li><strong>Use the folder already online</strong><small>Use the online folder for this setup.</small></li>' +
1002
+ '<li><strong>Choose a different folder</strong><small>Keep this selection unchanged.</small></li>';
1003
+ chooseUnresolvedButton.textContent = 'Choose a different folder';
1004
+ chooseUnresolvedButton.hidden = false;
1005
+ fetchMissingButton.textContent = 'Use folder already online';
1006
+ fetchMissingButton.dataset.action = 'promotion';
1007
+ fetchMissingButton.hidden = false;
1008
+ fetchMissingButton.focus();
1009
+ return;
1010
+ }
1011
+ const partial = existingProjectMode === 'link' && /^(\d+) of (\d+) folders ready\.$/.exec(result.message);
1012
+ if (partial && Number(partial[1]) < Number(partial[2])) {
1013
+ setBusy(false);
1014
+ selectedDiscovery = null;
1015
+ selectedPaths = [];
1016
+ projectReview.hidden = true;
1017
+ pickerActions.hidden = true;
1018
+ linkResolution.hidden = false;
1019
+ addProjectTitle.textContent = 'Finish connecting folders';
1020
+ addProjectDescription.textContent = result.message;
1021
+ renderUnresolvedFolders(result);
1022
+ return;
1023
+ }
1024
+ setBusy(false);
1025
+ closeAddProject(true);
1026
+ projectFeedback.className = 'project-feedback';
1027
+ projectFeedback.textContent = result.message;
1028
+ await loadProjects(false);
1029
+ } catch (error) {
1030
+ if (addProjectDialog.open && operation === modalOperation) showDialogError(error);
1031
+ } finally {
1032
+ if (operation === modalOperation) setBusy(false);
1033
+ }
1034
+ });
1035
+
1036
+ function renderUnresolvedFolders(result) {
1037
+ const unresolved = (result.folders || []).filter((folder) => folder.status === 'unresolved');
1038
+ linkFolderList.innerHTML = unresolved.map((folder) => '<li><strong>Which folder is the ' + escapeHtml(folder.label) + '?</strong><small>' +
1039
+ (folder.action === 'fetch' ? 'Choose one destination to get every missing folder.' : folder.sourceKind === 'remote' ? 'Choose its folder on this computer.' : 'Copy this folder from the other computer first.') + '</small>' +
1040
+ '<div class="form-actions">' + (folder.action === 'fetch' ? '' : '<button class="button" type="button" data-link-repository="' + escapeHtml(folder.repositoryId) + '">' +
1041
+ (folder.sourceKind === 'local' ? 'I copied the folder here' : 'Choose folder') + '</button>') +
1042
+ (folder.sourceKind === 'local' ? '<button class="text-button" type="button" data-delegate-folder="' + escapeHtml(folder.label) + '">Get it online</button>' : '') +
1043
+ '</div></li>').join('');
1044
+ chooseUnresolvedButton.hidden = true;
1045
+ fetchMissingButton.textContent = 'Get missing folders';
1046
+ fetchMissingButton.dataset.action = 'fetch';
1047
+ fetchMissingButton.hidden = !unresolved.some((folder) => folder.action === 'fetch');
1048
+ (fetchMissingButton.hidden ? linkFolderList.querySelector('button') : fetchMissingButton)?.focus();
1049
+ }
1050
+
1051
+ linkFolderList.addEventListener('click', async (event) => {
1052
+ const linkButton = event.target.closest('[data-link-repository]');
1053
+ if (linkButton) {
1054
+ setBusy(true, 'Waiting for a folder…');
1055
+ try {
1056
+ const choice = await api('/api/folders/choose-path', { method: 'POST' });
1057
+ if (choice.cancelled) return;
1058
+ const result = await api('/api/projects/link-folder', {
1059
+ method: 'POST',
1060
+ headers: { 'Content-Type': 'application/json' },
1061
+ body: JSON.stringify({
1062
+ projectId: attachmentProjectId,
1063
+ repositoryId: linkButton.dataset.linkRepository,
1064
+ path: choice.path,
1065
+ }),
1066
+ });
1067
+ const unresolved = (result.folders || []).filter((folder) => folder.status === 'unresolved');
1068
+ if (unresolved.length) {
1069
+ addProjectDescription.textContent = result.message;
1070
+ renderUnresolvedFolders(result);
1071
+ } else {
1072
+ closeAddProject(true);
1073
+ projectFeedback.textContent = result.message;
1074
+ await loadProjects(false);
1075
+ }
1076
+ } catch (error) {
1077
+ showDialogError(error);
1078
+ } finally {
1079
+ setBusy(false);
1080
+ }
1081
+ return;
1082
+ }
1083
+ const delegateButton = event.target.closest('[data-delegate-folder]');
1084
+ if (!delegateButton) return;
1085
+ const message = 'Please put "' + delegateButton.dataset.delegateFolder + '" online so it can be used on another computer. For an engineer: add a Git origin and push the current branch.';
1086
+ try {
1087
+ await navigator.clipboard.writeText(message);
1088
+ dialogFeedback.textContent = 'Message copied. Send it to someone who manages source code.';
1089
+ } catch {
1090
+ dialogFeedback.textContent = 'Copy this message: ' + message;
1091
+ }
1092
+ });
1093
+
1094
+ chooseUnresolvedButton.addEventListener('click', () => {
1095
+ linkResolution.hidden = true;
1096
+ pickerActions.hidden = false;
1097
+ addProjectTitle.textContent = 'Choose the next missing folder';
1098
+ addProjectDescription.textContent = 'Choose the folder named above.';
1099
+ chooseFolderButton.focus();
1100
+ });
1101
+
1102
+ fetchMissingButton.addEventListener('click', async () => {
1103
+ if (busy) return;
1104
+ if (fetchMissingButton.dataset.action === 'promotion') {
1105
+ if (!pendingProjectRequest) return;
1106
+ setBusy(true, 'Using the online folder…');
1107
+ try {
1108
+ const result = await api('/api/projects/create', {
1109
+ method: 'POST',
1110
+ headers: { 'Content-Type': 'application/json' },
1111
+ body: JSON.stringify({ ...pendingProjectRequest, promotionChoice: 'use_online' }),
1112
+ });
1113
+ closeAddProject(true);
1114
+ projectFeedback.textContent = result.message;
1115
+ await loadProjects(false);
1116
+ } catch (error) {
1117
+ showDialogError(error);
1118
+ } finally {
1119
+ setBusy(false);
1120
+ }
1121
+ return;
1122
+ }
1123
+ setBusy(true, 'Waiting for a destination folder…');
1124
+ try {
1125
+ const choice = await api('/api/folders/choose-path', { method: 'POST' });
1126
+ if (choice.cancelled) return;
1127
+ const result = await api('/api/projects/fetch-missing', {
1128
+ method: 'POST',
1129
+ headers: { 'Content-Type': 'application/json' },
1130
+ body: JSON.stringify({ projectId: attachmentProjectId, destination: choice.path }),
1131
+ });
1132
+ closeAddProject(true);
1133
+ projectFeedback.textContent = result.message;
1134
+ await loadProjects(false);
1135
+ } catch (error) {
1136
+ showDialogError(error);
1137
+ } finally {
1138
+ setBusy(false);
1139
+ }
1140
+ });
1141
+
1142
+ async function toggleRuntime(kind) {
1143
+ const profile = snapshot.profiles.find((candidate) => candidate.kind === kind);
1144
+ const action = profile.state === 'ready' ? 'stop' : 'start';
1145
+ lastError = '';
1146
+ setBusy(true, action === 'start' ? 'Switching connection…' : 'Turning connection off…');
1147
+ try {
1148
+ await api('/api/runtime/' + kind + '/' + action, { method: 'POST' });
1149
+ await refresh(true);
1150
+ } catch (error) {
1151
+ lastError = error.message;
1152
+ notice.className = 'notice error';
1153
+ notice.textContent = lastError;
1154
+ } finally {
1155
+ setBusy(false);
1156
+ await refresh(false);
1157
+ }
1158
+ }
1159
+
1160
+ async function runSessionActionAndWait(path, expectedSignedIn) {
1161
+ let requestFinished = false;
1162
+ let requestError = null;
1163
+ void api(path, { method: 'POST' })
1164
+ .catch((error) => { requestError = error; })
1165
+ .finally(() => { requestFinished = true; });
1166
+
1167
+ const deadline = Date.now() + (5 * 60 * 1000);
1168
+ while (Date.now() < deadline) {
1169
+ await delay(600);
1170
+ const data = await api('/api/status');
1171
+ if (data.signedIn === expectedSignedIn) {
1172
+ render(data);
1173
+ return;
1174
+ }
1175
+ if (requestFinished && requestError) throw requestError;
1176
+ }
1177
+ throw new Error('Sign in timed out. Try again.');
1178
+ }
1179
+
1180
+ sessionButton.addEventListener('click', async () => {
1181
+ const signingOut = snapshot && snapshot.signedIn;
1182
+ lastError = '';
1183
+ setBusy(true, signingOut ? 'Signing out…' : 'Waiting for sign in…');
1184
+ try {
1185
+ await runSessionActionAndWait(signingOut ? '/api/logout' : '/api/login', !signingOut);
1186
+ } catch (error) {
1187
+ lastError = error.message;
1188
+ notice.className = 'notice error';
1189
+ notice.textContent = lastError;
1190
+ } finally {
1191
+ setBusy(false);
1192
+ await refresh(true);
1193
+ await loadProjects(true);
1194
+ }
1195
+ });
1196
+
1197
+ refresh(true).then(() => loadProjects(true));
1198
+ setInterval(() => { if (!busy) refresh(false); }, 2500);
1199
+ setInterval(() => { if (!busy) loadProjects(false); }, 15000);
1200
+ </script>
1201
+ </body>
1202
+ </html>`;
1203
+ }