create-pmos 0.14.0 → 0.16.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/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -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 +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -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 +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
|
@@ -0,0 +1,460 @@
|
|
|
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
|
+
function srcButton(id, label) {
|
|
303
|
+
const on = state.addSrc === id;
|
|
304
|
+
return el('button', {
|
|
305
|
+
onClick: () => setState({ addSrc: id }),
|
|
306
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:'
|
|
307
|
+
+ (on ? 'var(--secondary)' : 'transparent') + ';border:1px solid ' + (on ? 'var(--input)' : 'var(--border)')
|
|
308
|
+
+ ';color:' + (on ? 'var(--foreground)' : 'var(--muted-foreground)'),
|
|
309
|
+
hover: 'border-color:var(--ring)',
|
|
310
|
+
}, ip(label));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Land on a freshly added repository — the prototype's post-ingest selection (lines 1602, 1616).
|
|
315
|
+
* `detail` is deliberately NOT touched: panel 3 stays on the add-product form so the ingest log the
|
|
316
|
+
* PM just triggered is still on screen next to the board it produced.
|
|
317
|
+
*/
|
|
318
|
+
function landOn(id, slug) {
|
|
319
|
+
setState((s) => ({
|
|
320
|
+
addedProducts: s.addedProducts.find((x) => x.id === id) ? s.addedProducts
|
|
321
|
+
: [...s.addedProducts, { ...(ADDABLE_PRODUCTS[id] || { id, sub: '0 initiatives', kind: 'github' }) }],
|
|
322
|
+
mode: 'product', product: id, sel: slug,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function alreadyAdded(id) {
|
|
327
|
+
return (state.addedProducts || []).some((x) => x.id === id);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** ⇣ connect + ingest (lines 1595–1611). */
|
|
331
|
+
function ingestGithub() {
|
|
332
|
+
if (FIXTURE) {
|
|
333
|
+
if (alreadyAdded('atlas-mobile')) { setState({ addLog: INGEST_ALREADY('atlas-mobile') }); return; }
|
|
334
|
+
// The scan imports two ADRs into knowledge alongside the board (lines 1597–1601), so the
|
|
335
|
+
// `knowledge` mode shows what the ingest claimed to have read.
|
|
336
|
+
setState((s) => ({ addLog: INGEST_LOG_GITHUB(s.addRepo), extraKnow: { ...s.extraKnow, ...INGESTED_KNOW } }));
|
|
337
|
+
landOn('atlas-mobile', firstSlugOf('atlas-mobile'));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
addThroughData(state.addRepo, 'github');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** + add product (lines 1612–1617). */
|
|
344
|
+
function addLocal() {
|
|
345
|
+
if (FIXTURE) {
|
|
346
|
+
if (alreadyAdded('notes-cli')) { setState({ addLog: INGEST_ALREADY('notes-cli') }); return; }
|
|
347
|
+
setState({ addLog: INGEST_LOG_LOCAL(state.addPath) });
|
|
348
|
+
landOn('notes-cli', firstSlugOf('notes-cli'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
addThroughData(state.addPath, 'local');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The honest path. With a data layer that can register a repository, the log is whatever that layer
|
|
356
|
+
* reports. Without one, the log says so in one line and nothing is added — the alternative is a
|
|
357
|
+
* board showing a repository the server has never heard of.
|
|
358
|
+
*/
|
|
359
|
+
function addThroughData(target, kind) {
|
|
360
|
+
const source = String(target || '').trim();
|
|
361
|
+
if (!source) {
|
|
362
|
+
setState({ addLog: [{ t: 'nothing to add — name a ' + (kind === 'github' ? 'repository' : 'folder') + ' first', c: 'var(--warning)' }] });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!dataMod || typeof dataMod.addRepo !== 'function') {
|
|
366
|
+
setState({ addLog: [
|
|
367
|
+
{ t: '$ pmos product add ' + source, c: 'var(--success)' },
|
|
368
|
+
{ t: 'no local endpoint for adding a repository — nothing was read, and nothing was written', c: 'var(--warning)' },
|
|
369
|
+
] });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setState({ addLog: [{ t: '$ pmos product add ' + source, c: 'var(--success)' }] });
|
|
373
|
+
Promise.resolve(dataMod.addRepo({ source, kind })).then((res) => {
|
|
374
|
+
const log = Array.isArray(res && res.log) ? res.log : [];
|
|
375
|
+
setState((s) => ({ addLog: [...s.addLog, ...log] }));
|
|
376
|
+
const tuple = toTuple(res && res.repo ? res.repo : res);
|
|
377
|
+
if (!tuple) return;
|
|
378
|
+
realRepos = [...(realRepos || []).filter((r) => r[0] !== tuple[0]), tuple];
|
|
379
|
+
initiativesByRepo.delete(tuple[0]);
|
|
380
|
+
// The repository is now in the real list, so it must NOT also go into `addedProducts` — that
|
|
381
|
+
// list is the fixture's in-window overlay and would render the row twice.
|
|
382
|
+
setState({ mode: 'product', product: tuple[0], sel: null });
|
|
383
|
+
if (typeof dataMod.loadInitiatives !== 'function') return;
|
|
384
|
+
Promise.resolve(dataMod.loadInitiatives(tuple[0])).then((payload) => {
|
|
385
|
+
const found = firstSlugIn(payload);
|
|
386
|
+
initiativesByRepo.set(tuple[0], found || null);
|
|
387
|
+
if (found && state.product === tuple[0] && !state.sel) setState({ sel: found });
|
|
388
|
+
}).catch(() => { /* the repository is added either way; panel 3 reports the read */ });
|
|
389
|
+
}).catch((e) => {
|
|
390
|
+
setState((s) => ({ addLog: [...s.addLog, { t: 'failed — ' + (e && e.message ? e.message : String(e)), c: 'var(--destructive)' }] }));
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The `addprod` detail body, as an array of sibling nodes. Panel 3 appends these after its own
|
|
396
|
+
* sticky header — see the module header for why this lives here rather than in `panel-detail.js`.
|
|
397
|
+
*/
|
|
398
|
+
export function addProductBody() {
|
|
399
|
+
const isGh = state.addSrc === 'github';
|
|
400
|
+
const log = state.addLog || [];
|
|
401
|
+
const out = [];
|
|
402
|
+
|
|
403
|
+
out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
|
|
404
|
+
el('div', { style: 'font-family:var(--mono);font-size:15px;font-weight:600' }, 'add a product'),
|
|
405
|
+
el('div', { style: 'font-size:12px;color:var(--muted-foreground);margin-top:2px' }, 'one product = one repo'),
|
|
406
|
+
));
|
|
407
|
+
|
|
408
|
+
out.push(el('div', { style: 'display:flex;gap:4px;padding:10px 14px 2px' },
|
|
409
|
+
srcButton('github', 'github repo'),
|
|
410
|
+
srcButton('local', 'local folder'),
|
|
411
|
+
));
|
|
412
|
+
|
|
413
|
+
if (isGh) {
|
|
414
|
+
out.push(el('div', { style: rowStyle },
|
|
415
|
+
el('span', { style: labelStyle }, 'repo'),
|
|
416
|
+
field('addRepo', state.addRepo),
|
|
417
|
+
));
|
|
418
|
+
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.7;color:var(--muted-foreground)' },
|
|
419
|
+
'Ingest plan: clone the repo → scan ',
|
|
420
|
+
el('span', { style: 'font-family:var(--mono)' }, 'planning/'),
|
|
421
|
+
' for initiative records → import ADRs into knowledge → propose a board with guessed anchors. ',
|
|
422
|
+
el('span', { style: 'color:var(--foreground)' }, 'You confirm every anchor — the scan proposes, it never decides.'),
|
|
423
|
+
));
|
|
424
|
+
out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
|
|
425
|
+
el('button', { onClick: ingestGithub, style: goStyle }, '⇣ connect + ingest'),
|
|
426
|
+
));
|
|
427
|
+
} else {
|
|
428
|
+
out.push(el('div', { style: rowStyle },
|
|
429
|
+
el('span', { style: labelStyle }, 'folder'),
|
|
430
|
+
field('addPath', state.addPath),
|
|
431
|
+
));
|
|
432
|
+
out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
433
|
+
'Starts with an empty board — nothing is imported from a bare folder. File the first initiative to begin.'));
|
|
434
|
+
out.push(el('div', { style: 'padding:2px 14px 12px;border-bottom:1px solid var(--border)' },
|
|
435
|
+
el('button', { onClick: addLocal, style: goStyle }, '+ add product'),
|
|
436
|
+
));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (log.length) {
|
|
440
|
+
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'));
|
|
441
|
+
out.push(el('div', { style: 'padding:4px 14px 12px;font-family:var(--mono);font-size:11px;line-height:1.8' },
|
|
442
|
+
log.map((l) => el('div', { style: 'color:' + l.c + ';white-space:pre-wrap;overflow-wrap:anywhere' }, ip(l.t))),
|
|
443
|
+
));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
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)' },
|
|
447
|
+
'pmos product add <repo | folder>'));
|
|
448
|
+
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Append the add-repository form to `host`. Does NOT clear — panel 3's header is already there. */
|
|
453
|
+
export function renderAddProduct(host) {
|
|
454
|
+
for (const n of addProductBody()) host.appendChild(n);
|
|
455
|
+
return host;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* Exposed for the browser suite: the drawer's exact shape is an acceptance criterion (two groups,
|
|
459
|
+
* no `workflow`), and an assertion should be able to read it without scraping the DOM. */
|
|
460
|
+
export const settingsGroups = () => DRAWER_GROUPS.map((g) => ({ label: g.label, items: [...g.items] }));
|