create-pmos 0.15.0 → 0.16.1
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 +65 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +50 -13
- package/workspace/bin/pmos.js +204 -51
- package/workspace/design-parity.js +358 -0
- package/workspace/doctor.js +54 -0
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/mobile-nav.js +32 -2
- package/workspace/lib/server-discovery.js +127 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +13 -5
- package/workspace/public/index.html +79 -409
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +905 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +553 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +168 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +174 -11
- package/workspace/focus.test.js +0 -650
- package/workspace/handoff.test.js +0 -443
- package/workspace/profiles.test.js +0 -362
- package/workspace/public/app.js +0 -5560
- package/workspace/public/styles.css +0 -2258
- package/workspace/repo-scan.test.js +0 -327
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/* pm/panel-repos.js — PANEL 1, repositories.
|
|
2
|
+
*
|
|
3
|
+
* A port of the prototype's first column (template lines 66–109) plus the `addprod` detail body it
|
|
4
|
+
* opens (template lines 730–768, derivation lines 1572–1618). Every inline `style` string below is
|
|
5
|
+
* copied from that file character for character: the comparator diffs computed style and geometry,
|
|
6
|
+
* so a "tidied" declaration is a defect, not an improvement.
|
|
7
|
+
*
|
|
8
|
+
* ── Exports ──────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
* renderRepos(host) the panel-1 renderer app.js calls (host = the open-panel container).
|
|
10
|
+
* addProductBody() the `addprod` detail body, as an ARRAY of sibling nodes. Panel 3 owns
|
|
11
|
+
* the sticky detail header and appends these after it — the add-repository
|
|
12
|
+
* form belongs to this module (build contract, ownership table) but it
|
|
13
|
+
* renders inside panel 3, exactly as the prototype does.
|
|
14
|
+
* renderAddProduct(host) convenience: appends addProductBody() to `host` WITHOUT clearing it.
|
|
15
|
+
* onAddProduct() the "+ add repository" click handler, for anything else that offers it.
|
|
16
|
+
* selectRepo(id) pick a repository — the row's own handler, exported for the test suite.
|
|
17
|
+
*
|
|
18
|
+
* ── The bare <span> around every interpolated value ──────────────────────────────────────────
|
|
19
|
+
* The prototype's template runtime renders each `{{ value }}` inside its own inline
|
|
20
|
+
* `<span class="sc-interp">`; literal text in the template is NOT wrapped. The comparator anchors
|
|
21
|
+
* its style/geometry comparison on the element that OWNS a text node, so a value moved out of that
|
|
22
|
+
* inline span would be measured as a block instead of a text run and read as a difference. `ip()`
|
|
23
|
+
* below reproduces the wrapper; it is used at exactly the positions the template has `{{ … }}`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { el, setChildren } from './dom.js';
|
|
27
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
28
|
+
import {
|
|
29
|
+
productsList, repoRow, firstSlugOf, MODE_META, SETTINGS_GROUPS, ADDABLE_PRODUCTS,
|
|
30
|
+
INGESTED_KNOW, INGEST_LOG_GITHUB, INGEST_LOG_LOCAL, INGEST_ALREADY,
|
|
31
|
+
} from './fixture.js';
|
|
32
|
+
|
|
33
|
+
/** An interpolated value, in the inline span the prototype's runtime puts it in. */
|
|
34
|
+
const ip = (v) => el('span', null, v);
|
|
35
|
+
|
|
36
|
+
/* ── the repository list ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
* Fixture mode is the prototype's two seed repositories. Normal mode asks the data layer once, at
|
|
38
|
+
* boot, and repaints when it answers; until then the list is empty, which renders an empty panel
|
|
39
|
+
* rather than a wrong one. `data.js` is imported dynamically because a panel must still come up if
|
|
40
|
+
* the data layer is absent — the shell tolerates a missing module and so does this.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
let realRepos = null; // [[id, sub, kind], …] once the data layer has answered
|
|
44
|
+
let dataMod = null;
|
|
45
|
+
const initiativesByRepo = new Map(); // repo id → first initiative slug, learned lazily
|
|
46
|
+
|
|
47
|
+
/** Normalise whatever `loadRepos()` returns into the prototype's `[id, sub, kind]` triple. */
|
|
48
|
+
function toTuple(r) {
|
|
49
|
+
if (Array.isArray(r)) return [String(r[0]), String(r[1] ?? ''), r[2] === 'local' ? 'local' : 'github'];
|
|
50
|
+
if (!r || typeof r !== 'object') return null;
|
|
51
|
+
const id = r.name || r.id || r.repo || r.key;
|
|
52
|
+
if (!id) return null;
|
|
53
|
+
const n = typeof r.count === 'number' ? r.count
|
|
54
|
+
: typeof r.initiatives === 'number' ? r.initiatives
|
|
55
|
+
: Array.isArray(r.initiatives) ? r.initiatives.length : null;
|
|
56
|
+
/* An UNREADABLE count is an em dash, never an empty string. A repository whose spine could not be
|
|
57
|
+
* read (a folder with no `planning/`, a tree that errored) rendered "local · " — a sub-line that
|
|
58
|
+
* trails off mid-sentence and reads as a bug in the row rather than as "PMOS has not counted
|
|
59
|
+
* this". The dash is the design's own vocabulary for a value that is not there. */
|
|
60
|
+
const sub = r.sub || (n === null ? '—' : n + (n === 1 ? ' initiative' : ' initiatives'));
|
|
61
|
+
const kind = (r.kind || r.source) === 'local' ? 'local' : (r.kind || r.source) === 'github' ? 'github'
|
|
62
|
+
: (r.remote || r.origin) ? 'github' : 'local';
|
|
63
|
+
return [String(id), sub, kind];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The first initiative slug of a repository, from whatever shape the data layer hands back. */
|
|
67
|
+
function firstSlugIn(payload) {
|
|
68
|
+
if (!payload) return null;
|
|
69
|
+
if (Array.isArray(payload)) {
|
|
70
|
+
for (const g of payload) {
|
|
71
|
+
if (typeof g === 'string') return g;
|
|
72
|
+
if (!g || typeof g !== 'object') continue;
|
|
73
|
+
if (Array.isArray(g.slugs) && g.slugs.length) return g.slugs[0];
|
|
74
|
+
if (g.slug) return g.slug;
|
|
75
|
+
if (g.id) return g.id;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(payload.groups)) return firstSlugIn(payload.groups);
|
|
80
|
+
if (Array.isArray(payload.initiatives)) return firstSlugIn(payload.initiatives);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function connectData() {
|
|
85
|
+
if (FIXTURE) return;
|
|
86
|
+
try { dataMod = await import('./data.js'); } catch { dataMod = null; return; }
|
|
87
|
+
if (!dataMod || typeof dataMod.loadRepos !== 'function') return;
|
|
88
|
+
try {
|
|
89
|
+
const list = await dataMod.loadRepos();
|
|
90
|
+
const tuples = (Array.isArray(list) ? list : []).map(toTuple).filter(Boolean);
|
|
91
|
+
realRepos = tuples;
|
|
92
|
+
setState({}); // repaint with the real list
|
|
93
|
+
} catch { realRepos = []; }
|
|
94
|
+
}
|
|
95
|
+
connectData();
|
|
96
|
+
|
|
97
|
+
/** allProds (line 1431) — the base repositories plus anything the PM added in this window. */
|
|
98
|
+
function repoTuples() {
|
|
99
|
+
if (FIXTURE) return productsList(state.addedProducts || []);
|
|
100
|
+
const added = (state.addedProducts || []).map((x) => [x.id, x.sub, x.kind || 'github']);
|
|
101
|
+
return [...(realRepos || []), ...added];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `product` (line 1396): the state field, defaulting to the first repository in the list. */
|
|
105
|
+
function activeProduct(tuples) {
|
|
106
|
+
if (state.product) return state.product;
|
|
107
|
+
const list = tuples || repoTuples();
|
|
108
|
+
return list.length ? list[0][0] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* THE SELECTED REPOSITORY IS A FACT, NOT A COINCIDENCE.
|
|
112
|
+
* `state.product` is null until something sets it. Real mode gets it from `loadRepos()`; fixture
|
|
113
|
+
* mode had nothing to set it, so until the PM clicked a row it stayed null and each panel fell back
|
|
114
|
+
* to "the first one in the list" on its own. The screen looks right — every fallback picks the same
|
|
115
|
+
* repository — but the FIELD that says which repository the window is standing in is empty, and
|
|
116
|
+
* everything that reads it rather than re-deriving it gets no answer: panel 4's tab scoping, the
|
|
117
|
+
* phone chrome, and any check that asks the app which repository it is showing. Set once, at load,
|
|
118
|
+
* from the same list the rows are drawn from, so the highlighted row and `state.product` are the
|
|
119
|
+
* same statement. */
|
|
120
|
+
if (FIXTURE && !state.product) {
|
|
121
|
+
const firstTuple = repoTuples()[0];
|
|
122
|
+
if (firstTuple) setState({ product: firstTuple[0] });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The initiative selected when a repository is picked (line 1440), where it is known synchronously. */
|
|
126
|
+
function firstSlugSync(id) {
|
|
127
|
+
if (FIXTURE) return firstSlugOf(id);
|
|
128
|
+
if (initiativesByRepo.has(id)) return initiativesByRepo.get(id);
|
|
129
|
+
if (dataMod && typeof dataMod.firstSlugOf === 'function') {
|
|
130
|
+
try { return dataMod.firstSlugOf(id) || null; } catch { /* fall through */ }
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pick a repository. Verbatim from the prototype's row handler (lines 1439–1442): the repository
|
|
137
|
+
* becomes current, the board goes back to `product` mode — so a PM who wandered into `skills` and
|
|
138
|
+
* then switched repository lands on the board, not on another repository's skills — and the first
|
|
139
|
+
* initiative of that repository is selected. A repository with no initiatives selects nothing and
|
|
140
|
+
* the detail panel says so; it does not keep the previous repository's selection.
|
|
141
|
+
*/
|
|
142
|
+
export function selectRepo(id) {
|
|
143
|
+
const slug = firstSlugSync(id);
|
|
144
|
+
setState({ product: id, mode: 'product', sel: slug, detail: slug ? { kind: 'init' } : { kind: 'none' } });
|
|
145
|
+
if (slug || FIXTURE || !dataMod || typeof dataMod.loadInitiatives !== 'function') return;
|
|
146
|
+
Promise.resolve(dataMod.loadInitiatives(id)).then((payload) => {
|
|
147
|
+
const found = firstSlugIn(payload);
|
|
148
|
+
initiativesByRepo.set(id, found || null);
|
|
149
|
+
if (found && state.product === id && !state.sel) setState({ sel: found, detail: { kind: 'init' } });
|
|
150
|
+
}).catch(() => { /* an unreadable repository still selects; panel 3 reports the read */ });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── settings & tools ────────────────────────────────────────────────────────────────────────
|
|
154
|
+
* EXACTLY two groups, and no `workflow` entry. The prototype's `modeMeta` still carries `workflow`
|
|
155
|
+
* because the mode exists in code, but it is deliberately absent from the drawer: a workflow is
|
|
156
|
+
* decided by the agent at runtime, not configured by hand (PRD, panel-1 scope). The filter below is
|
|
157
|
+
* belt and braces — it makes the absence a property of THIS module, so it cannot regress by someone
|
|
158
|
+
* adding an id to the seed list.
|
|
159
|
+
*/
|
|
160
|
+
const DRAWER_GROUPS = SETTINGS_GROUPS.map((g) => ({
|
|
161
|
+
label: g.label,
|
|
162
|
+
items: g.items.filter((id) => id !== 'workflow' && MODE_META[id]),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
function drawerButton(id) {
|
|
166
|
+
const [glyph, label] = MODE_META[id];
|
|
167
|
+
const on = state.mode === id;
|
|
168
|
+
return el('button', {
|
|
169
|
+
onClick: () => setState({ mode: id, settingsOpen: false, col1: true }),
|
|
170
|
+
style: 'display:flex;align-items:center;gap:9px;width:100%;padding:7px 14px;min-height:38px;border-left:2px solid '
|
|
171
|
+
+ (on ? 'var(--brand)' : 'transparent') + ';background:' + (on ? 'var(--accent)' : 'transparent')
|
|
172
|
+
+ ';color:' + (on ? 'var(--foreground)' : 'var(--muted-foreground)'),
|
|
173
|
+
hover: 'background:var(--accent)',
|
|
174
|
+
},
|
|
175
|
+
el('span', { style: 'flex:none;width:16px;text-align:center;font-size:13px;line-height:1' }, ip(glyph)),
|
|
176
|
+
el('span', { style: 'font-size:12px' }, ip(label)),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function drawer() {
|
|
181
|
+
const kids = [];
|
|
182
|
+
if (state.settingsOpen) {
|
|
183
|
+
const groups = [];
|
|
184
|
+
for (const g of DRAWER_GROUPS) {
|
|
185
|
+
groups.push(el('div', {
|
|
186
|
+
style: 'padding:8px 14px 3px;font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)',
|
|
187
|
+
}, ip(g.label)));
|
|
188
|
+
for (const id of g.items) groups.push(drawerButton(id));
|
|
189
|
+
}
|
|
190
|
+
kids.push(el('div', { style: 'max-height:44vh;overflow-y:auto;border-bottom:1px solid var(--border)' }, groups));
|
|
191
|
+
}
|
|
192
|
+
kids.push(el('button', {
|
|
193
|
+
onClick: () => setState((s) => ({ settingsOpen: !s.settingsOpen })),
|
|
194
|
+
style: 'display:flex;align-items:center;gap:9px;width:100%;padding:11px 14px;min-height:44px;color:var(--muted-foreground)',
|
|
195
|
+
hover: 'color:var(--foreground);background:var(--accent)',
|
|
196
|
+
},
|
|
197
|
+
el('span', { style: 'flex:none;width:14px;text-align:center;font-size:14px' }, '⚙'),
|
|
198
|
+
el('span', { style: 'font-size:12px' }, 'settings & tools'),
|
|
199
|
+
el('span', { style: 'margin-left:auto;font-size:10px' }, ip(state.settingsOpen ? '▾' : '▸')),
|
|
200
|
+
));
|
|
201
|
+
return el('div', { style: 'border-top:1px solid var(--border);flex:none' }, kids);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── the rows ────────────────────────────────────────────────────────────────────────────────── */
|
|
205
|
+
|
|
206
|
+
function repoButton(tuple, product) {
|
|
207
|
+
const rp = repoRow(tuple);
|
|
208
|
+
const active = product === rp.name;
|
|
209
|
+
const gc = active ? (rp.kind === 'github' ? 'var(--info)' : 'var(--brand)') : 'var(--muted-foreground)';
|
|
210
|
+
return el('button', {
|
|
211
|
+
onClick: () => selectRepo(rp.name),
|
|
212
|
+
style: 'display:flex;align-items:center;gap:9px;width:100%;padding:9px 14px;min-height:46px;border-left:2px solid '
|
|
213
|
+
+ (active ? 'var(--brand)' : 'transparent') + ';background:' + (active ? 'var(--accent)' : 'transparent'),
|
|
214
|
+
hover: 'background:var(--accent)',
|
|
215
|
+
},
|
|
216
|
+
el('span', {
|
|
217
|
+
style: 'flex:none;width:16px;text-align:center;font-size:14px;line-height:1;color:' + gc,
|
|
218
|
+
title: rp.kindLabel + ' repository',
|
|
219
|
+
}, ip(rp.kindGlyph)),
|
|
220
|
+
el('span', { style: 'min-width:0' },
|
|
221
|
+
el('span', { style: 'display:block;font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, ip(rp.name)),
|
|
222
|
+
el('span', { style: 'display:block;font-size:10.5px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, ip(rp.displaySub)),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** setDetail (line 1401) — open panel 3 on a detail kind, un-collapsing it if the PM folded it. */
|
|
228
|
+
const setDetail = (d) => setState({ detail: d, col2: true });
|
|
229
|
+
|
|
230
|
+
/** onAddProduct (line 1444). */
|
|
231
|
+
export function onAddProduct() { setDetail({ kind: 'addprod' }); }
|
|
232
|
+
|
|
233
|
+
/* ── the panel ───────────────────────────────────────────────────────────────────────────────── */
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fill panel 1. `host` IS the prototype's open-panel container (app.js owns its style), so this
|
|
237
|
+
* appends the panel's three parts as direct children and never wraps them.
|
|
238
|
+
*/
|
|
239
|
+
export function renderRepos(host) {
|
|
240
|
+
const tuples = repoTuples();
|
|
241
|
+
const product = activeProduct(tuples);
|
|
242
|
+
|
|
243
|
+
const header = el('div', {
|
|
244
|
+
style: 'display:flex;align-items:center;gap:8px;padding:11px 10px 10px 14px;border-bottom:1px solid var(--border)',
|
|
245
|
+
},
|
|
246
|
+
el('span', { style: 'font-size:14px;font-weight:600;letter-spacing:.01em' }, 'repositories'),
|
|
247
|
+
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, ip(String(tuples.length))),
|
|
248
|
+
el('button', {
|
|
249
|
+
onClick: () => setState((s) => ({ col0: !s.col0 })),
|
|
250
|
+
style: 'color:var(--muted-foreground);font-size:14px;padding:2px 6px',
|
|
251
|
+
hover: 'color:var(--foreground)',
|
|
252
|
+
}, '⟨'),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const scroll = el('div', { style: 'flex:1;overflow-y:auto;min-height:0' },
|
|
256
|
+
tuples.map((t) => repoButton(t, product)),
|
|
257
|
+
el('button', {
|
|
258
|
+
onClick: onAddProduct,
|
|
259
|
+
style: 'display:flex;align-items:center;gap:9px;width:100%;padding:9px 14px;min-height:40px;color:var(--muted-foreground)',
|
|
260
|
+
hover: 'color:var(--foreground);background:var(--accent)',
|
|
261
|
+
},
|
|
262
|
+
el('span', { style: 'flex:none;width:14px;text-align:center;font-size:14px' }, '+'),
|
|
263
|
+
el('span', { style: 'font-size:12px' }, 'add repository'),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
setChildren(host, [header, scroll, drawer()]);
|
|
268
|
+
return host;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── the add-repository form (panel 3, detail kind `addprod`) ────────────────────────────────
|
|
272
|
+
* The two sources the prototype offers: a GitHub repo, which is cloned and scanned, and a local
|
|
273
|
+
* folder, which starts with an empty board because nothing can be imported from a bare folder.
|
|
274
|
+
*
|
|
275
|
+
* In FIXTURE mode the two buttons reproduce the prototype's scripted ingest exactly — the same log
|
|
276
|
+
* lines, the same two repositories, the same landing selection — because the comparator's captured
|
|
277
|
+
* "09-add-repo" state is that script. Outside fixture mode the form posts the declaration to the
|
|
278
|
+
* data layer if it offers `addRepo()`, and otherwise reports, in the log, that nothing was added.
|
|
279
|
+
* It never prints a log line describing work that did not happen: a scripted "clone … ok" against a
|
|
280
|
+
* repo nobody read is precisely the watermelon this PRD names.
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
const inputStyle = 'font-family:var(--mono);font-size:11.5px;padding:6px 9px;border:1px solid var(--input);min-height:32px';
|
|
284
|
+
const labelStyle = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-top:8px';
|
|
285
|
+
const rowStyle = 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:10px 14px';
|
|
286
|
+
const goStyle = 'padding:5px 12px;min-height:30px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-size:12px;font-weight:500';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A form field. `oninput` writes the value straight onto the state object WITHOUT a render — a
|
|
290
|
+
* repaint between keystrokes would rebuild the input and take the caret with it — and `onchange`
|
|
291
|
+
* commits with a real `setState` so everything derived from the field catches up.
|
|
292
|
+
*/
|
|
293
|
+
function field(key, value) {
|
|
294
|
+
return el('input', {
|
|
295
|
+
value,
|
|
296
|
+
style: inputStyle,
|
|
297
|
+
onInput: (e) => { state[key] = e.target.value; },
|
|
298
|
+
onChange: (e) => setState({ [key]: e.target.value }),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ── choosing the folder, instead of typing its path ─────────────────────────────────────────
|
|
303
|
+
* The form's local branch used to be a bare text field, so "add the repo I am looking at" meant
|
|
304
|
+
* knowing its absolute path and typing it correctly — and the two endpoints that exist to avoid
|
|
305
|
+
* exactly that (`/api/pick-folder`, `/api/fs/list`) had no caller anywhere in the front end.
|
|
306
|
+
*
|
|
307
|
+
* Two ways in, because one of them is not always there: the machine's native dialog first, and the
|
|
308
|
+
* in-app browser when there is no native chooser (headless Linux), when one is already open, or
|
|
309
|
+
* when the PM asks for it. A cancelled dialog changes nothing — it is an answer, not a failure.
|
|
310
|
+
*/
|
|
311
|
+
const browseRowStyle = 'display:grid;grid-template-columns:76px 1fr auto;gap:10px;padding:10px 14px';
|
|
312
|
+
const chooseStyle = 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:32px;'
|
|
313
|
+
+ 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground)';
|
|
314
|
+
|
|
315
|
+
/** The in-app browser, opened at `dir` (empty = the server's idea of home). */
|
|
316
|
+
function openBrowse(dir) {
|
|
317
|
+
// Fixture mode reads NOTHING off this machine — the comparator drives this surface, and a run
|
|
318
|
+
// that opened a dialog or listed the PM's home directory would be doing real work behind a
|
|
319
|
+
// scripted board. It says so instead of degrading into a vaguer "no endpoint" line.
|
|
320
|
+
if (FIXTURE) {
|
|
321
|
+
setState({ addLog: [{ t: 'the folder chooser is off in fixture mode — nothing on this machine is read', c: 'var(--warning)' }] });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!dataMod || typeof dataMod.listFolder !== 'function') {
|
|
325
|
+
setState({ addLog: [{ t: 'no local endpoint for browsing folders — type the path instead', c: 'var(--warning)' }] });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
setState({ addBrowse: { loading: true, path: dir || '', entries: [] } });
|
|
329
|
+
Promise.resolve(dataMod.listFolder(dir)).then((res) => {
|
|
330
|
+
if (!res || res.ok === false) {
|
|
331
|
+
setState({ addBrowse: { path: dir || '', entries: [], error: (res && res.error) || 'that folder could not be listed' } });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setState({ addBrowse: res });
|
|
335
|
+
}).catch((e) => setState({ addBrowse: { path: dir || '', entries: [], error: e && e.message ? e.message : String(e) } }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Ask the server to open the machine's folder dialog; fall back to the in-app browser. */
|
|
339
|
+
function chooseFolder() {
|
|
340
|
+
if (FIXTURE || !dataMod || typeof dataMod.chooseFolder !== 'function') { openBrowse(state.addPath); return; }
|
|
341
|
+
setState({ addPicking: true });
|
|
342
|
+
Promise.resolve(dataMod.chooseFolder(state.addPath)).then((res) => {
|
|
343
|
+
setState({ addPicking: false });
|
|
344
|
+
if (res && res.ok && res.root) { setState({ addPath: res.root, addBrowse: null }); return; }
|
|
345
|
+
if (res && res.cancelled) return; // the PM closed the dialog: no change, no noise
|
|
346
|
+
openBrowse(state.addPath); // unsupported, busy, or unreachable → in-app
|
|
347
|
+
}).catch(() => { setState({ addPicking: false }); openBrowse(state.addPath); });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Take the folder currently open in the browser as the answer. */
|
|
351
|
+
function useFolder(p) {
|
|
352
|
+
if (!p) return;
|
|
353
|
+
setState({ addPath: p, addBrowse: null });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const browseRow = (label, title, onClick, muted) => el('button', {
|
|
357
|
+
onClick, title,
|
|
358
|
+
style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px;min-height:28px;text-align:left;'
|
|
359
|
+
+ 'font-family:var(--mono);font-size:11px;color:var(--' + (muted ? 'muted-foreground' : 'foreground') + ')',
|
|
360
|
+
hover: 'background:var(--accent)',
|
|
361
|
+
}, label);
|
|
362
|
+
|
|
363
|
+
/** The browser body: where we are, one row per sub-directory, and a way to take the current one. */
|
|
364
|
+
function browseBody() {
|
|
365
|
+
const b = state.addBrowse;
|
|
366
|
+
const rows = [];
|
|
367
|
+
rows.push(el('div', { style: 'display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--border)' },
|
|
368
|
+
el('span', { style: 'flex:1;font-family:var(--mono);font-size:11px;overflow-wrap:anywhere' }, ip(b.path || '…')),
|
|
369
|
+
el('button', { onClick: () => useFolder(b.path), style: chooseStyle + ';min-height:26px', hover: 'border-color:var(--ring)' },
|
|
370
|
+
ip('use this folder')),
|
|
371
|
+
));
|
|
372
|
+
if (b.error) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--destructive)' }, ip(b.error)));
|
|
373
|
+
else if (b.loading) rows.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('reading…')));
|
|
374
|
+
else {
|
|
375
|
+
const list = [];
|
|
376
|
+
if (b.parent) list.push(browseRow('↑ ..', b.parent, () => openBrowse(b.parent), true));
|
|
377
|
+
for (const e of b.entries || []) {
|
|
378
|
+
// The repo marker is the whole point of showing this list rather than a plain file chooser:
|
|
379
|
+
// it says which of these folders PMOS can actually open as a workspace.
|
|
380
|
+
list.push(browseRow((e.isRepo ? '⑂ ' : '· ') + e.name, e.path, () => openBrowse(e.path), !e.isRepo));
|
|
381
|
+
}
|
|
382
|
+
if (b.denied) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--warning)' }, ip('this folder cannot be read')));
|
|
383
|
+
else if (!list.length) list.push(el('div', { style: 'padding:8px 10px;font-size:11.5px;color:var(--muted-foreground)' }, ip('no sub-folders here')));
|
|
384
|
+
rows.push(el('div', { style: 'max-height:220px;overflow-y:auto' }, list));
|
|
385
|
+
}
|
|
386
|
+
return el('div', { style: 'margin:0 14px 10px;border:1px solid var(--border)' }, rows);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function srcButton(id, label) {
|
|
390
|
+
const on = state.addSrc === id;
|
|
391
|
+
return el('button', {
|
|
392
|
+
onClick: () => setState({ addSrc: id }),
|
|
393
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:'
|
|
394
|
+
+ (on ? 'var(--secondary)' : 'transparent') + ';border:1px solid ' + (on ? 'var(--input)' : 'var(--border)')
|
|
395
|
+
+ ';color:' + (on ? 'var(--foreground)' : 'var(--muted-foreground)'),
|
|
396
|
+
hover: 'border-color:var(--ring)',
|
|
397
|
+
}, ip(label));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Land on a freshly added repository — the prototype's post-ingest selection (lines 1602, 1616).
|
|
402
|
+
* `detail` is deliberately NOT touched: panel 3 stays on the add-product form so the ingest log the
|
|
403
|
+
* PM just triggered is still on screen next to the board it produced.
|
|
404
|
+
*/
|
|
405
|
+
function landOn(id, slug) {
|
|
406
|
+
setState((s) => ({
|
|
407
|
+
addedProducts: s.addedProducts.find((x) => x.id === id) ? s.addedProducts
|
|
408
|
+
: [...s.addedProducts, { ...(ADDABLE_PRODUCTS[id] || { id, sub: '0 initiatives', kind: 'github' }) }],
|
|
409
|
+
mode: 'product', product: id, sel: slug,
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function alreadyAdded(id) {
|
|
414
|
+
return (state.addedProducts || []).some((x) => x.id === id);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** ⇣ connect + ingest (lines 1595–1611). */
|
|
418
|
+
function ingestGithub() {
|
|
419
|
+
if (FIXTURE) {
|
|
420
|
+
if (alreadyAdded('atlas-mobile')) { setState({ addLog: INGEST_ALREADY('atlas-mobile') }); return; }
|
|
421
|
+
// The scan imports two ADRs into knowledge alongside the board (lines 1597–1601), so the
|
|
422
|
+
// `knowledge` mode shows what the ingest claimed to have read.
|
|
423
|
+
setState((s) => ({ addLog: INGEST_LOG_GITHUB(s.addRepo), extraKnow: { ...s.extraKnow, ...INGESTED_KNOW } }));
|
|
424
|
+
landOn('atlas-mobile', firstSlugOf('atlas-mobile'));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
addThroughData(state.addRepo, 'github');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** + add product (lines 1612–1617). */
|
|
431
|
+
function addLocal() {
|
|
432
|
+
if (FIXTURE) {
|
|
433
|
+
if (alreadyAdded('notes-cli')) { setState({ addLog: INGEST_ALREADY('notes-cli') }); return; }
|
|
434
|
+
setState({ addLog: INGEST_LOG_LOCAL(state.addPath) });
|
|
435
|
+
landOn('notes-cli', firstSlugOf('notes-cli'));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
addThroughData(state.addPath, 'local');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The honest path. With a data layer that can register a repository, the log is whatever that layer
|
|
443
|
+
* reports. Without one, the log says so in one line and nothing is added — the alternative is a
|
|
444
|
+
* board showing a repository the server has never heard of.
|
|
445
|
+
*/
|
|
446
|
+
function addThroughData(target, kind) {
|
|
447
|
+
const source = String(target || '').trim();
|
|
448
|
+
if (!source) {
|
|
449
|
+
setState({ addLog: [{ t: 'nothing to add — name a ' + (kind === 'github' ? 'repository' : 'folder') + ' first', c: 'var(--warning)' }] });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (!dataMod || typeof dataMod.addRepo !== 'function') {
|
|
453
|
+
setState({ addLog: [
|
|
454
|
+
{ t: '$ pmos product add ' + source, c: 'var(--success)' },
|
|
455
|
+
{ t: 'no local endpoint for adding a repository — nothing was read, and nothing was written', c: 'var(--warning)' },
|
|
456
|
+
] });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
setState({ addLog: [{ t: '$ pmos product add ' + source, c: 'var(--success)' }] });
|
|
460
|
+
Promise.resolve(dataMod.addRepo({ source, kind })).then((res) => {
|
|
461
|
+
const log = Array.isArray(res && res.log) ? res.log : [];
|
|
462
|
+
setState((s) => ({ addLog: [...s.addLog, ...log] }));
|
|
463
|
+
const tuple = toTuple(res && res.repo ? res.repo : res);
|
|
464
|
+
if (!tuple) return;
|
|
465
|
+
realRepos = [...(realRepos || []).filter((r) => r[0] !== tuple[0]), tuple];
|
|
466
|
+
initiativesByRepo.delete(tuple[0]);
|
|
467
|
+
// The repository is now in the real list, so it must NOT also go into `addedProducts` — that
|
|
468
|
+
// list is the fixture's in-window overlay and would render the row twice.
|
|
469
|
+
setState({ mode: 'product', product: tuple[0], sel: null });
|
|
470
|
+
if (typeof dataMod.loadInitiatives !== 'function') return;
|
|
471
|
+
Promise.resolve(dataMod.loadInitiatives(tuple[0])).then((payload) => {
|
|
472
|
+
const found = firstSlugIn(payload);
|
|
473
|
+
initiativesByRepo.set(tuple[0], found || null);
|
|
474
|
+
if (found && state.product === tuple[0] && !state.sel) setState({ sel: found });
|
|
475
|
+
}).catch(() => { /* the repository is added either way; panel 3 reports the read */ });
|
|
476
|
+
}).catch((e) => {
|
|
477
|
+
setState((s) => ({ addLog: [...s.addLog, { t: 'failed — ' + (e && e.message ? e.message : String(e)), c: 'var(--destructive)' }] }));
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The `addprod` detail body, as an array of sibling nodes. Panel 3 appends these after its own
|
|
483
|
+
* sticky header — see the module header for why this lives here rather than in `panel-detail.js`.
|
|
484
|
+
*/
|
|
485
|
+
export function addProductBody() {
|
|
486
|
+
const isGh = state.addSrc === 'github';
|
|
487
|
+
const log = state.addLog || [];
|
|
488
|
+
const out = [];
|
|
489
|
+
|
|
490
|
+
out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
|
|
491
|
+
el('div', { style: 'font-family:var(--mono);font-size:15px;font-weight:600' }, 'add a product'),
|
|
492
|
+
el('div', { style: 'font-size:12px;color:var(--muted-foreground);margin-top:2px' }, 'one product = one repo'),
|
|
493
|
+
));
|
|
494
|
+
|
|
495
|
+
out.push(el('div', { style: 'display:flex;gap:4px;padding:10px 14px 2px' },
|
|
496
|
+
srcButton('github', 'github repo'),
|
|
497
|
+
srcButton('local', 'local folder'),
|
|
498
|
+
));
|
|
499
|
+
|
|
500
|
+
if (isGh) {
|
|
501
|
+
out.push(el('div', { style: rowStyle },
|
|
502
|
+
el('span', { style: labelStyle }, 'repo'),
|
|
503
|
+
field('addRepo', state.addRepo),
|
|
504
|
+
));
|
|
505
|
+
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.7;color:var(--muted-foreground)' },
|
|
506
|
+
'Ingest plan: clone the repo → scan ',
|
|
507
|
+
el('span', { style: 'font-family:var(--mono)' }, 'planning/'),
|
|
508
|
+
' for initiative records → import ADRs into knowledge → propose a board with guessed anchors. ',
|
|
509
|
+
el('span', { style: 'color:var(--foreground)' }, 'You confirm every anchor — the scan proposes, it never decides.'),
|
|
510
|
+
));
|
|
511
|
+
out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
|
|
512
|
+
el('button', { onClick: ingestGithub, style: goStyle }, '⇣ connect + ingest'),
|
|
513
|
+
));
|
|
514
|
+
} else {
|
|
515
|
+
// Field FIRST, chooser beside it: the path is still typeable (and still the thing that gets
|
|
516
|
+
// registered), the button only fills it in. The row keeps label + input as its first two
|
|
517
|
+
// children, which is the shape panel 1's suite reads.
|
|
518
|
+
out.push(el('div', { style: browseRowStyle },
|
|
519
|
+
el('span', { style: labelStyle }, 'folder'),
|
|
520
|
+
field('addPath', state.addPath),
|
|
521
|
+
el('button', { onClick: chooseFolder, style: chooseStyle, hover: 'border-color:var(--ring)' },
|
|
522
|
+
ip(state.addPicking ? 'choosing…' : 'choose…')),
|
|
523
|
+
));
|
|
524
|
+
if (state.addBrowse) out.push(browseBody());
|
|
525
|
+
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
526
|
+
'Starts with an empty board — nothing is imported from a bare folder. File the first initiative to begin.'));
|
|
527
|
+
out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
|
|
528
|
+
el('button', { onClick: addLocal, style: goStyle }, '+ add product'),
|
|
529
|
+
));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (log.length) {
|
|
533
|
+
out.push(el('div', { style: 'padding:9px 14px 4px;font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)' }, 'ingest log'));
|
|
534
|
+
out.push(el('div', { style: 'padding:4px 14px 12px;font-family:var(--mono);font-size:11px;line-height:1.8' },
|
|
535
|
+
log.map((l) => el('div', { style: 'color:' + l.c + ';white-space:pre-wrap;overflow-wrap:anywhere' }, ip(l.t))),
|
|
536
|
+
));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
out.push(el('div', { style: 'margin-top:auto;padding:8px 14px;border-top:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
540
|
+
'pmos product add <repo | folder>'));
|
|
541
|
+
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Append the add-repository form to `host`. Does NOT clear — panel 3's header is already there. */
|
|
546
|
+
export function renderAddProduct(host) {
|
|
547
|
+
for (const n of addProductBody()) host.appendChild(n);
|
|
548
|
+
return host;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* Exposed for the browser suite: the drawer's exact shape is an acceptance criterion (two groups,
|
|
552
|
+
* no `workflow`), and an assertion should be able to read it without scraping the DOM. */
|
|
553
|
+
export const settingsGroups = () => DRAWER_GROUPS.map((g) => ({ label: g.label, items: [...g.items] }));
|