@ctrl-spc/cs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/agents.js +6 -0
- package/dist/codebases.js +108 -0
- package/dist/companion-ui.js +1058 -0
- package/dist/companion.js +396 -0
- package/dist/config.js +169 -10
- package/dist/daemon.js +12 -79
- package/dist/env.js +15 -0
- package/dist/folders.js +67 -0
- package/dist/git-remote.js +79 -0
- package/dist/index.js +7 -18
- package/dist/mcp.js +1679 -0
- package/dist/presence.js +206 -0
- package/dist/projects.js +32 -0
- package/dist/supabase.js +21 -1
- package/package.json +4 -2
|
@@ -0,0 +1,1058 @@
|
|
|
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
|
+
.empty-cta { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); text-align: center; padding: var(--sp-6) var(--sp-4); border: 1px dashed var(--border-strong); border-radius: var(--r-md); }
|
|
224
|
+
.empty-cta.is-compact { padding: var(--sp-5) var(--sp-4); gap: var(--sp-2); }
|
|
225
|
+
.empty-cta-title { font-weight: 600; }
|
|
226
|
+
.empty-cta .muted { font-size: var(--text-sm); max-width: 46ch; }
|
|
227
|
+
.pill {
|
|
228
|
+
display: inline-flex; align-items: center; flex-shrink: 0; white-space: nowrap;
|
|
229
|
+
font-size: var(--text-xs); font-weight: 500; padding: 2px 10px; border-radius: 20px;
|
|
230
|
+
border: 1px solid var(--border); color: var(--muted);
|
|
231
|
+
}
|
|
232
|
+
.pill-ok { color: var(--ok); border-color: var(--ok); }
|
|
233
|
+
.locate-match { min-height: 1.2em; }
|
|
234
|
+
|
|
235
|
+
/* --- agent tools badge (feature 05: passive, read-only) --- */
|
|
236
|
+
.agent-tools {
|
|
237
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
238
|
+
border: 1px solid var(--border); border-radius: var(--r-md);
|
|
239
|
+
padding: var(--sp-3) var(--sp-4); background: var(--surface);
|
|
240
|
+
}
|
|
241
|
+
.agent-tools-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: var(--faint); }
|
|
242
|
+
.agent-tools-dot.is-on { background: var(--ok); }
|
|
243
|
+
/* Amber "connecting…" while async registration is in flight (no amber token). */
|
|
244
|
+
.agent-tools-dot.is-wait { background: #e0a020; }
|
|
245
|
+
.agent-tools-title { font-weight: 600; }
|
|
246
|
+
.agent-tools-sub { font-size: var(--text-xs); color: var(--muted); margin-top: 2px; }
|
|
247
|
+
</style>
|
|
248
|
+
</head>
|
|
249
|
+
<body>
|
|
250
|
+
<div id="app"></div>
|
|
251
|
+
<script>
|
|
252
|
+
const TOKEN = ${JSON.stringify(token)};
|
|
253
|
+
const WEB_URL = ${JSON.stringify(WEB_APP_URL)};
|
|
254
|
+
const app = document.getElementById('app');
|
|
255
|
+
|
|
256
|
+
function keycap(text, space) {
|
|
257
|
+
const s = document.createElement('span');
|
|
258
|
+
s.className = 'logo-key' + (space ? ' logo-space' : '');
|
|
259
|
+
s.textContent = text;
|
|
260
|
+
return s;
|
|
261
|
+
}
|
|
262
|
+
function logo() {
|
|
263
|
+
const l = document.createElement('span');
|
|
264
|
+
l.className = 'logo';
|
|
265
|
+
l.setAttribute('aria-label', 'CTRL+SPC');
|
|
266
|
+
const plus = document.createElement('span');
|
|
267
|
+
plus.className = 'logo-plus';
|
|
268
|
+
plus.textContent = '+';
|
|
269
|
+
l.append(keycap('ctrl'), plus, keycap('spc', true));
|
|
270
|
+
return l;
|
|
271
|
+
}
|
|
272
|
+
function el(tag, cls, text) {
|
|
273
|
+
const n = document.createElement(tag);
|
|
274
|
+
if (cls) n.className = cls;
|
|
275
|
+
if (text != null) n.textContent = text;
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function api(path, opts) {
|
|
280
|
+
opts = opts || {};
|
|
281
|
+
const headers = Object.assign({ 'x-ctrl-spc-token': TOKEN }, opts.headers || {});
|
|
282
|
+
if (opts.body) headers['Content-Type'] = 'application/json';
|
|
283
|
+
const res = await fetch(path, { method: opts.method || 'GET', headers: headers, body: opts.body, cache: 'no-store' });
|
|
284
|
+
const data = await res.json().catch(function () { return {}; });
|
|
285
|
+
return { ok: res.ok, status: res.status, data: data };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function boot() {
|
|
289
|
+
const r = await api('/api/session');
|
|
290
|
+
if (r.data && r.data.signedIn) renderHome(r.data);
|
|
291
|
+
else renderSignIn(r.data || {});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The passive "Agent tools" badge (feature 05). Read-only: no button, no
|
|
295
|
+
* action. Reflects the lifecycle state the server computed in /api/session —
|
|
296
|
+
* connecting (amber, async registration in flight) / connected / couldn't-
|
|
297
|
+
* connect, plus the signed-out and no-agent off-states. */
|
|
298
|
+
function agentToolsBadge(state) {
|
|
299
|
+
state = state || { connected: false, reason: 'signed-out', agent: '', server: 'ctrl-spc' };
|
|
300
|
+
const reason = state.reason;
|
|
301
|
+
const box = el('div', 'agent-tools');
|
|
302
|
+
let dotCls = 'agent-tools-dot';
|
|
303
|
+
if (reason === 'connected') dotCls += ' is-on';
|
|
304
|
+
else if (reason === 'connecting') dotCls += ' is-wait';
|
|
305
|
+
const dot = el('span', dotCls);
|
|
306
|
+
const body = el('div');
|
|
307
|
+
let title, sub;
|
|
308
|
+
if (reason === 'connected') {
|
|
309
|
+
title = 'Agent tools: connected \\u2713';
|
|
310
|
+
sub = (state.agent || 'Claude') + ' \\u00b7 ' + (state.server || 'ctrl-spc');
|
|
311
|
+
} else if (reason === 'connecting') {
|
|
312
|
+
title = 'Agent tools: connecting\\u2026';
|
|
313
|
+
sub = state.agent || '';
|
|
314
|
+
} else if (reason === 'failed') {
|
|
315
|
+
title = 'Agent tools: not connected';
|
|
316
|
+
sub = "couldn't connect the tools \\u2014 will retry";
|
|
317
|
+
} else if (reason === 'no-agent') {
|
|
318
|
+
title = 'Agent tools: not connected';
|
|
319
|
+
sub = 'No supported agent (Claude or Codex) found on this machine';
|
|
320
|
+
} else {
|
|
321
|
+
title = 'Agent tools: not connected';
|
|
322
|
+
sub = 'sign in to connect';
|
|
323
|
+
}
|
|
324
|
+
body.append(el('div', 'agent-tools-title', title));
|
|
325
|
+
body.append(el('div', 'agent-tools-sub mono', sub));
|
|
326
|
+
box.append(dot, body);
|
|
327
|
+
return box;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The badge must flip connecting\\u2192connected on its own (Claude registers in
|
|
331
|
+
// ~7s) with no page reload. A light 2s poll of /api/session swaps ONLY the badge
|
|
332
|
+
// element in place — never a full home re-render, which would disrupt the
|
|
333
|
+
// codebases list, open dialogs, and focus. Guards against overlapping fetches and
|
|
334
|
+
// stops itself once the badge leaves the DOM (home torn down).
|
|
335
|
+
let badgePollTimer = null;
|
|
336
|
+
let badgePolling = false;
|
|
337
|
+
|
|
338
|
+
function stopBadgePoll() {
|
|
339
|
+
if (badgePollTimer) { clearInterval(badgePollTimer); badgePollTimer = null; }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function pollBadgeOnce() {
|
|
343
|
+
if (badgePolling) return;
|
|
344
|
+
const badge = document.getElementById('agent-tools');
|
|
345
|
+
if (!badge) { stopBadgePoll(); return; }
|
|
346
|
+
badgePolling = true;
|
|
347
|
+
try {
|
|
348
|
+
const r = await api('/api/session');
|
|
349
|
+
// Home may have been torn down while the fetch was in flight.
|
|
350
|
+
if (!badge.isConnected) return;
|
|
351
|
+
if (!(r.data && r.data.signedIn)) { stopBadgePoll(); return; }
|
|
352
|
+
const fresh = agentToolsBadge(r.data.agentTools);
|
|
353
|
+
fresh.id = 'agent-tools';
|
|
354
|
+
fresh.style.marginBottom = 'var(--sp-5)';
|
|
355
|
+
badge.replaceWith(fresh);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
/* transient — try again next tick */
|
|
358
|
+
} finally {
|
|
359
|
+
badgePolling = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function startBadgePoll() {
|
|
364
|
+
stopBadgePoll();
|
|
365
|
+
badgePollTimer = setInterval(pollBadgeOnce, 2000);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderSignIn(session) {
|
|
369
|
+
stopBadgePoll(); // the sign-in badge is static — no lifecycle to poll
|
|
370
|
+
app.replaceChildren();
|
|
371
|
+
const wrap = el('div', 'auth');
|
|
372
|
+
const card = el('div', 'auth-card');
|
|
373
|
+
card.append(logo());
|
|
374
|
+
card.append(el('h1', 'auth-title', 'Sign in'));
|
|
375
|
+
card.append(el('p', 'auth-sub', 'The board your agents report to. Sign in to link this computer to your account.'));
|
|
376
|
+
|
|
377
|
+
const form = el('form', 'auth-form');
|
|
378
|
+
const emailField = el('div', 'field');
|
|
379
|
+
const emailLabel = el('label', null, 'Email'); emailLabel.htmlFor = 'email';
|
|
380
|
+
const email = el('input', 'input'); email.id = 'email'; email.type = 'email'; email.required = true;
|
|
381
|
+
email.autocomplete = 'email'; email.placeholder = 'you@example.com';
|
|
382
|
+
emailField.append(emailLabel, email);
|
|
383
|
+
|
|
384
|
+
const pwField = el('div', 'field');
|
|
385
|
+
const pwLabel = el('label', null, 'Password'); pwLabel.htmlFor = 'password';
|
|
386
|
+
const pw = el('input', 'input'); pw.id = 'password'; pw.type = 'password'; pw.required = true;
|
|
387
|
+
pw.autocomplete = 'current-password'; pw.placeholder = '\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022\\u2022';
|
|
388
|
+
pwField.append(pwLabel, pw);
|
|
389
|
+
|
|
390
|
+
const error = el('div', 'form-error');
|
|
391
|
+
const submit = el('button', 'btn btn-primary auth-submit', 'Sign in'); submit.type = 'submit';
|
|
392
|
+
form.append(emailField, pwField, error, submit);
|
|
393
|
+
|
|
394
|
+
form.addEventListener('submit', async function (e) {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
error.textContent = '';
|
|
397
|
+
submit.disabled = true; submit.textContent = 'Signing in';
|
|
398
|
+
const r = await api('/api/login', { method: 'POST', body: JSON.stringify({ email: email.value, password: pw.value }) });
|
|
399
|
+
if (r.ok && r.data.ok) { boot(); return; }
|
|
400
|
+
error.textContent = (r.data && r.data.error) || 'Sign-in failed.';
|
|
401
|
+
submit.disabled = false; submit.textContent = 'Sign in';
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const signup = el('a', 'auth-switch', 'Sign up on the web \\u2197');
|
|
405
|
+
signup.href = WEB_URL; signup.target = '_blank'; signup.rel = 'noopener noreferrer';
|
|
406
|
+
|
|
407
|
+
card.append(form, signup, agentToolsBadge(session.agentTools));
|
|
408
|
+
wrap.append(card);
|
|
409
|
+
app.append(wrap);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function renderHome(session) {
|
|
413
|
+
app.replaceChildren();
|
|
414
|
+
const bar = el('header', 'titlebar');
|
|
415
|
+
bar.append(logo());
|
|
416
|
+
const spacer = el('span', null); spacer.style.flex = '1'; bar.append(spacer);
|
|
417
|
+
const help = el('button', 'btn btn-ghost', 'How it works');
|
|
418
|
+
help.addEventListener('click', function () { openHowItWorks(help); });
|
|
419
|
+
bar.append(help);
|
|
420
|
+
bar.append(el('span', 'mono muted', session.email || ''));
|
|
421
|
+
const web = el('a', 'btn', 'Open the web app \\u2197');
|
|
422
|
+
web.href = WEB_URL; web.target = '_blank'; web.rel = 'noopener noreferrer';
|
|
423
|
+
bar.append(web);
|
|
424
|
+
const out = el('button', 'btn btn-ghost', 'Sign out');
|
|
425
|
+
out.addEventListener('click', async function () {
|
|
426
|
+
out.disabled = true;
|
|
427
|
+
await api('/api/logout', { method: 'POST' });
|
|
428
|
+
boot();
|
|
429
|
+
});
|
|
430
|
+
bar.append(out);
|
|
431
|
+
|
|
432
|
+
const main = el('main', 'home');
|
|
433
|
+
const tools = agentToolsBadge(session.agentTools);
|
|
434
|
+
tools.id = 'agent-tools';
|
|
435
|
+
tools.style.marginBottom = 'var(--sp-5)';
|
|
436
|
+
main.append(tools);
|
|
437
|
+
main.append(el('h1', 'home-title', 'Projects'));
|
|
438
|
+
const sub = el('p', 'home-sub muted');
|
|
439
|
+
sub.append(document.createTextNode('Map each project to its folder on '));
|
|
440
|
+
sub.append(el('strong', null, session.machineName || 'this computer'));
|
|
441
|
+
sub.append(document.createTextNode('.'));
|
|
442
|
+
main.append(sub);
|
|
443
|
+
const list = el('div', 'projects'); list.id = 'projects';
|
|
444
|
+
main.append(list);
|
|
445
|
+
|
|
446
|
+
app.append(bar, main);
|
|
447
|
+
loadProjects(list);
|
|
448
|
+
// Keep the badge honest as async registration settles (connecting \\u2192 connected),
|
|
449
|
+
// swapping only the badge element — never the whole home view.
|
|
450
|
+
startBadgePoll();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function loadProjects(list) {
|
|
454
|
+
list.replaceChildren(el('p', 'empty', 'Loading projects\\u2026'));
|
|
455
|
+
const r = await api('/api/projects');
|
|
456
|
+
if (!r.ok) { list.replaceChildren(el('p', 'empty', (r.data && r.data.error) || 'Could not load projects.')); return; }
|
|
457
|
+
const projects = (r.data && r.data.projects) || [];
|
|
458
|
+
if (projects.length === 0) {
|
|
459
|
+
const box = el('div', 'empty-cta');
|
|
460
|
+
box.append(el('div', 'empty-cta-title', 'No projects yet'));
|
|
461
|
+
box.append(el('p', 'muted', 'A project holds your codebases. Create your first one in the web app, then come back here to attach a codebase.'));
|
|
462
|
+
const cta = el('a', 'btn btn-primary', 'Create your first project in the web app \\u2197');
|
|
463
|
+
cta.href = WEB_URL; cta.target = '_blank'; cta.rel = 'noopener noreferrer';
|
|
464
|
+
box.append(cta);
|
|
465
|
+
list.replaceChildren(box);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
list.replaceChildren.apply(list, projects.map(function (p) { return projectRow(p, list); }));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function xIcon() {
|
|
472
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
473
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
474
|
+
svg.setAttribute('width', '14'); svg.setAttribute('height', '14');
|
|
475
|
+
svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('fill', 'none');
|
|
476
|
+
svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.5');
|
|
477
|
+
svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round');
|
|
478
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
479
|
+
const path = document.createElementNS(NS, 'path');
|
|
480
|
+
path.setAttribute('d', 'M4 4l8 8M12 4l-8 8');
|
|
481
|
+
svg.append(path);
|
|
482
|
+
return svg;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function projectRow(p, list) {
|
|
486
|
+
const row = el('div', 'project');
|
|
487
|
+
const head = el('div', 'project-head');
|
|
488
|
+
const meta = el('div', 'project-meta');
|
|
489
|
+
meta.append(el('strong', 'project-name', p.name));
|
|
490
|
+
const hasRemote = !!p.gitRemoteUrl && p.gitRemoteUrl.indexOf('local:v1:') !== 0;
|
|
491
|
+
meta.append(el('code', 'project-remote' + (hasRemote ? '' : ' is-local'), hasRemote ? p.gitRemoteUrl : 'No Git remote'));
|
|
492
|
+
head.append(meta);
|
|
493
|
+
|
|
494
|
+
const actions = el('div', 'project-actions');
|
|
495
|
+
const status = el('span', 'project-status' + (p.localPath ? ' is-mapped' : ''), p.localPath ? p.localPath : 'Not mapped');
|
|
496
|
+
const mapBtn = el('button', 'btn', p.localPath ? 'Edit' : 'Map');
|
|
497
|
+
mapBtn.addEventListener('click', function () { openMapDialog(p, list, mapBtn); });
|
|
498
|
+
actions.append(status, mapBtn);
|
|
499
|
+
head.append(actions);
|
|
500
|
+
row.append(head);
|
|
501
|
+
|
|
502
|
+
// --- codebases on this project (Phase 1) ---
|
|
503
|
+
const section = el('div', 'project-codebases');
|
|
504
|
+
const secHead = el('div', 'project-codebases-head');
|
|
505
|
+
secHead.append(el('span', 'project-codebases-title', 'Codebases'));
|
|
506
|
+
const cbList = el('div', 'codebases');
|
|
507
|
+
const addBtn = el('button', 'btn', 'Add codebase');
|
|
508
|
+
addBtn.addEventListener('click', function () {
|
|
509
|
+
openAddCodebaseDialog(p, function () { loadCodebases(p, cbList); }, addBtn);
|
|
510
|
+
});
|
|
511
|
+
secHead.append(addBtn);
|
|
512
|
+
section.append(secHead, cbList);
|
|
513
|
+
row.append(section);
|
|
514
|
+
|
|
515
|
+
loadCodebases(p, cbList);
|
|
516
|
+
return row;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function loadCodebases(p, container) {
|
|
520
|
+
container.replaceChildren(el('p', 'codebases-empty', 'Loading\\u2026'));
|
|
521
|
+
const r = await api('/api/projects/codebases?projectId=' + encodeURIComponent(p.id));
|
|
522
|
+
if (!r.ok) {
|
|
523
|
+
container.replaceChildren(el('p', 'codebases-empty', (r.data && r.data.error) || 'Could not load codebases.'));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const codebases = (r.data && r.data.codebases) || [];
|
|
527
|
+
if (codebases.length === 0) {
|
|
528
|
+
const box = el('div', 'empty-cta is-compact');
|
|
529
|
+
box.append(el('p', 'muted', 'No codebases yet. Point ' + p.name + ' at a folder on this computer that has a git remote.'));
|
|
530
|
+
const cta = el('button', 'btn btn-primary', 'Add your first codebase');
|
|
531
|
+
cta.type = 'button';
|
|
532
|
+
cta.addEventListener('click', function () {
|
|
533
|
+
// Route return-focus to a control that SURVIVES the codebase-list refresh
|
|
534
|
+
// (this cta is inside the container and gets destroyed by loadCodebases),
|
|
535
|
+
// mirroring the Remove button's focus guard.
|
|
536
|
+
const section = container.closest('.project-codebases');
|
|
537
|
+
const survivingAdd = section && section.querySelector('.project-codebases-head button');
|
|
538
|
+
openAddCodebaseDialog(p, function () { loadCodebases(p, container); }, survivingAdd || cta);
|
|
539
|
+
});
|
|
540
|
+
box.append(cta);
|
|
541
|
+
container.replaceChildren(box);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
container.replaceChildren.apply(container, codebases.map(function (c) { return codebaseRow(c, p, container); }));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Screen 7: a codebase row shows whether THIS computer has it. Located → the
|
|
548
|
+
* local folder (shown only in this local UI, never sent to the cloud) + an
|
|
549
|
+
* "On this computer" pill. Not located → the git remote + a "Locate…"
|
|
550
|
+
* action that opens the locate dialog for this codebase. Screen 14: every row
|
|
551
|
+
* also carries a "Remove" control (parity with the web) alongside that. */
|
|
552
|
+
function codebaseRow(c, p, container) {
|
|
553
|
+
const row = el('div', 'codebase');
|
|
554
|
+
const meta = el('div', 'codebase-meta');
|
|
555
|
+
meta.append(el('strong', 'codebase-name', c.name));
|
|
556
|
+
if (c.localPath) {
|
|
557
|
+
// Local path — companion-only. Displaying it here never uploads it.
|
|
558
|
+
meta.append(el('code', 'codebase-path mono', c.localPath));
|
|
559
|
+
} else {
|
|
560
|
+
meta.append(el('code', 'codebase-remote mono', c.gitRemoteUrl));
|
|
561
|
+
}
|
|
562
|
+
row.append(meta);
|
|
563
|
+
|
|
564
|
+
const actions = el('div', 'codebase-actions');
|
|
565
|
+
if (c.localPath) {
|
|
566
|
+
actions.append(el('span', 'pill pill-ok', 'On this computer'));
|
|
567
|
+
} else {
|
|
568
|
+
const locateBtn = el('button', 'btn', 'Locate\\u2026');
|
|
569
|
+
locateBtn.addEventListener('click', function () {
|
|
570
|
+
openLocateDialog(p, c, function () { loadCodebases(p, container); }, locateBtn);
|
|
571
|
+
});
|
|
572
|
+
actions.append(locateBtn);
|
|
573
|
+
}
|
|
574
|
+
const removeBtn = el('button', 'btn', 'Remove');
|
|
575
|
+
removeBtn.addEventListener('click', function () {
|
|
576
|
+
openRemoveCodebaseDialog(p, c, function () {
|
|
577
|
+
loadCodebases(p, container);
|
|
578
|
+
// The refresh destroys this row's Remove button, so send focus to a
|
|
579
|
+
// SURVIVING control — the section's "Add codebase" button — instead of a
|
|
580
|
+
// detached node (which would drop focus to <body>).
|
|
581
|
+
const section = container.closest('.project-codebases');
|
|
582
|
+
const addBtn = section && section.querySelector('.project-codebases-head button');
|
|
583
|
+
if (addBtn && addBtn.focus) addBtn.focus();
|
|
584
|
+
}, removeBtn);
|
|
585
|
+
});
|
|
586
|
+
actions.append(removeBtn);
|
|
587
|
+
row.append(actions);
|
|
588
|
+
return row;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Screen 14: confirm removing a codebase from a project. Removal is org-wide —
|
|
592
|
+
* it deletes the shared codebase row for everyone in the org — but it does NOT
|
|
593
|
+
* un-locate this computer: the local folder mapping stays, so a re-add shows it
|
|
594
|
+
* located again. Mirrors the shared dialog shell (openLocateDialog). */
|
|
595
|
+
function openRemoveCodebaseDialog(p, codebase, onRemoved, returnFocus) {
|
|
596
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
597
|
+
const panel = el('div', 'overlay-center is-compact');
|
|
598
|
+
const form = document.createElement('form');
|
|
599
|
+
form.className = 'dialog';
|
|
600
|
+
|
|
601
|
+
const header = el('header', 'dialog-header');
|
|
602
|
+
const heading = el('div', 'dialog-heading');
|
|
603
|
+
heading.append(el('h2', null, 'Remove \\u201c' + codebase.name + '\\u201d from ' + p.name + '?'));
|
|
604
|
+
const closeBtn = el('button', 'dialog-close');
|
|
605
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
606
|
+
closeBtn.append(xIcon());
|
|
607
|
+
header.append(heading, closeBtn);
|
|
608
|
+
|
|
609
|
+
const body = el('div', 'dialog-body');
|
|
610
|
+
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."));
|
|
611
|
+
const error = el('div', 'form-error');
|
|
612
|
+
body.append(error);
|
|
613
|
+
|
|
614
|
+
const footer = el('div', 'dialog-footer');
|
|
615
|
+
const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
|
|
616
|
+
const confirmBtn = el('button', 'btn btn-primary', 'Remove codebase'); confirmBtn.type = 'submit';
|
|
617
|
+
footer.append(cancelBtn, confirmBtn);
|
|
618
|
+
|
|
619
|
+
form.append(header, body, footer);
|
|
620
|
+
panel.append(form);
|
|
621
|
+
backdrop.append(panel);
|
|
622
|
+
document.body.append(backdrop);
|
|
623
|
+
// Focus Cancel, not the destructive Remove — a reflexive Enter/Space must not
|
|
624
|
+
// delete (matches the web's destructive-confirm behavior).
|
|
625
|
+
cancelBtn.focus();
|
|
626
|
+
|
|
627
|
+
function close(skipFocus) {
|
|
628
|
+
backdrop.remove();
|
|
629
|
+
document.removeEventListener('keydown', onKey);
|
|
630
|
+
// On a successful remove the caller re-homes focus to a surviving control,
|
|
631
|
+
// so skip returning focus here — the row's Remove trigger is destroyed by
|
|
632
|
+
// the refresh and focusing it would drop focus to <body>.
|
|
633
|
+
if (!skipFocus && returnFocus && returnFocus.focus) returnFocus.focus();
|
|
634
|
+
}
|
|
635
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
636
|
+
document.addEventListener('keydown', onKey);
|
|
637
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
638
|
+
// Wrap so the click event isn't passed as close()'s skipFocus arg — cancel and
|
|
639
|
+
// the X keep returning focus to the Remove trigger (the row survives).
|
|
640
|
+
closeBtn.addEventListener('click', function () { close(); });
|
|
641
|
+
cancelBtn.addEventListener('click', function () { close(); });
|
|
642
|
+
|
|
643
|
+
form.addEventListener('submit', async function (e) {
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
error.textContent = '';
|
|
646
|
+
confirmBtn.disabled = true; confirmBtn.textContent = 'Removing';
|
|
647
|
+
const r = await api('/api/projects/remove-codebase', { method: 'POST', body: JSON.stringify({ codebaseId: codebase.id }) });
|
|
648
|
+
confirmBtn.disabled = false; confirmBtn.textContent = 'Remove codebase';
|
|
649
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not remove the codebase.'; return; }
|
|
650
|
+
// Row survives on cancel/error (returnFocus = Remove trigger). On success the
|
|
651
|
+
// row is destroyed, so re-home focus (onRemoved) THEN tear down without the
|
|
652
|
+
// stale return-focus.
|
|
653
|
+
onRemoved();
|
|
654
|
+
close(true);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The re-openable "How it works" reference (Phase 3). Static explainer of the
|
|
659
|
+
* three surfaces — the CLI, this Companion, and the web app — in the shared
|
|
660
|
+
* overlay shell (same header/body/footer + Escape/backdrop close as the other
|
|
661
|
+
* dialogs). Content only, no API. */
|
|
662
|
+
function openHowItWorks(returnFocus) {
|
|
663
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
664
|
+
const panel = el('div', 'overlay-center');
|
|
665
|
+
const dialog = el('div', 'dialog');
|
|
666
|
+
|
|
667
|
+
const header = el('header', 'dialog-header');
|
|
668
|
+
const heading = el('div', 'dialog-heading');
|
|
669
|
+
heading.append(el('h2', null, 'How CTRL+SPC works'));
|
|
670
|
+
const closeBtn = el('button', 'dialog-close');
|
|
671
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
672
|
+
closeBtn.append(xIcon());
|
|
673
|
+
header.append(heading, closeBtn);
|
|
674
|
+
|
|
675
|
+
const body = el('div', 'dialog-body');
|
|
676
|
+
function section(titleNode, descText, extraNode) {
|
|
677
|
+
const wrap = el('div');
|
|
678
|
+
wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; wrap.style.gap = 'var(--sp-1)';
|
|
679
|
+
wrap.append(titleNode, el('p', 'muted', descText));
|
|
680
|
+
if (extraNode) wrap.append(extraNode);
|
|
681
|
+
return wrap;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// CLI section — inline command names in mono, matching the approved wireframe.
|
|
685
|
+
const cliWrap = el('div');
|
|
686
|
+
cliWrap.style.display = 'flex'; cliWrap.style.flexDirection = 'column'; cliWrap.style.gap = 'var(--sp-1)';
|
|
687
|
+
const cliTitle = el('h3', null, 'The CLI ');
|
|
688
|
+
cliTitle.append(el('code', 'mono', 'cs'));
|
|
689
|
+
const cliDesc = el('p', 'muted');
|
|
690
|
+
cliDesc.append(document.createTextNode('A small program on this computer. It keeps this computer online so your agents can be reached, and it opens this Companion. Run '));
|
|
691
|
+
cliDesc.append(el('code', 'mono', 'cs'));
|
|
692
|
+
cliDesc.append(document.createTextNode(' anytime to open it; '));
|
|
693
|
+
cliDesc.append(el('code', 'mono', 'cs autostart on'));
|
|
694
|
+
cliDesc.append(document.createTextNode(' keeps it running in the background.'));
|
|
695
|
+
cliWrap.append(cliTitle, cliDesc);
|
|
696
|
+
body.append(cliWrap);
|
|
697
|
+
|
|
698
|
+
const compTitle = el('h3', null, 'The Companion ');
|
|
699
|
+
const compHint = el('span', 'muted', '(this app)'); compHint.style.fontWeight = '400';
|
|
700
|
+
compTitle.append(compHint);
|
|
701
|
+
body.append(section(compTitle,
|
|
702
|
+
'Local setup for this computer. Sign in, and attach your codebases — folders on this computer that have a git remote — to your projects. Paths stay on your computer.'));
|
|
703
|
+
|
|
704
|
+
const webLink = el('a', 'btn btn-primary', 'Open the web app \\u2197');
|
|
705
|
+
webLink.href = WEB_URL; webLink.target = '_blank'; webLink.rel = 'noopener noreferrer';
|
|
706
|
+
webLink.style.alignSelf = 'flex-start'; webLink.style.marginTop = 'var(--sp-1)';
|
|
707
|
+
body.append(section(el('h3', null, 'The web app'),
|
|
708
|
+
'Where the work happens. See your projects, create work items, hand a work item to an agent, and tell the agent what to do with it: explore the code, write a document or a plan, answer questions, and more.',
|
|
709
|
+
webLink));
|
|
710
|
+
|
|
711
|
+
const footer = el('div', 'dialog-footer');
|
|
712
|
+
const doneBtn = el('button', 'btn', 'Close'); doneBtn.type = 'button';
|
|
713
|
+
footer.append(doneBtn);
|
|
714
|
+
|
|
715
|
+
dialog.append(header, body, footer);
|
|
716
|
+
panel.append(dialog);
|
|
717
|
+
backdrop.append(panel);
|
|
718
|
+
document.body.append(backdrop);
|
|
719
|
+
doneBtn.focus();
|
|
720
|
+
|
|
721
|
+
function close() {
|
|
722
|
+
backdrop.remove();
|
|
723
|
+
document.removeEventListener('keydown', onKey);
|
|
724
|
+
if (returnFocus && returnFocus.focus) returnFocus.focus();
|
|
725
|
+
}
|
|
726
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
727
|
+
document.addEventListener('keydown', onKey);
|
|
728
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
729
|
+
closeBtn.addEventListener('click', close);
|
|
730
|
+
doneBtn.addEventListener('click', close);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Screens 8 & 9: locate a not-here codebase on THIS computer. Pick a folder;
|
|
734
|
+
* if its git remote canonicalizes to the codebase's identity, its path is saved
|
|
735
|
+
* locally and this computer is reported as having the codebase. A non-matching
|
|
736
|
+
* or remote-less folder is refused. Mirrors openAddCodebaseDialog. */
|
|
737
|
+
function openLocateDialog(p, codebase, onLocated, returnFocus) {
|
|
738
|
+
let chosenLocalPath = null; // absolute path of the picked folder (local only)
|
|
739
|
+
let matches = false; // does the picked folder's remote == this identity
|
|
740
|
+
|
|
741
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
742
|
+
const panel = el('div', 'overlay-center is-compact');
|
|
743
|
+
const form = document.createElement('form');
|
|
744
|
+
form.className = 'dialog';
|
|
745
|
+
|
|
746
|
+
const header = el('header', 'dialog-header');
|
|
747
|
+
const heading = el('div', 'dialog-heading');
|
|
748
|
+
heading.append(el('h2', null, 'Locate \\u201c' + codebase.name + '\\u201d on this computer'));
|
|
749
|
+
heading.append(el('p', 'muted', p.name));
|
|
750
|
+
const closeBtn = el('button', 'dialog-close');
|
|
751
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
752
|
+
closeBtn.append(xIcon());
|
|
753
|
+
header.append(heading, closeBtn);
|
|
754
|
+
|
|
755
|
+
const body = el('div', 'dialog-body');
|
|
756
|
+
const folderField = el('div', 'field');
|
|
757
|
+
folderField.append(el('label', null, 'Folder on this computer'));
|
|
758
|
+
const folderChoose = el('div', 'folder-choose');
|
|
759
|
+
const folderVal = el('span', 'folder-path mono is-empty', 'Not chosen');
|
|
760
|
+
const chooseBtn = el('button', 'btn', 'Choose folder\\u2026'); chooseBtn.type = 'button';
|
|
761
|
+
folderChoose.append(folderVal, chooseBtn);
|
|
762
|
+
folderField.append(folderChoose);
|
|
763
|
+
|
|
764
|
+
const remoteField = el('div', 'field');
|
|
765
|
+
remoteField.append(el('label', null, 'Its git remote'));
|
|
766
|
+
const remoteVal = el('code', 'folder-path mono is-empty', 'Choose a folder to detect its git remote.');
|
|
767
|
+
remoteField.append(remoteVal);
|
|
768
|
+
|
|
769
|
+
const identityField = el('div', 'field');
|
|
770
|
+
identityField.append(el('label', null, 'Codebase identity'));
|
|
771
|
+
identityField.append(el('code', 'folder-path mono', codebase.gitRemoteUrl));
|
|
772
|
+
|
|
773
|
+
const matchRow = el('div', 'locate-match');
|
|
774
|
+
const matchPill = el('span', 'pill pill-ok', '\\u2713 matches'); matchPill.hidden = true;
|
|
775
|
+
matchRow.append(matchPill);
|
|
776
|
+
|
|
777
|
+
const error = el('div', 'form-error');
|
|
778
|
+
body.append(folderField, remoteField, identityField, matchRow, error);
|
|
779
|
+
|
|
780
|
+
const footer = el('div', 'dialog-footer');
|
|
781
|
+
const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
|
|
782
|
+
const locateHereBtn = el('button', 'btn btn-primary', 'Locate here'); locateHereBtn.type = 'submit'; locateHereBtn.disabled = true;
|
|
783
|
+
footer.append(cancelBtn, locateHereBtn);
|
|
784
|
+
|
|
785
|
+
form.append(header, body, footer);
|
|
786
|
+
panel.append(form);
|
|
787
|
+
backdrop.append(panel);
|
|
788
|
+
document.body.append(backdrop);
|
|
789
|
+
chooseBtn.focus();
|
|
790
|
+
|
|
791
|
+
function close() {
|
|
792
|
+
backdrop.remove();
|
|
793
|
+
document.removeEventListener('keydown', onKey);
|
|
794
|
+
if (returnFocus && returnFocus.focus) returnFocus.focus();
|
|
795
|
+
}
|
|
796
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
797
|
+
document.addEventListener('keydown', onKey);
|
|
798
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
799
|
+
closeBtn.addEventListener('click', close);
|
|
800
|
+
cancelBtn.addEventListener('click', close);
|
|
801
|
+
|
|
802
|
+
chooseBtn.addEventListener('click', async function () {
|
|
803
|
+
error.textContent = ''; matchPill.hidden = true; matches = false; locateHereBtn.disabled = true;
|
|
804
|
+
chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
|
|
805
|
+
const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
|
|
806
|
+
chooseBtn.disabled = false; chooseBtn.textContent = 'Choose folder\\u2026';
|
|
807
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
|
|
808
|
+
if (r.data.cancelled) return;
|
|
809
|
+
// Local path shown here only; never sent to the cloud.
|
|
810
|
+
chosenLocalPath = r.data.localPath;
|
|
811
|
+
folderVal.textContent = chosenLocalPath; folderVal.className = 'folder-path mono';
|
|
812
|
+
// codebaseRemote is the folder's canonical host/path identity (or null when
|
|
813
|
+
// it has no remote / a non-hosted remote). The match check mirrors the
|
|
814
|
+
// server's guard exactly — the server re-validates on submit regardless.
|
|
815
|
+
const detected = r.data.codebaseRemote;
|
|
816
|
+
remoteVal.textContent = r.data.gitRemoteUrl || 'none';
|
|
817
|
+
if (!detected) {
|
|
818
|
+
remoteVal.className = 'folder-path mono is-warn';
|
|
819
|
+
error.textContent = "This folder has no git remote, so it can't be this codebase.";
|
|
820
|
+
} else if (detected !== codebase.gitRemoteUrl) {
|
|
821
|
+
remoteVal.className = 'folder-path mono is-warn';
|
|
822
|
+
error.textContent = 'This folder is a different repo (' + detected + '), not ' + codebase.gitRemoteUrl + '.';
|
|
823
|
+
} else {
|
|
824
|
+
remoteVal.className = 'folder-path mono';
|
|
825
|
+
matches = true; matchPill.hidden = false; locateHereBtn.disabled = false;
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
form.addEventListener('submit', async function (e) {
|
|
830
|
+
e.preventDefault();
|
|
831
|
+
if (!matches || !chosenLocalPath) return;
|
|
832
|
+
error.textContent = '';
|
|
833
|
+
locateHereBtn.disabled = true; locateHereBtn.textContent = 'Locating';
|
|
834
|
+
// Only the codebase identity + this machine's id reach the cloud — never the
|
|
835
|
+
// path. The server re-detects the folder's remote and refuses a mismatch.
|
|
836
|
+
const r = await api('/api/projects/locate-codebase', { method: 'POST', body: JSON.stringify({ codebaseRemote: codebase.gitRemoteUrl, localPath: chosenLocalPath }) });
|
|
837
|
+
locateHereBtn.disabled = false; locateHereBtn.textContent = 'Locate here';
|
|
838
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not locate the codebase.'; return; }
|
|
839
|
+
close();
|
|
840
|
+
onLocated();
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Add-codebase flow: choose a folder, detect its git remote, and register the
|
|
845
|
+
* remote's canonical host/path identity (never the local path) as a codebase on
|
|
846
|
+
* the project. A folder with no git remote — or whose remote isn't a hosted
|
|
847
|
+
* repository URL (a local/file path) — is refused. Reuses the shared dialog. */
|
|
848
|
+
function openAddCodebaseDialog(p, onAdded, returnFocus) {
|
|
849
|
+
let chosenRemote = null; // the canonical host/path identity, or null when not addable
|
|
850
|
+
let chosenLocalPath = null; // the picked folder (local only) — used to auto-locate after add
|
|
851
|
+
|
|
852
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
853
|
+
const panel = el('div', 'overlay-center is-compact');
|
|
854
|
+
const form = document.createElement('form');
|
|
855
|
+
form.className = 'dialog';
|
|
856
|
+
|
|
857
|
+
const header = el('header', 'dialog-header');
|
|
858
|
+
const heading = el('div', 'dialog-heading');
|
|
859
|
+
heading.append(el('h2', null, 'Add codebase'));
|
|
860
|
+
heading.append(el('p', 'muted', p.name));
|
|
861
|
+
const closeBtn = el('button', 'dialog-close');
|
|
862
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
863
|
+
closeBtn.append(xIcon());
|
|
864
|
+
header.append(heading, closeBtn);
|
|
865
|
+
|
|
866
|
+
const body = el('div', 'dialog-body');
|
|
867
|
+
const folderField = el('div', 'field');
|
|
868
|
+
folderField.append(el('label', null, 'Folder on this computer'));
|
|
869
|
+
const folderChoose = el('div', 'folder-choose');
|
|
870
|
+
const folderVal = el('span', 'folder-path mono is-empty', 'Not chosen');
|
|
871
|
+
const chooseBtn = el('button', 'btn', 'Choose folder'); chooseBtn.type = 'button';
|
|
872
|
+
folderChoose.append(folderVal, chooseBtn);
|
|
873
|
+
folderField.append(folderChoose);
|
|
874
|
+
|
|
875
|
+
const remoteField = el('div', 'field');
|
|
876
|
+
remoteField.append(el('label', null, 'Git remote'));
|
|
877
|
+
const remoteVal = el('code', 'folder-path mono is-empty', 'Choose a folder to detect its git remote.');
|
|
878
|
+
remoteField.append(remoteVal);
|
|
879
|
+
|
|
880
|
+
const error = el('div', 'form-error');
|
|
881
|
+
const note = el('div', 'form-note');
|
|
882
|
+
body.append(folderField, remoteField, error, note);
|
|
883
|
+
|
|
884
|
+
const footer = el('div', 'dialog-footer');
|
|
885
|
+
const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
|
|
886
|
+
const addBtn = el('button', 'btn btn-primary', 'Add as codebase'); addBtn.type = 'submit'; addBtn.disabled = true;
|
|
887
|
+
footer.append(cancelBtn, addBtn);
|
|
888
|
+
|
|
889
|
+
form.append(header, body, footer);
|
|
890
|
+
panel.append(form);
|
|
891
|
+
backdrop.append(panel);
|
|
892
|
+
document.body.append(backdrop);
|
|
893
|
+
chooseBtn.focus();
|
|
894
|
+
|
|
895
|
+
function close() {
|
|
896
|
+
backdrop.remove();
|
|
897
|
+
document.removeEventListener('keydown', onKey);
|
|
898
|
+
if (returnFocus && returnFocus.focus) returnFocus.focus();
|
|
899
|
+
}
|
|
900
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
901
|
+
document.addEventListener('keydown', onKey);
|
|
902
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
903
|
+
closeBtn.addEventListener('click', close);
|
|
904
|
+
cancelBtn.addEventListener('click', close);
|
|
905
|
+
|
|
906
|
+
chooseBtn.addEventListener('click', async function () {
|
|
907
|
+
error.textContent = ''; note.textContent = '';
|
|
908
|
+
chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
|
|
909
|
+
const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
|
|
910
|
+
chooseBtn.disabled = false; chooseBtn.textContent = 'Choose folder';
|
|
911
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
|
|
912
|
+
if (r.data.cancelled) return;
|
|
913
|
+
// Show the chosen folder locally only — it is never sent to the cloud.
|
|
914
|
+
chosenLocalPath = r.data.localPath;
|
|
915
|
+
folderVal.textContent = r.data.localPath; folderVal.className = 'folder-path mono';
|
|
916
|
+
// codebaseRemote is the canonical host/path identity (or null when the
|
|
917
|
+
// folder's remote isn't a hosted repo). It is what gets stored, never the
|
|
918
|
+
// raw remote or the local path.
|
|
919
|
+
const identity = r.data.codebaseRemote;
|
|
920
|
+
if (!r.data.gitRemoteUrl) {
|
|
921
|
+
chosenRemote = null;
|
|
922
|
+
remoteVal.textContent = 'none'; remoteVal.className = 'folder-path mono is-warn';
|
|
923
|
+
error.textContent = "This folder has no git remote \\u2014 it can't become a codebase.";
|
|
924
|
+
addBtn.disabled = true;
|
|
925
|
+
} else if (!identity) {
|
|
926
|
+
// Has a remote, but it's a local/file path, not a hosted repository URL.
|
|
927
|
+
chosenRemote = null;
|
|
928
|
+
remoteVal.textContent = r.data.gitRemoteUrl; remoteVal.className = 'folder-path mono is-warn';
|
|
929
|
+
error.textContent = "This folder's git remote isn't a hosted repository URL, so it can't become a codebase.";
|
|
930
|
+
addBtn.disabled = true;
|
|
931
|
+
} else {
|
|
932
|
+
chosenRemote = identity;
|
|
933
|
+
remoteVal.textContent = identity; remoteVal.className = 'folder-path mono';
|
|
934
|
+
addBtn.disabled = false;
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
form.addEventListener('submit', async function (e) {
|
|
939
|
+
e.preventDefault();
|
|
940
|
+
if (!chosenRemote) return;
|
|
941
|
+
error.textContent = ''; note.textContent = '';
|
|
942
|
+
addBtn.disabled = true; addBtn.textContent = 'Adding';
|
|
943
|
+
// Only the canonical git-remote identity goes to the cloud — never the local path.
|
|
944
|
+
const r = await api('/api/projects/add-codebase', { method: 'POST', body: JSON.stringify({ projectId: p.id, gitRemoteUrl: chosenRemote }) });
|
|
945
|
+
if (!r.ok) { addBtn.disabled = false; addBtn.textContent = 'Add as codebase'; error.textContent = (r.data && r.data.error) || 'Could not add the codebase.'; return; }
|
|
946
|
+
// Auto-locate: the computer that adds a codebase inherently HAS the folder,
|
|
947
|
+
// so record this machine's availability now — it shows "On this computer"
|
|
948
|
+
// immediately. Separate call from add; the server re-validates the folder's
|
|
949
|
+
// remote (already this identity, so it passes) and only the identity + this
|
|
950
|
+
// machine's id reach the cloud, never the path. Best-effort: the add already
|
|
951
|
+
// succeeded, so a failed availability write must never block the success UX
|
|
952
|
+
// or surface an error (the user can Locate explicitly later). Swallow any
|
|
953
|
+
// rejection so the button reset + refresh below always run.
|
|
954
|
+
if (chosenLocalPath) {
|
|
955
|
+
await api('/api/projects/locate-codebase', { method: 'POST', body: JSON.stringify({ codebaseRemote: chosenRemote, localPath: chosenLocalPath }) }).catch(function () {});
|
|
956
|
+
}
|
|
957
|
+
addBtn.disabled = false; addBtn.textContent = 'Add as codebase';
|
|
958
|
+
if (r.data && r.data.added === false) {
|
|
959
|
+
// Dedupe: the project already had this remote. Say so rather than closing
|
|
960
|
+
// as if a new codebase were just created.
|
|
961
|
+
note.textContent = 'Already on this project \\u2014 no duplicate created.';
|
|
962
|
+
onAdded();
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
close();
|
|
966
|
+
onAdded();
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Opens the shared dialog shell (fixed header + body + footer, footer-right
|
|
971
|
+
* actions) — same structure/classes as the web app's Dialog primitive. */
|
|
972
|
+
function openMapDialog(p, list, returnFocus) {
|
|
973
|
+
const hasRemote = !!p.gitRemoteUrl && p.gitRemoteUrl.indexOf('local:v1:') !== 0;
|
|
974
|
+
let chosenPath = p.localPath || null;
|
|
975
|
+
|
|
976
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
977
|
+
const panel = el('div', 'overlay-center is-compact');
|
|
978
|
+
const form = document.createElement('form');
|
|
979
|
+
form.className = 'dialog';
|
|
980
|
+
|
|
981
|
+
const header = el('header', 'dialog-header');
|
|
982
|
+
const heading = el('div', 'dialog-heading');
|
|
983
|
+
heading.append(el('h2', null, p.name));
|
|
984
|
+
const closeBtn = el('button', 'dialog-close');
|
|
985
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
986
|
+
closeBtn.append(xIcon());
|
|
987
|
+
header.append(heading, closeBtn);
|
|
988
|
+
|
|
989
|
+
const body = el('div', 'dialog-body');
|
|
990
|
+
const folderField = el('div', 'field');
|
|
991
|
+
folderField.append(el('label', null, 'Local folder'));
|
|
992
|
+
const folderChoose = el('div', 'folder-choose');
|
|
993
|
+
const folderVal = el('span', chosenPath ? 'folder-path mono' : 'folder-path mono is-empty', chosenPath || 'Not chosen');
|
|
994
|
+
const chooseBtn = el('button', 'btn', 'Choose'); chooseBtn.type = 'button';
|
|
995
|
+
folderChoose.append(folderVal, chooseBtn);
|
|
996
|
+
folderField.append(folderChoose);
|
|
997
|
+
|
|
998
|
+
const remoteField = el('div', 'field');
|
|
999
|
+
const remoteLabel = el('label', null, 'Git remote URL'); remoteLabel.htmlFor = 'map-remote';
|
|
1000
|
+
const remoteInput = el('input', 'input'); remoteInput.id = 'map-remote';
|
|
1001
|
+
remoteInput.placeholder = 'git@github.com:org/repo.git';
|
|
1002
|
+
if (hasRemote) remoteInput.value = p.gitRemoteUrl;
|
|
1003
|
+
remoteField.append(remoteLabel, remoteInput);
|
|
1004
|
+
|
|
1005
|
+
const error = el('div', 'form-error');
|
|
1006
|
+
body.append(folderField, remoteField, error);
|
|
1007
|
+
|
|
1008
|
+
const footer = el('div', 'dialog-footer');
|
|
1009
|
+
const cancelBtn = el('button', 'btn', 'Cancel'); cancelBtn.type = 'button';
|
|
1010
|
+
const saveBtn = el('button', 'btn btn-primary', 'Save'); saveBtn.type = 'submit';
|
|
1011
|
+
footer.append(cancelBtn, saveBtn);
|
|
1012
|
+
|
|
1013
|
+
form.append(header, body, footer);
|
|
1014
|
+
panel.append(form);
|
|
1015
|
+
backdrop.append(panel);
|
|
1016
|
+
document.body.append(backdrop);
|
|
1017
|
+
remoteInput.focus();
|
|
1018
|
+
|
|
1019
|
+
function close() {
|
|
1020
|
+
backdrop.remove();
|
|
1021
|
+
document.removeEventListener('keydown', onKey);
|
|
1022
|
+
if (returnFocus && returnFocus.focus) returnFocus.focus();
|
|
1023
|
+
}
|
|
1024
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
1025
|
+
document.addEventListener('keydown', onKey);
|
|
1026
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
1027
|
+
closeBtn.addEventListener('click', close);
|
|
1028
|
+
cancelBtn.addEventListener('click', close);
|
|
1029
|
+
|
|
1030
|
+
chooseBtn.addEventListener('click', async function () {
|
|
1031
|
+
error.textContent = '';
|
|
1032
|
+
chooseBtn.disabled = true; chooseBtn.textContent = 'Choosing';
|
|
1033
|
+
const r = await api('/api/projects/choose-folder', { method: 'POST', body: JSON.stringify({ projectId: p.id }) });
|
|
1034
|
+
chooseBtn.disabled = false; chooseBtn.textContent = 'Choose';
|
|
1035
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not open the folder picker.'; return; }
|
|
1036
|
+
if (r.data.cancelled) return;
|
|
1037
|
+
chosenPath = r.data.localPath;
|
|
1038
|
+
folderVal.textContent = chosenPath; folderVal.className = 'folder-path mono';
|
|
1039
|
+
if (r.data.gitRemoteUrl && !remoteInput.value) remoteInput.value = r.data.gitRemoteUrl;
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
form.addEventListener('submit', async function (e) {
|
|
1043
|
+
e.preventDefault();
|
|
1044
|
+
error.textContent = '';
|
|
1045
|
+
saveBtn.disabled = true; saveBtn.textContent = 'Saving';
|
|
1046
|
+
const r = await api('/api/projects/map', { method: 'POST', body: JSON.stringify({ projectId: p.id, localPath: chosenPath, gitRemoteUrl: remoteInput.value }) });
|
|
1047
|
+
saveBtn.disabled = false; saveBtn.textContent = 'Save';
|
|
1048
|
+
if (!r.ok) { error.textContent = (r.data && r.data.error) || 'Could not save.'; return; }
|
|
1049
|
+
close();
|
|
1050
|
+
loadProjects(list);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
boot();
|
|
1055
|
+
</script>
|
|
1056
|
+
</body>
|
|
1057
|
+
</html>`;
|
|
1058
|
+
}
|