@ctrl-spc/cs 0.1.0 → 0.2.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.
@@ -0,0 +1,856 @@
1
+ import { WEB_APP_URL } from './env.js';
2
+ /**
3
+ * The Companion single-page app: sign in, sign out, projects list, map a
4
+ * project's per-machine folder + git remote. Styled to match the web app
5
+ * (tokens + component CSS copied from web/src/styles). Fully self-contained —
6
+ * no external fonts/scripts — so it satisfies the served page's strict CSP
7
+ * (default-src 'none'; connect-src 'self').
8
+ *
9
+ * User-controlled text (project names, remotes, paths) is written with DOM
10
+ * textContent, never innerHTML, so a hostile project name can't inject markup.
11
+ */
12
+ export function renderCompanionUi(token) {
13
+ return `<!doctype html>
14
+ <html lang="en" data-theme="system">
15
+ <head>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <title>CTRL+SPC — Companion</title>
19
+ <style>
20
+ :root {
21
+ --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
22
+ --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
23
+ --text-xs: 0.6875rem; --text-sm: 0.8125rem; --text-md: 0.875rem;
24
+ --text-lg: 1rem; --text-xl: 1.1875rem; --text-2xl: 1.4375rem;
25
+ --sp-1: 0.25rem; --sp-2: 0.5rem; --sp-3: 0.75rem; --sp-4: 1rem;
26
+ --sp-5: 1.5rem; --sp-6: 2rem; --sp-7: 3rem;
27
+ --r-sm: 5px; --r-md: 8px; --r-lg: 12px;
28
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1); --t-fast: 150ms; --t-med: 220ms;
29
+ --ctl-h: 2rem; --titlebar-h: 46px;
30
+ --z-overlay-backdrop: 300; --z-overlay: 310;
31
+ }
32
+ :root, :root[data-theme="light"] {
33
+ color-scheme: light;
34
+ --bg: oklch(1 0 0); --surface: oklch(0.972 0 0); --surface-2: oklch(0.942 0 0);
35
+ --border: oklch(0.898 0 0); --border-strong: oklch(0.78 0 0);
36
+ --ink: oklch(0.225 0 0); --muted: oklch(0.465 0 0); --faint: oklch(0.60 0 0);
37
+ --primary: oklch(0.225 0 0); --primary-hover: oklch(0.34 0 0);
38
+ --primary-soft: oklch(0.225 0 0 / 0.08); --on-primary: oklch(1 0 0);
39
+ --danger: oklch(0.20 0 0); --danger-soft: oklch(0.20 0 0 / 0.08);
40
+ --ok: oklch(0.30 0 0);
41
+ --card-shadow: 0 1px 2px oklch(0 0 0 / 0.06), 0 1px 1px oklch(0 0 0 / 0.04);
42
+ --overlay-shadow: 0 12px 40px oklch(0 0 0 / 0.18), 0 2px 8px oklch(0 0 0 / 0.10);
43
+ --backdrop: oklch(0.2 0 0 / 0.35);
44
+ }
45
+ :root[data-theme="dark"] {
46
+ color-scheme: dark;
47
+ --bg: oklch(0.245 0 0); --surface: oklch(0.285 0 0); --surface-2: oklch(0.33 0 0);
48
+ --border: oklch(0.38 0 0); --border-strong: oklch(0.47 0 0);
49
+ --ink: oklch(0.93 0 0); --muted: oklch(0.72 0 0); --faint: oklch(0.58 0 0);
50
+ --primary: oklch(0.93 0 0); --primary-hover: oklch(0.85 0 0);
51
+ --primary-soft: oklch(0.93 0 0 / 0.10); --on-primary: oklch(0.20 0 0);
52
+ --danger: oklch(0.90 0 0); --danger-soft: oklch(0.90 0 0 / 0.12);
53
+ --ok: oklch(0.85 0 0);
54
+ --card-shadow: 0 1px 2px oklch(0 0 0 / 0.30), 0 1px 1px oklch(0 0 0 / 0.20);
55
+ --overlay-shadow: 0 12px 40px oklch(0 0 0 / 0.5), 0 2px 8px oklch(0 0 0 / 0.3);
56
+ --backdrop: oklch(0.10 0 0 / 0.55);
57
+ }
58
+ @media (prefers-color-scheme: dark) {
59
+ :root[data-theme="system"] {
60
+ color-scheme: dark;
61
+ --bg: oklch(0.245 0 0); --surface: oklch(0.285 0 0); --surface-2: oklch(0.33 0 0);
62
+ --border: oklch(0.38 0 0); --border-strong: oklch(0.47 0 0);
63
+ --ink: oklch(0.93 0 0); --muted: oklch(0.72 0 0); --faint: oklch(0.58 0 0);
64
+ --primary: oklch(0.93 0 0); --primary-hover: oklch(0.85 0 0);
65
+ --primary-soft: oklch(0.93 0 0 / 0.10); --on-primary: oklch(0.20 0 0);
66
+ --danger: oklch(0.90 0 0); --danger-soft: oklch(0.90 0 0 / 0.12); --ok: oklch(0.85 0 0);
67
+ --card-shadow: 0 1px 2px oklch(0 0 0 / 0.30), 0 1px 1px oklch(0 0 0 / 0.20);
68
+ --overlay-shadow: 0 12px 40px oklch(0 0 0 / 0.5), 0 2px 8px oklch(0 0 0 / 0.3);
69
+ --backdrop: oklch(0.10 0 0 / 0.55);
70
+ }
71
+ }
72
+ *, *::before, *::after { box-sizing: border-box; }
73
+ html, body { height: 100%; }
74
+ body {
75
+ margin: 0; font-family: var(--font-ui); font-size: var(--text-md);
76
+ line-height: 1.45; color: var(--ink); background: var(--bg);
77
+ -webkit-font-smoothing: antialiased;
78
+ }
79
+ h1, h2, h3 { margin: 0; font-weight: 600; text-wrap: balance; }
80
+ p { margin: 0; }
81
+ button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
82
+ input { font: inherit; color: inherit; }
83
+ a { color: var(--primary); text-decoration: none; }
84
+ a:hover { text-decoration: underline; }
85
+ code, .mono { font-family: var(--font-mono); }
86
+ .muted { color: var(--muted); }
87
+ :focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; border-radius: var(--r-sm); }
88
+ ::placeholder { color: var(--muted); opacity: 1; }
89
+ [hidden] { display: none !important; }
90
+
91
+ .btn {
92
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
93
+ height: var(--ctl-h); padding: 0 12px; border-radius: var(--r-md);
94
+ font-size: var(--text-sm); font-weight: 500;
95
+ border: 1px solid var(--border); background: var(--surface);
96
+ transition: background var(--t-fast) var(--ease-out), border-color var(--t-fast) var(--ease-out);
97
+ white-space: nowrap;
98
+ }
99
+ .btn:hover { background: var(--surface-2); }
100
+ .btn:disabled { opacity: 0.5; cursor: default; }
101
+ .btn-primary { background: var(--primary); border-color: transparent; color: var(--on-primary); }
102
+ .btn-primary:hover { background: var(--primary-hover); }
103
+ .btn-primary:disabled:hover { background: var(--primary); }
104
+ .btn-ghost { border-color: transparent; background: transparent; }
105
+ .btn-ghost:hover { background: var(--surface-2); }
106
+
107
+ .input {
108
+ width: 100%; height: var(--ctl-h); padding: 0 10px; border-radius: var(--r-md);
109
+ border: 1px solid var(--border); background: var(--bg); font-size: var(--text-md);
110
+ transition: border-color var(--t-fast) var(--ease-out);
111
+ }
112
+ .input:hover { border-color: var(--border-strong); }
113
+ .input:focus { outline: none; border-color: var(--primary); }
114
+ .field { display: flex; flex-direction: column; gap: var(--sp-1); }
115
+ .field > label { font-size: var(--text-sm); font-weight: 500; color: var(--muted); }
116
+ .form-error { color: var(--danger); font-size: var(--text-sm); min-height: 1.1em; }
117
+
118
+ .logo { display: inline-flex; align-items: center; gap: 4px; user-select: none; }
119
+ .logo-key {
120
+ font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.04em;
121
+ padding: 2px 7px 3px; border: 1px solid var(--border-strong); border-bottom-width: 2.5px;
122
+ border-radius: 5px; background: var(--surface); color: var(--ink);
123
+ }
124
+ .logo-space { background: var(--primary); color: var(--on-primary); border-color: transparent; border-bottom-color: oklch(0 0 0 / 0.25); }
125
+ .logo-plus { color: var(--muted); font-size: var(--text-xs); font-weight: 600; }
126
+
127
+ /* --- sign in --- */
128
+ .auth { min-height: 100%; display: grid; place-items: center; background: var(--bg); padding: var(--sp-5); }
129
+ .auth-card {
130
+ width: min(400px, 100%); display: flex; flex-direction: column; gap: var(--sp-3);
131
+ padding: var(--sp-6); border: 1px solid var(--border); border-radius: var(--r-lg);
132
+ background: var(--surface); box-shadow: var(--card-shadow);
133
+ }
134
+ .auth-title { font-size: var(--text-2xl); margin-top: var(--sp-2); }
135
+ .auth-sub { color: var(--muted); font-size: var(--text-sm); line-height: 1.55; }
136
+ .auth-form { display: flex; flex-direction: column; gap: var(--sp-3); margin-top: var(--sp-2); }
137
+ .auth-submit { justify-content: center; padding: 8px 12px; font-size: var(--text-md); }
138
+ .auth-switch { align-self: flex-start; color: var(--primary); font-size: var(--text-sm); font-weight: 500; }
139
+
140
+ /* --- home --- */
141
+ .titlebar {
142
+ display: flex; align-items: center; gap: var(--sp-4); height: var(--titlebar-h);
143
+ padding: 0 var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface);
144
+ }
145
+ .home { max-width: 720px; margin: 0 auto; padding: var(--sp-6) var(--sp-4) var(--sp-7); }
146
+ .home-title { font-size: var(--text-xl); }
147
+ .home-sub { font-size: var(--text-sm); margin-top: var(--sp-1); }
148
+ .projects { display: flex; flex-direction: column; gap: var(--sp-2); margin-top: var(--sp-5); }
149
+ .empty { color: var(--muted); font-size: var(--text-sm); padding: var(--sp-6) 0; text-align: center; }
150
+
151
+ .project {
152
+ border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface);
153
+ box-shadow: var(--card-shadow); overflow: hidden;
154
+ }
155
+ .project-head { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--sp-4); }
156
+ .project-meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
157
+ .project-name { font-weight: 600; overflow-wrap: anywhere; }
158
+ .project-remote { font-size: var(--text-xs); color: var(--muted); overflow-wrap: anywhere; }
159
+ .project-remote.is-local { font-style: italic; }
160
+ .project-status { font-size: var(--text-xs); color: var(--muted); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
+ .project-status.is-mapped { color: var(--ok); }
162
+ .project-actions { display: flex; align-items: center; gap: var(--sp-3); flex-shrink: 0; }
163
+
164
+ /* --- shared dialog (copied from web Dialog: fixed header + body + footer) --- */
165
+ .overlay-backdrop {
166
+ position: fixed; inset: 0; background: var(--backdrop);
167
+ z-index: var(--z-overlay-backdrop); display: flex; align-items: center; justify-content: center;
168
+ animation: overlay-fade var(--t-fast) var(--ease-out);
169
+ }
170
+ .overlay-center {
171
+ margin: auto; width: min(560px, 94vw); max-height: 86vh;
172
+ background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-lg);
173
+ box-shadow: var(--overlay-shadow); z-index: var(--z-overlay);
174
+ display: flex; flex-direction: column; animation: overlay-rise var(--t-med) var(--ease-out);
175
+ }
176
+ .overlay-center.is-compact { width: min(420px, calc(100vw - 32px)); }
177
+ .dialog { display: flex; flex: 1; min-height: 0; flex-direction: column; }
178
+ .dialog-header {
179
+ display: flex; flex: none; align-items: flex-start; justify-content: space-between;
180
+ gap: var(--sp-4); padding: var(--sp-4) var(--sp-5);
181
+ border-bottom: 1px solid var(--border); background: var(--surface);
182
+ border-radius: var(--r-lg) var(--r-lg) 0 0;
183
+ }
184
+ .dialog-heading { display: flex; min-width: 0; flex-direction: column; gap: var(--sp-1); }
185
+ .dialog-heading h2 { margin: 0; font-size: var(--text-lg); overflow-wrap: anywhere; }
186
+ .dialog-body { flex: 1; min-height: 0; overflow-y: auto; padding: var(--sp-5); }
187
+ .dialog-body > * + * { margin-top: var(--sp-4); }
188
+ .dialog-footer {
189
+ display: flex; flex: none; align-items: center; justify-content: flex-end;
190
+ gap: var(--sp-2); padding: var(--sp-3) var(--sp-5);
191
+ border-top: 1px solid var(--border); background: var(--surface);
192
+ border-radius: 0 0 var(--r-lg) var(--r-lg);
193
+ }
194
+ .dialog-close {
195
+ display: flex; flex: none; align-items: center; justify-content: center;
196
+ width: 32px; height: 32px; border: 0; border-radius: var(--r-sm);
197
+ background: transparent; color: var(--muted); cursor: pointer;
198
+ }
199
+ .dialog-close:hover, .dialog-close:focus-visible { color: var(--ink); background: var(--surface-2); }
200
+ @keyframes overlay-fade { from { opacity: 0; } to { opacity: 1; } }
201
+ @keyframes overlay-rise { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: none; } }
202
+
203
+ .folder-choose { display: flex; align-items: center; gap: var(--sp-2); }
204
+ .folder-path { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--ink); }
205
+ .folder-path.is-empty { color: var(--faint); }
206
+ .folder-path.is-warn { color: var(--danger); }
207
+
208
+ /* --- codebases (Phase 1 add + Phase 2 per-machine availability) --- */
209
+ .project-codebases { padding: var(--sp-3) var(--sp-4) var(--sp-4); border-top: 1px solid var(--border); }
210
+ .project-codebases-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-bottom: var(--sp-2); }
211
+ .project-codebases-title { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
212
+ .codebases { display: flex; flex-direction: column; gap: var(--sp-2); }
213
+ .codebase { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
214
+ .codebase-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
215
+ .codebase-name { font-weight: 600; overflow-wrap: anywhere; }
216
+ .codebase-remote { font-size: var(--text-xs); color: var(--muted); overflow-wrap: anywhere; }
217
+ /* The local folder for a located codebase — shown ONLY in this local companion
218
+ UI (localhost), never sent to the cloud. */
219
+ .codebase-path { font-size: var(--text-xs); color: var(--ink); overflow-wrap: anywhere; }
220
+ .codebase-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
221
+ .codebases-empty { font-size: var(--text-sm); color: var(--faint); }
222
+ .form-note { color: var(--ok); font-size: var(--text-sm); min-height: 1.1em; }
223
+ .pill {
224
+ display: inline-flex; align-items: center; flex-shrink: 0; white-space: nowrap;
225
+ font-size: var(--text-xs); font-weight: 500; padding: 2px 10px; border-radius: 20px;
226
+ border: 1px solid var(--border); color: var(--muted);
227
+ }
228
+ .pill-ok { color: var(--ok); border-color: var(--ok); }
229
+ .locate-match { min-height: 1.2em; }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <div id="app"></div>
234
+ <script>
235
+ const TOKEN = ${JSON.stringify(token)};
236
+ const WEB_URL = ${JSON.stringify(WEB_APP_URL)};
237
+ const app = document.getElementById('app');
238
+
239
+ function keycap(text, space) {
240
+ const s = document.createElement('span');
241
+ s.className = 'logo-key' + (space ? ' logo-space' : '');
242
+ s.textContent = text;
243
+ return s;
244
+ }
245
+ function logo() {
246
+ const l = document.createElement('span');
247
+ l.className = 'logo';
248
+ l.setAttribute('aria-label', 'CTRL+SPC');
249
+ const plus = document.createElement('span');
250
+ plus.className = 'logo-plus';
251
+ plus.textContent = '+';
252
+ l.append(keycap('ctrl'), plus, keycap('spc', true));
253
+ return l;
254
+ }
255
+ function el(tag, cls, text) {
256
+ const n = document.createElement(tag);
257
+ if (cls) n.className = cls;
258
+ if (text != null) n.textContent = text;
259
+ return n;
260
+ }
261
+
262
+ async function api(path, opts) {
263
+ opts = opts || {};
264
+ const headers = Object.assign({ 'x-ctrl-spc-token': TOKEN }, opts.headers || {});
265
+ if (opts.body) headers['Content-Type'] = 'application/json';
266
+ const res = await fetch(path, { method: opts.method || 'GET', headers: headers, body: opts.body, cache: 'no-store' });
267
+ const data = await res.json().catch(function () { return {}; });
268
+ return { ok: res.ok, status: res.status, data: data };
269
+ }
270
+
271
+ async function boot() {
272
+ const r = await api('/api/session');
273
+ if (r.data && r.data.signedIn) renderHome(r.data);
274
+ else renderSignIn(r.data || {});
275
+ }
276
+
277
+ function renderSignIn(session) {
278
+ app.replaceChildren();
279
+ const wrap = el('div', 'auth');
280
+ const card = el('div', 'auth-card');
281
+ card.append(logo());
282
+ card.append(el('h1', 'auth-title', 'Sign in'));
283
+ card.append(el('p', 'auth-sub', 'The board your agents report to. Sign in to link this computer to your account.'));
284
+
285
+ const form = el('form', 'auth-form');
286
+ const emailField = el('div', 'field');
287
+ const emailLabel = el('label', null, 'Email'); emailLabel.htmlFor = 'email';
288
+ const email = el('input', 'input'); email.id = 'email'; email.type = 'email'; email.required = true;
289
+ email.autocomplete = 'email'; email.placeholder = 'you@example.com';
290
+ emailField.append(emailLabel, email);
291
+
292
+ const pwField = el('div', 'field');
293
+ const pwLabel = el('label', null, 'Password'); pwLabel.htmlFor = 'password';
294
+ const pw = el('input', 'input'); pw.id = 'password'; pw.type = 'password'; pw.required = true;
295
+ pw.autocomplete = 'current-password'; pw.placeholder = '\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022';
296
+ pwField.append(pwLabel, pw);
297
+
298
+ const error = el('div', 'form-error');
299
+ const submit = el('button', 'btn btn-primary auth-submit', 'Sign in'); submit.type = 'submit';
300
+ form.append(emailField, pwField, error, submit);
301
+
302
+ form.addEventListener('submit', async function (e) {
303
+ e.preventDefault();
304
+ error.textContent = '';
305
+ submit.disabled = true; submit.textContent = 'Signing in';
306
+ const r = await api('/api/login', { method: 'POST', body: JSON.stringify({ email: email.value, password: pw.value }) });
307
+ if (r.ok && r.data.ok) { boot(); return; }
308
+ error.textContent = (r.data && r.data.error) || 'Sign-in failed.';
309
+ submit.disabled = false; submit.textContent = 'Sign in';
310
+ });
311
+
312
+ const signup = el('a', 'auth-switch', 'Sign up on the web \\u2197');
313
+ signup.href = WEB_URL; signup.target = '_blank'; signup.rel = 'noopener noreferrer';
314
+
315
+ card.append(form, signup);
316
+ wrap.append(card);
317
+ app.append(wrap);
318
+ }
319
+
320
+ function renderHome(session) {
321
+ app.replaceChildren();
322
+ const bar = el('header', 'titlebar');
323
+ bar.append(logo());
324
+ const spacer = el('span', null); spacer.style.flex = '1'; bar.append(spacer);
325
+ bar.append(el('span', 'mono muted', session.email || ''));
326
+ const out = el('button', 'btn btn-ghost', 'Sign out');
327
+ out.addEventListener('click', async function () {
328
+ out.disabled = true;
329
+ await api('/api/logout', { method: 'POST' });
330
+ boot();
331
+ });
332
+ bar.append(out);
333
+
334
+ const main = el('main', 'home');
335
+ main.append(el('h1', 'home-title', 'Projects'));
336
+ const sub = el('p', 'home-sub muted');
337
+ sub.append(document.createTextNode('Map each project to its folder on '));
338
+ sub.append(el('strong', null, session.machineName || 'this computer'));
339
+ sub.append(document.createTextNode('.'));
340
+ main.append(sub);
341
+ const list = el('div', 'projects'); list.id = 'projects';
342
+ main.append(list);
343
+
344
+ app.append(bar, main);
345
+ loadProjects(list);
346
+ }
347
+
348
+ async function loadProjects(list) {
349
+ list.replaceChildren(el('p', 'empty', 'Loading projects\\u2026'));
350
+ const r = await api('/api/projects');
351
+ if (!r.ok) { list.replaceChildren(el('p', 'empty', (r.data && r.data.error) || 'Could not load projects.')); return; }
352
+ const projects = (r.data && r.data.projects) || [];
353
+ if (projects.length === 0) { list.replaceChildren(el('p', 'empty', 'No projects yet. Create one in the web app.')); return; }
354
+ list.replaceChildren.apply(list, projects.map(function (p) { return projectRow(p, list); }));
355
+ }
356
+
357
+ function xIcon() {
358
+ const NS = 'http://www.w3.org/2000/svg';
359
+ const svg = document.createElementNS(NS, 'svg');
360
+ svg.setAttribute('width', '14'); svg.setAttribute('height', '14');
361
+ svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('fill', 'none');
362
+ svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.5');
363
+ svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round');
364
+ svg.setAttribute('aria-hidden', 'true');
365
+ const path = document.createElementNS(NS, 'path');
366
+ path.setAttribute('d', 'M4 4l8 8M12 4l-8 8');
367
+ svg.append(path);
368
+ return svg;
369
+ }
370
+
371
+ function projectRow(p, list) {
372
+ const row = el('div', 'project');
373
+ const head = el('div', 'project-head');
374
+ const meta = el('div', 'project-meta');
375
+ meta.append(el('strong', 'project-name', p.name));
376
+ const hasRemote = !!p.gitRemoteUrl && p.gitRemoteUrl.indexOf('local:v1:') !== 0;
377
+ meta.append(el('code', 'project-remote' + (hasRemote ? '' : ' is-local'), hasRemote ? p.gitRemoteUrl : 'No Git remote'));
378
+ head.append(meta);
379
+
380
+ const actions = el('div', 'project-actions');
381
+ const status = el('span', 'project-status' + (p.localPath ? ' is-mapped' : ''), p.localPath ? p.localPath : 'Not mapped');
382
+ const mapBtn = el('button', 'btn', p.localPath ? 'Edit' : 'Map');
383
+ mapBtn.addEventListener('click', function () { openMapDialog(p, list, mapBtn); });
384
+ actions.append(status, mapBtn);
385
+ head.append(actions);
386
+ row.append(head);
387
+
388
+ // --- codebases on this project (Phase 1) ---
389
+ const section = el('div', 'project-codebases');
390
+ const secHead = el('div', 'project-codebases-head');
391
+ secHead.append(el('span', 'project-codebases-title', 'Codebases'));
392
+ const cbList = el('div', 'codebases');
393
+ const addBtn = el('button', 'btn', 'Add codebase');
394
+ addBtn.addEventListener('click', function () {
395
+ openAddCodebaseDialog(p, function () { loadCodebases(p, cbList); }, addBtn);
396
+ });
397
+ secHead.append(addBtn);
398
+ section.append(secHead, cbList);
399
+ row.append(section);
400
+
401
+ loadCodebases(p, cbList);
402
+ return row;
403
+ }
404
+
405
+ async function loadCodebases(p, container) {
406
+ container.replaceChildren(el('p', 'codebases-empty', 'Loading\\u2026'));
407
+ const r = await api('/api/projects/codebases?projectId=' + encodeURIComponent(p.id));
408
+ if (!r.ok) {
409
+ container.replaceChildren(el('p', 'codebases-empty', (r.data && r.data.error) || 'Could not load codebases.'));
410
+ return;
411
+ }
412
+ const codebases = (r.data && r.data.codebases) || [];
413
+ if (codebases.length === 0) {
414
+ container.replaceChildren(el('p', 'codebases-empty', 'No codebases yet. Add one from a folder that has a git remote.'));
415
+ return;
416
+ }
417
+ container.replaceChildren.apply(container, codebases.map(function (c) { return codebaseRow(c, p, container); }));
418
+ }
419
+
420
+ /** Screen 7: a codebase row shows whether THIS computer has it. Located → the
421
+ * local folder (shown only in this local UI, never sent to the cloud) + an
422
+ * "On this computer" pill. Not located → the git remote + a "Locate…"
423
+ * action that opens the locate dialog for this codebase. Screen 14: every row
424
+ * also carries a "Remove" control (parity with the web) alongside that. */
425
+ function codebaseRow(c, p, container) {
426
+ const row = el('div', 'codebase');
427
+ const meta = el('div', 'codebase-meta');
428
+ meta.append(el('strong', 'codebase-name', c.name));
429
+ if (c.localPath) {
430
+ // Local path — companion-only. Displaying it here never uploads it.
431
+ meta.append(el('code', 'codebase-path mono', c.localPath));
432
+ } else {
433
+ meta.append(el('code', 'codebase-remote mono', c.gitRemoteUrl));
434
+ }
435
+ row.append(meta);
436
+
437
+ const actions = el('div', 'codebase-actions');
438
+ if (c.localPath) {
439
+ actions.append(el('span', 'pill pill-ok', 'On this computer'));
440
+ } else {
441
+ const locateBtn = el('button', 'btn', 'Locate\\u2026');
442
+ locateBtn.addEventListener('click', function () {
443
+ openLocateDialog(p, c, function () { loadCodebases(p, container); }, locateBtn);
444
+ });
445
+ actions.append(locateBtn);
446
+ }
447
+ const removeBtn = el('button', 'btn', 'Remove');
448
+ removeBtn.addEventListener('click', function () {
449
+ openRemoveCodebaseDialog(p, c, function () {
450
+ loadCodebases(p, container);
451
+ // The refresh destroys this row's Remove button, so send focus to a
452
+ // SURVIVING control — the section's "Add codebase" button — instead of a
453
+ // detached node (which would drop focus to <body>).
454
+ const section = container.closest('.project-codebases');
455
+ const addBtn = section && section.querySelector('.project-codebases-head button');
456
+ if (addBtn && addBtn.focus) addBtn.focus();
457
+ }, removeBtn);
458
+ });
459
+ actions.append(removeBtn);
460
+ row.append(actions);
461
+ return row;
462
+ }
463
+
464
+ /** Screen 14: confirm removing a codebase from a project. Removal is org-wide —
465
+ * it deletes the shared codebase row for everyone in the org — but it does NOT
466
+ * un-locate this computer: the local folder mapping stays, so a re-add shows it
467
+ * located again. Mirrors the shared dialog shell (openLocateDialog). */
468
+ function openRemoveCodebaseDialog(p, codebase, onRemoved, returnFocus) {
469
+ const backdrop = el('div', 'overlay-backdrop');
470
+ const panel = el('div', 'overlay-center is-compact');
471
+ const form = document.createElement('form');
472
+ form.className = 'dialog';
473
+
474
+ const header = el('header', 'dialog-header');
475
+ const heading = el('div', 'dialog-heading');
476
+ heading.append(el('h2', null, 'Remove \\u201c' + codebase.name + '\\u201d from ' + p.name + '?'));
477
+ const closeBtn = el('button', 'dialog-close');
478
+ closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
479
+ closeBtn.append(xIcon());
480
+ header.append(heading, closeBtn);
481
+
482
+ const body = el('div', 'dialog-body');
483
+ body.append(el('p', 'muted', "It'll be removed for everyone in the org. Your local folder mapping stays, and you can re-add it anytime."));
484
+ const error = el('div', 'form-error');
485
+ body.append(error);
486
+
487
+ const footer = el('div', 'dialog-footer');
488
+ const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
489
+ const confirmBtn = el('button', 'btn btn-primary', 'Remove codebase'); confirmBtn.type = 'submit';
490
+ footer.append(cancelBtn, confirmBtn);
491
+
492
+ form.append(header, body, footer);
493
+ panel.append(form);
494
+ backdrop.append(panel);
495
+ document.body.append(backdrop);
496
+ // Focus Cancel, not the destructive Remove — a reflexive Enter/Space must not
497
+ // delete (matches the web's destructive-confirm behavior).
498
+ cancelBtn.focus();
499
+
500
+ function close(skipFocus) {
501
+ backdrop.remove();
502
+ document.removeEventListener('keydown', onKey);
503
+ // On a successful remove the caller re-homes focus to a surviving control,
504
+ // so skip returning focus here — the row's Remove trigger is destroyed by
505
+ // the refresh and focusing it would drop focus to <body>.
506
+ if (!skipFocus && returnFocus && returnFocus.focus) returnFocus.focus();
507
+ }
508
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
509
+ document.addEventListener('keydown', onKey);
510
+ backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
511
+ // Wrap so the click event isn't passed as close()'s skipFocus arg — cancel and
512
+ // the X keep returning focus to the Remove trigger (the row survives).
513
+ closeBtn.addEventListener('click', function () { close(); });
514
+ cancelBtn.addEventListener('click', function () { close(); });
515
+
516
+ form.addEventListener('submit', async function (e) {
517
+ e.preventDefault();
518
+ error.textContent = '';
519
+ confirmBtn.disabled = true; confirmBtn.textContent = 'Removing';
520
+ const r = await api('/api/projects/remove-codebase', { method: 'POST', body: JSON.stringify({ codebaseId: codebase.id }) });
521
+ confirmBtn.disabled = false; confirmBtn.textContent = 'Remove codebase';
522
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not remove the codebase.'; return; }
523
+ // Row survives on cancel/error (returnFocus = Remove trigger). On success the
524
+ // row is destroyed, so re-home focus (onRemoved) THEN tear down without the
525
+ // stale return-focus.
526
+ onRemoved();
527
+ close(true);
528
+ });
529
+ }
530
+
531
+ /** Screens 8 & 9: locate a not-here codebase on THIS computer. Pick a folder;
532
+ * if its git remote canonicalizes to the codebase's identity, its path is saved
533
+ * locally and this computer is reported as having the codebase. A non-matching
534
+ * or remote-less folder is refused. Mirrors openAddCodebaseDialog. */
535
+ function openLocateDialog(p, codebase, onLocated, returnFocus) {
536
+ let chosenLocalPath = null; // absolute path of the picked folder (local only)
537
+ let matches = false; // does the picked folder's remote == this identity
538
+
539
+ const backdrop = el('div', 'overlay-backdrop');
540
+ const panel = el('div', 'overlay-center is-compact');
541
+ const form = document.createElement('form');
542
+ form.className = 'dialog';
543
+
544
+ const header = el('header', 'dialog-header');
545
+ const heading = el('div', 'dialog-heading');
546
+ heading.append(el('h2', null, 'Locate \\u201c' + codebase.name + '\\u201d on this computer'));
547
+ heading.append(el('p', 'muted', p.name));
548
+ const closeBtn = el('button', 'dialog-close');
549
+ closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
550
+ closeBtn.append(xIcon());
551
+ header.append(heading, closeBtn);
552
+
553
+ const body = el('div', 'dialog-body');
554
+ const folderField = el('div', 'field');
555
+ folderField.append(el('label', null, 'Folder on this computer'));
556
+ const folderChoose = el('div', 'folder-choose');
557
+ const folderVal = el('span', 'folder-path mono is-empty', 'Not chosen');
558
+ const chooseBtn = el('button', 'btn', 'Choose folder\\u2026'); chooseBtn.type = 'button';
559
+ folderChoose.append(folderVal, chooseBtn);
560
+ folderField.append(folderChoose);
561
+
562
+ const remoteField = el('div', 'field');
563
+ remoteField.append(el('label', null, 'Its git remote'));
564
+ const remoteVal = el('code', 'folder-path mono is-empty', 'Choose a folder to detect its git remote.');
565
+ remoteField.append(remoteVal);
566
+
567
+ const identityField = el('div', 'field');
568
+ identityField.append(el('label', null, 'Codebase identity'));
569
+ identityField.append(el('code', 'folder-path mono', codebase.gitRemoteUrl));
570
+
571
+ const matchRow = el('div', 'locate-match');
572
+ const matchPill = el('span', 'pill pill-ok', '\\u2713 matches'); matchPill.hidden = true;
573
+ matchRow.append(matchPill);
574
+
575
+ const error = el('div', 'form-error');
576
+ body.append(folderField, remoteField, identityField, matchRow, error);
577
+
578
+ const footer = el('div', 'dialog-footer');
579
+ const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
580
+ const locateHereBtn = el('button', 'btn btn-primary', 'Locate here'); locateHereBtn.type = 'submit'; locateHereBtn.disabled = true;
581
+ footer.append(cancelBtn, locateHereBtn);
582
+
583
+ form.append(header, body, footer);
584
+ panel.append(form);
585
+ backdrop.append(panel);
586
+ document.body.append(backdrop);
587
+ chooseBtn.focus();
588
+
589
+ function close() {
590
+ backdrop.remove();
591
+ document.removeEventListener('keydown', onKey);
592
+ if (returnFocus && returnFocus.focus) returnFocus.focus();
593
+ }
594
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
595
+ document.addEventListener('keydown', onKey);
596
+ backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
597
+ closeBtn.addEventListener('click', close);
598
+ cancelBtn.addEventListener('click', close);
599
+
600
+ chooseBtn.addEventListener('click', async function () {
601
+ error.textContent = ''; matchPill.hidden = true; matches = false; locateHereBtn.disabled = true;
602
+ chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
603
+ const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
604
+ chooseBtn.disabled = false; chooseBtn.textContent = 'Choose folder\\u2026';
605
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
606
+ if (r.data.cancelled) return;
607
+ // Local path shown here only; never sent to the cloud.
608
+ chosenLocalPath = r.data.localPath;
609
+ folderVal.textContent = chosenLocalPath; folderVal.className = 'folder-path mono';
610
+ // codebaseRemote is the folder's canonical host/path identity (or null when
611
+ // it has no remote / a non-hosted remote). The match check mirrors the
612
+ // server's guard exactly — the server re-validates on submit regardless.
613
+ const detected = r.data.codebaseRemote;
614
+ remoteVal.textContent = r.data.gitRemoteUrl || 'none';
615
+ if (!detected) {
616
+ remoteVal.className = 'folder-path mono is-warn';
617
+ error.textContent = "This folder has no git remote, so it can't be this codebase.";
618
+ } else if (detected !== codebase.gitRemoteUrl) {
619
+ remoteVal.className = 'folder-path mono is-warn';
620
+ error.textContent = 'This folder is a different repo (' + detected + '), not ' + codebase.gitRemoteUrl + '.';
621
+ } else {
622
+ remoteVal.className = 'folder-path mono';
623
+ matches = true; matchPill.hidden = false; locateHereBtn.disabled = false;
624
+ }
625
+ });
626
+
627
+ form.addEventListener('submit', async function (e) {
628
+ e.preventDefault();
629
+ if (!matches || !chosenLocalPath) return;
630
+ error.textContent = '';
631
+ locateHereBtn.disabled = true; locateHereBtn.textContent = 'Locating';
632
+ // Only the codebase identity + this machine's id reach the cloud — never the
633
+ // path. The server re-detects the folder's remote and refuses a mismatch.
634
+ const r = await api('/api/projects/locate-codebase', { method: 'POST', body: JSON.stringify({ codebaseRemote: codebase.gitRemoteUrl, localPath: chosenLocalPath }) });
635
+ locateHereBtn.disabled = false; locateHereBtn.textContent = 'Locate here';
636
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not locate the codebase.'; return; }
637
+ close();
638
+ onLocated();
639
+ });
640
+ }
641
+
642
+ /** Add-codebase flow: choose a folder, detect its git remote, and register the
643
+ * remote's canonical host/path identity (never the local path) as a codebase on
644
+ * the project. A folder with no git remote — or whose remote isn't a hosted
645
+ * repository URL (a local/file path) — is refused. Reuses the shared dialog. */
646
+ function openAddCodebaseDialog(p, onAdded, returnFocus) {
647
+ let chosenRemote = null; // the canonical host/path identity, or null when not addable
648
+ let chosenLocalPath = null; // the picked folder (local only) — used to auto-locate after add
649
+
650
+ const backdrop = el('div', 'overlay-backdrop');
651
+ const panel = el('div', 'overlay-center is-compact');
652
+ const form = document.createElement('form');
653
+ form.className = 'dialog';
654
+
655
+ const header = el('header', 'dialog-header');
656
+ const heading = el('div', 'dialog-heading');
657
+ heading.append(el('h2', null, 'Add codebase'));
658
+ heading.append(el('p', 'muted', p.name));
659
+ const closeBtn = el('button', 'dialog-close');
660
+ closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
661
+ closeBtn.append(xIcon());
662
+ header.append(heading, closeBtn);
663
+
664
+ const body = el('div', 'dialog-body');
665
+ const folderField = el('div', 'field');
666
+ folderField.append(el('label', null, 'Folder on this computer'));
667
+ const folderChoose = el('div', 'folder-choose');
668
+ const folderVal = el('span', 'folder-path mono is-empty', 'Not chosen');
669
+ const chooseBtn = el('button', 'btn', 'Choose folder'); chooseBtn.type = 'button';
670
+ folderChoose.append(folderVal, chooseBtn);
671
+ folderField.append(folderChoose);
672
+
673
+ const remoteField = el('div', 'field');
674
+ remoteField.append(el('label', null, 'Git remote'));
675
+ const remoteVal = el('code', 'folder-path mono is-empty', 'Choose a folder to detect its git remote.');
676
+ remoteField.append(remoteVal);
677
+
678
+ const error = el('div', 'form-error');
679
+ const note = el('div', 'form-note');
680
+ body.append(folderField, remoteField, error, note);
681
+
682
+ const footer = el('div', 'dialog-footer');
683
+ const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
684
+ const addBtn = el('button', 'btn btn-primary', 'Add as codebase'); addBtn.type = 'submit'; addBtn.disabled = true;
685
+ footer.append(cancelBtn, addBtn);
686
+
687
+ form.append(header, body, footer);
688
+ panel.append(form);
689
+ backdrop.append(panel);
690
+ document.body.append(backdrop);
691
+ chooseBtn.focus();
692
+
693
+ function close() {
694
+ backdrop.remove();
695
+ document.removeEventListener('keydown', onKey);
696
+ if (returnFocus && returnFocus.focus) returnFocus.focus();
697
+ }
698
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
699
+ document.addEventListener('keydown', onKey);
700
+ backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
701
+ closeBtn.addEventListener('click', close);
702
+ cancelBtn.addEventListener('click', close);
703
+
704
+ chooseBtn.addEventListener('click', async function () {
705
+ error.textContent = ''; note.textContent = '';
706
+ chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
707
+ const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
708
+ chooseBtn.disabled = false; chooseBtn.textContent = 'Choose folder';
709
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
710
+ if (r.data.cancelled) return;
711
+ // Show the chosen folder locally only — it is never sent to the cloud.
712
+ chosenLocalPath = r.data.localPath;
713
+ folderVal.textContent = r.data.localPath; folderVal.className = 'folder-path mono';
714
+ // codebaseRemote is the canonical host/path identity (or null when the
715
+ // folder's remote isn't a hosted repo). It is what gets stored, never the
716
+ // raw remote or the local path.
717
+ const identity = r.data.codebaseRemote;
718
+ if (!r.data.gitRemoteUrl) {
719
+ chosenRemote = null;
720
+ remoteVal.textContent = 'none'; remoteVal.className = 'folder-path mono is-warn';
721
+ error.textContent = "This folder has no git remote \\u2014 it can't become a codebase.";
722
+ addBtn.disabled = true;
723
+ } else if (!identity) {
724
+ // Has a remote, but it's a local/file path, not a hosted repository URL.
725
+ chosenRemote = null;
726
+ remoteVal.textContent = r.data.gitRemoteUrl; remoteVal.className = 'folder-path mono is-warn';
727
+ error.textContent = "This folder's git remote isn't a hosted repository URL, so it can't become a codebase.";
728
+ addBtn.disabled = true;
729
+ } else {
730
+ chosenRemote = identity;
731
+ remoteVal.textContent = identity; remoteVal.className = 'folder-path mono';
732
+ addBtn.disabled = false;
733
+ }
734
+ });
735
+
736
+ form.addEventListener('submit', async function (e) {
737
+ e.preventDefault();
738
+ if (!chosenRemote) return;
739
+ error.textContent = ''; note.textContent = '';
740
+ addBtn.disabled = true; addBtn.textContent = 'Adding';
741
+ // Only the canonical git-remote identity goes to the cloud — never the local path.
742
+ const r = await api('/api/projects/add-codebase', { method: 'POST', body: JSON.stringify({ projectId: p.id, gitRemoteUrl: chosenRemote }) });
743
+ if (!r.ok) { addBtn.disabled = false; addBtn.textContent = 'Add as codebase'; error.textContent = (r.data && r.data.error) || 'Could not add the codebase.'; return; }
744
+ // Auto-locate: the computer that adds a codebase inherently HAS the folder,
745
+ // so record this machine's availability now — it shows "On this computer"
746
+ // immediately. Separate call from add; the server re-validates the folder's
747
+ // remote (already this identity, so it passes) and only the identity + this
748
+ // machine's id reach the cloud, never the path. Best-effort: the add already
749
+ // succeeded, so a failed availability write must never block the success UX
750
+ // or surface an error (the user can Locate explicitly later). Swallow any
751
+ // rejection so the button reset + refresh below always run.
752
+ if (chosenLocalPath) {
753
+ await api('/api/projects/locate-codebase', { method: 'POST', body: JSON.stringify({ codebaseRemote: chosenRemote, localPath: chosenLocalPath }) }).catch(function () {});
754
+ }
755
+ addBtn.disabled = false; addBtn.textContent = 'Add as codebase';
756
+ if (r.data && r.data.added === false) {
757
+ // Dedupe: the project already had this remote. Say so rather than closing
758
+ // as if a new codebase were just created.
759
+ note.textContent = 'Already on this project \\u2014 no duplicate created.';
760
+ onAdded();
761
+ return;
762
+ }
763
+ close();
764
+ onAdded();
765
+ });
766
+ }
767
+
768
+ /** Opens the shared dialog shell (fixed header + body + footer, footer-right
769
+ * actions) — same structure/classes as the web app's Dialog primitive. */
770
+ function openMapDialog(p, list, returnFocus) {
771
+ const hasRemote = !!p.gitRemoteUrl && p.gitRemoteUrl.indexOf('local:v1:') !== 0;
772
+ let chosenPath = p.localPath || null;
773
+
774
+ const backdrop = el('div', 'overlay-backdrop');
775
+ const panel = el('div', 'overlay-center is-compact');
776
+ const form = document.createElement('form');
777
+ form.className = 'dialog';
778
+
779
+ const header = el('header', 'dialog-header');
780
+ const heading = el('div', 'dialog-heading');
781
+ heading.append(el('h2', null, p.name));
782
+ const closeBtn = el('button', 'dialog-close');
783
+ closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
784
+ closeBtn.append(xIcon());
785
+ header.append(heading, closeBtn);
786
+
787
+ const body = el('div', 'dialog-body');
788
+ const folderField = el('div', 'field');
789
+ folderField.append(el('label', null, 'Local folder'));
790
+ const folderChoose = el('div', 'folder-choose');
791
+ const folderVal = el('span', chosenPath ? 'folder-path mono' : 'folder-path mono is-empty', chosenPath || 'Not chosen');
792
+ const chooseBtn = el('button', 'btn', 'Choose'); chooseBtn.type = 'button';
793
+ folderChoose.append(folderVal, chooseBtn);
794
+ folderField.append(folderChoose);
795
+
796
+ const remoteField = el('div', 'field');
797
+ const remoteLabel = el('label', null, 'Git remote URL'); remoteLabel.htmlFor = 'map-remote';
798
+ const remoteInput = el('input', 'input'); remoteInput.id = 'map-remote';
799
+ remoteInput.placeholder = 'git@github.com:org/repo.git';
800
+ if (hasRemote) remoteInput.value = p.gitRemoteUrl;
801
+ remoteField.append(remoteLabel, remoteInput);
802
+
803
+ const error = el('div', 'form-error');
804
+ body.append(folderField, remoteField, error);
805
+
806
+ const footer = el('div', 'dialog-footer');
807
+ const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
808
+ const saveBtn = el('button', 'btn btn-primary', 'Save'); saveBtn.type = 'submit';
809
+ footer.append(cancelBtn, saveBtn);
810
+
811
+ form.append(header, body, footer);
812
+ panel.append(form);
813
+ backdrop.append(panel);
814
+ document.body.append(backdrop);
815
+ remoteInput.focus();
816
+
817
+ function close() {
818
+ backdrop.remove();
819
+ document.removeEventListener('keydown', onKey);
820
+ if (returnFocus && returnFocus.focus) returnFocus.focus();
821
+ }
822
+ function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
823
+ document.addEventListener('keydown', onKey);
824
+ backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
825
+ closeBtn.addEventListener('click', close);
826
+ cancelBtn.addEventListener('click', close);
827
+
828
+ chooseBtn.addEventListener('click', async function () {
829
+ error.textContent = '';
830
+ chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
831
+ const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
832
+ chooseBtn.disabled = false; chooseBtn.textContent = 'Choose';
833
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
834
+ if (r.data.cancelled) return;
835
+ chosenPath = r.data.localPath;
836
+ folderVal.textContent = chosenPath; folderVal.className = 'folder-path mono';
837
+ if (r.data.gitRemoteUrl && !remoteInput.value) remoteInput.value = r.data.gitRemoteUrl;
838
+ });
839
+
840
+ form.addEventListener('submit', async function (e) {
841
+ e.preventDefault();
842
+ error.textContent = '';
843
+ saveBtn.disabled = true; saveBtn.textContent = 'Saving';
844
+ const r = await api('/api/projects/map', { method: 'POST', body: JSON.stringify({ projectId: p.id, localPath: chosenPath, gitRemoteUrl: remoteInput.value }) });
845
+ saveBtn.disabled = false; saveBtn.textContent = 'Save';
846
+ if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not save.'; return; }
847
+ close();
848
+ loadProjects(list);
849
+ });
850
+ }
851
+
852
+ boot();
853
+ </script>
854
+ </body>
855
+ </html>`;
856
+ }