@floless/app 0.73.0 → 0.74.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/dist/floless-server.cjs +334 -209
- package/dist/web/app.css +62 -1
- package/dist/web/index.html +5 -0
- package/dist/web/steel-3d-view.js +46 -22
- package/dist/web/steel-editor.html +75 -30
- package/dist/web/workspaces.js +139 -0
- package/package.json +1 -1
package/dist/web/workspaces.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
const $stepTabs = document.getElementById('ws-step-tabs');
|
|
32
32
|
const $projMenu = document.getElementById('ws-proj-menu');
|
|
33
33
|
const $wsExports = document.getElementById('ws-exports');
|
|
34
|
+
const $wsHistory = document.getElementById('ws-history');
|
|
34
35
|
const frames = {
|
|
35
36
|
model: document.getElementById('ws-frame-model'),
|
|
36
37
|
drawings: document.getElementById('ws-frame-drawings'),
|
|
@@ -201,6 +202,11 @@
|
|
|
201
202
|
const showExports = s === 'exports';
|
|
202
203
|
$wsExports.hidden = !showExports;
|
|
203
204
|
if (showExports) renderExports();
|
|
205
|
+
// History is likewise a shell-rendered pane: the project's version ledger.
|
|
206
|
+
const showHistory = s === 'history';
|
|
207
|
+
$wsHistory.hidden = !showHistory;
|
|
208
|
+
if (showHistory) renderHistory();
|
|
209
|
+
else closeRollbackConfirm(); // don't leave a rollback popover open behind a step switch
|
|
204
210
|
}
|
|
205
211
|
$stepTabs.addEventListener('click', (e) => {
|
|
206
212
|
const b = e.target.closest('button[data-step]');
|
|
@@ -227,6 +233,7 @@
|
|
|
227
233
|
if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
|
|
228
234
|
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
229
235
|
if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
|
|
236
|
+
if (!$wsHistory.hidden) renderHistory(); // a new version was recorded — reflect it + clear the banner
|
|
230
237
|
} catch (err) {
|
|
231
238
|
showToast('Approve failed: ' + (err && err.message || err), 'warn');
|
|
232
239
|
} finally { btn.disabled = false; btn.textContent = prev; }
|
|
@@ -360,6 +367,138 @@
|
|
|
360
367
|
if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
|
|
361
368
|
});
|
|
362
369
|
|
|
370
|
+
// ── History step ────────────────────────────────────────────────────────────────
|
|
371
|
+
// The project's version ledger. A version = a commit (Approve = Model gate, Rollback = unsigned).
|
|
372
|
+
// The draft contract is the working tree; an unapproved draft shows the working-copy banner.
|
|
373
|
+
const histWhen = (iso) => {
|
|
374
|
+
const d = new Date(iso);
|
|
375
|
+
return isNaN(d.getTime()) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false });
|
|
376
|
+
};
|
|
377
|
+
// Avatar: the human is "You" (neutral fill); an AI author (future rebake) gets the accent fill —
|
|
378
|
+
// accent already means "the AI acted" elsewhere in the baseline, so it's a free scan signal.
|
|
379
|
+
const avatarFor = (author) =>
|
|
380
|
+
author === 'You'
|
|
381
|
+
? '<span class="avatar you">YOU</span>'
|
|
382
|
+
: `<span class="avatar ai">${escapeHtml((author || '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || 'AI')}</span>`;
|
|
383
|
+
const gateBadge = (gate) =>
|
|
384
|
+
gate === 'model'
|
|
385
|
+
? '<span class="gate-badge model" data-tip="Model gate — the geometry was signed off">Model</span>'
|
|
386
|
+
: '<span class="gate-badge" data-tip="Unsigned — a working-tree restore, not a sign-off">unsigned</span>';
|
|
387
|
+
|
|
388
|
+
let versions = []; // last-rendered ledger (newest first)
|
|
389
|
+
|
|
390
|
+
function versionRowHtml(v) {
|
|
391
|
+
// Rolling back to the version you're already on is a no-op — omit the button (blank cell), never disable it.
|
|
392
|
+
const action = v.current
|
|
393
|
+
? ''
|
|
394
|
+
: `<button type="button" class="btn-mini" data-rollback="${escapeAttr(String(v.n))}" data-tip="Restore this version as a new version">↺ Rollback</button>`;
|
|
395
|
+
return `<tr class="${v.current ? 'current' : ''}">` +
|
|
396
|
+
`<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
|
|
397
|
+
`<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
|
|
398
|
+
`<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
|
|
399
|
+
`<td>${gateBadge(v.gate)}</td>` +
|
|
400
|
+
`<td class="change">${escapeHtml(v.message)}</td>` +
|
|
401
|
+
`<td>${action}</td></tr>`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function renderHistory() {
|
|
405
|
+
if (!current) return;
|
|
406
|
+
const id = current.id;
|
|
407
|
+
$wsHistory.innerHTML = '<div class="hist-note">Loading history…</div>';
|
|
408
|
+
let rows = [];
|
|
409
|
+
let approvedAt;
|
|
410
|
+
try {
|
|
411
|
+
const r = await api(`/api/projects/${encodeURIComponent(id)}/versions`);
|
|
412
|
+
rows = Array.isArray(r.versions) ? r.versions : [];
|
|
413
|
+
approvedAt = r.approvedAt || undefined;
|
|
414
|
+
current.approvedAt = approvedAt; // re-sync — an in-editor autosave clears the sign-off server-side
|
|
415
|
+
} catch (err) {
|
|
416
|
+
showToast('Couldn’t load history: ' + ((err && err.message) || err), 'warn');
|
|
417
|
+
if (current && current.id === id && !$wsHistory.hidden) {
|
|
418
|
+
$wsHistory.innerHTML = '<div class="hist-empty">History unavailable — try again.</div>';
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (!current || current.id !== id || $wsHistory.hidden) return; // switched away mid-await
|
|
423
|
+
versions = rows;
|
|
424
|
+
if (!rows.length) {
|
|
425
|
+
// Empty = an inline-action banner (never a bare header row that reads as broken/loading).
|
|
426
|
+
$wsHistory.innerHTML =
|
|
427
|
+
'<div class="hist-empty">No versions yet — <button type="button" id="hist-approve">Approve the model</button> ' +
|
|
428
|
+
'to create <b>v1</b> and start the ledger.</div>';
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Working copy = the draft; when it isn't signed off (approvedAt cleared by an edit or a
|
|
432
|
+
// rollback) surface it honestly ABOVE the table (not as a fake row) and name the Exports lock.
|
|
433
|
+
const working = !approvedAt
|
|
434
|
+
? '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
|
|
435
|
+
'<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>'
|
|
436
|
+
: '';
|
|
437
|
+
$wsHistory.innerHTML =
|
|
438
|
+
'<div class="hist-note">Every <b>Approve</b> and <b>Rollback</b> records a version. Rollback is non-destructive — it restores a version as a <i>new</i> one.</div>' +
|
|
439
|
+
working +
|
|
440
|
+
// Version/Gate/action are fixed-narrow; When/By auto-size to their nowrap content (fixing them
|
|
441
|
+
// wider than the content wastes room and needlessly wraps Change). Change flexes to the rest.
|
|
442
|
+
'<table class="hist"><thead><tr>' +
|
|
443
|
+
'<th style="width:44px">Version</th><th>When</th><th>By</th>' +
|
|
444
|
+
'<th style="width:80px">Gate</th><th style="width:99%">Change</th><th style="width:92px"></th>' +
|
|
445
|
+
'</tr></thead><tbody>' + rows.map(versionRowHtml).join('') + '</tbody></table>';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Rollback confirm — a styled anchored popover (never a native dialog); names the Exports re-lock.
|
|
449
|
+
function onDocDownForConfirm(e) {
|
|
450
|
+
if (!e.target.closest('.hist-confirm') && !e.target.closest('[data-rollback]')) closeRollbackConfirm();
|
|
451
|
+
}
|
|
452
|
+
function closeRollbackConfirm() {
|
|
453
|
+
const pop = $wsHistory.querySelector('.hist-confirm');
|
|
454
|
+
if (pop) pop.remove();
|
|
455
|
+
document.removeEventListener('mousedown', onDocDownForConfirm, true);
|
|
456
|
+
}
|
|
457
|
+
function showRollbackConfirm(btn, n) {
|
|
458
|
+
closeRollbackConfirm();
|
|
459
|
+
const pop = document.createElement('div');
|
|
460
|
+
pop.className = 'hist-confirm';
|
|
461
|
+
pop.innerHTML =
|
|
462
|
+
`<div class="hc-title">Restore v${escapeHtml(String(n))} as a new version?</div>` +
|
|
463
|
+
`<div class="hc-body">History isn’t erased — this adds a new version copied from v${escapeHtml(String(n))}. ` +
|
|
464
|
+
`Exports re-lock until you Approve again.</div>` +
|
|
465
|
+
`<div class="hc-act"><button type="button" class="hc-cancel">Cancel</button>` +
|
|
466
|
+
`<button type="button" class="hc-go" data-hc-go="${escapeAttr(String(n))}">↺ Rollback to v${escapeHtml(String(n))}</button></div>`;
|
|
467
|
+
$wsHistory.appendChild(pop);
|
|
468
|
+
const pr = $wsHistory.getBoundingClientRect();
|
|
469
|
+
const br = btn.getBoundingClientRect();
|
|
470
|
+
pop.style.top = br.bottom - pr.top + $wsHistory.scrollTop + 6 + 'px';
|
|
471
|
+
pop.style.left = Math.max(8, Math.min(br.right - pr.left - pop.offsetWidth, $wsHistory.clientWidth - pop.offsetWidth - 8)) + 'px';
|
|
472
|
+
document.addEventListener('mousedown', onDocDownForConfirm, true);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function rollbackTo(n) {
|
|
476
|
+
if (!current) return;
|
|
477
|
+
const proj = current;
|
|
478
|
+
closeRollbackConfirm();
|
|
479
|
+
try {
|
|
480
|
+
await api(`/api/projects/${encodeURIComponent(proj.id)}/versions/${encodeURIComponent(n)}/rollback`, { method: 'POST' });
|
|
481
|
+
proj.approvedAt = undefined; // rollback re-locks exports (the server cleared the sign-off)
|
|
482
|
+
showToast(`Restored v${n} as a new version — Exports re-locked; Approve to re-enable`, 'ok');
|
|
483
|
+
// Reload the editor iframe so it shows the restored geometry (it fetched the contract on load).
|
|
484
|
+
frames.model.dataset.loaded = '';
|
|
485
|
+
frames.model.src = 'about:blank';
|
|
486
|
+
if (!frames.model.hidden) { frames.model.src = frames.model.dataset.want; frames.model.dataset.loaded = '1'; }
|
|
487
|
+
renderHistory();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
showToast('Rollback failed: ' + ((err && err.message) || err), 'warn');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
$wsHistory.addEventListener('click', (e) => {
|
|
494
|
+
if (e.target.closest('#hist-approve')) { approveProject(); return; }
|
|
495
|
+
if (e.target.closest('.hc-cancel')) { closeRollbackConfirm(); return; }
|
|
496
|
+
const go = e.target.closest('[data-hc-go]');
|
|
497
|
+
if (go) { rollbackTo(Number(go.dataset.hcGo)); return; }
|
|
498
|
+
const roll = e.target.closest('[data-rollback]');
|
|
499
|
+
if (roll) { showRollbackConfirm(roll, Number(roll.dataset.rollback)); return; }
|
|
500
|
+
});
|
|
501
|
+
|
|
363
502
|
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|
|
364
503
|
let menuFor = null;
|
|
365
504
|
function menuItem(action, label) {
|