@floless/app 0.18.0 → 0.19.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 +394 -153
- package/dist/web/app.css +19 -0
- package/dist/web/aware.js +168 -9
- package/package.json +1 -1
package/dist/web/app.css
CHANGED
|
@@ -1096,6 +1096,11 @@
|
|
|
1096
1096
|
/* A disabled action reads as inert — never a primary that looks clickable but isn't. */
|
|
1097
1097
|
.modal-actions button:disabled { opacity: 0.5; cursor: default; }
|
|
1098
1098
|
.modal-actions button.primary:disabled:hover { background: var(--accent); box-shadow: none; }
|
|
1099
|
+
/* Destructive confirm (#85): the commit button is neutral at rest and turns
|
|
1100
|
+
error-red only on hover, so an accidental Enter (focus sits on the accent Cancel)
|
|
1101
|
+
never deletes — matching the app's other destructive affordances. */
|
|
1102
|
+
.modal-actions button.danger { border-color: var(--border-strong); color: var(--text); font-weight: 600; }
|
|
1103
|
+
.modal-actions button.danger:hover { color: var(--err); border-color: var(--err); background: rgba(248, 113, 113, 0.1); box-shadow: none; }
|
|
1099
1104
|
.modal.library {
|
|
1100
1105
|
width: 720px;
|
|
1101
1106
|
max-height: 82vh;
|
|
@@ -1585,6 +1590,20 @@
|
|
|
1585
1590
|
.wf-option.selected .wf-option-name { color: var(--accent); }
|
|
1586
1591
|
.wf-option-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1587
1592
|
.wf-option-meta { font-family: var(--mono); font-size: 10px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
|
1593
|
+
/* Per-row lifecycle actions (#85) — hover/keyboard-revealed, mirroring the #68
|
|
1594
|
+
template chip's ✎/× treatment one level up (act on an installed workflow). */
|
|
1595
|
+
.wf-option-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
|
|
1596
|
+
.wf-act {
|
|
1597
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1598
|
+
width: 20px; height: 20px; padding: 0; border: 1px solid transparent; border-radius: 3px;
|
|
1599
|
+
background: transparent; color: var(--text-dim); font: inherit; font-size: 12px; line-height: 1;
|
|
1600
|
+
cursor: pointer; opacity: 0; transition: opacity .12s ease, color .12s, background .12s, border-color .12s;
|
|
1601
|
+
}
|
|
1602
|
+
.wf-option:hover .wf-act, .wf-option.highlighted .wf-act { opacity: 0.45; }
|
|
1603
|
+
.wf-act:hover { opacity: 1; }
|
|
1604
|
+
.wf-act.act-edit:hover { color: var(--accent); background: var(--accent-soft); }
|
|
1605
|
+
.wf-act.act-dup:hover { color: var(--text); border-color: var(--border-strong); }
|
|
1606
|
+
.wf-act.act-del:hover { color: var(--err); background: rgba(248, 113, 113, 0.1); border-color: var(--err); }
|
|
1588
1607
|
.wf-empty-search { color: var(--text-dim); font-style: italic; font-size: 12px; padding: 16px 10px; text-align: center; }
|
|
1589
1608
|
.wf-empty-search[hidden] { display: none; }
|
|
1590
1609
|
|
package/dist/web/aware.js
CHANGED
|
@@ -578,8 +578,17 @@
|
|
|
578
578
|
html += `<div class="wf-group" role="group" aria-label="${escapeAttr(providerLabel(p))}"><div class="wf-group-header">${escapeHtml(providerLabel(p))}</div>`;
|
|
579
579
|
for (const a of rows) {
|
|
580
580
|
const meta = `${escapeHtml(String(a.nodes))} node${a.nodes === 1 ? '' : 's'} · ${escapeHtml(String(a.layout))}`;
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
// A div (not a button) carries role="option" so the action icons can be real
|
|
582
|
+
// <button>s without nesting interactive elements (#85). Selection stays driven by
|
|
583
|
+
// the delegated $wfList click + comboMove highlight (aria-activedescendant).
|
|
584
|
+
const aid = escapeAttr(a.id);
|
|
585
|
+
html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
|
|
586
|
+
+ `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
|
|
587
|
+
+ `<span class="wf-option-actions">`
|
|
588
|
+
+ `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
|
|
589
|
+
+ `<button type="button" class="wf-act act-dup" data-wf-act="duplicate" data-id="${aid}" tabindex="-1" data-tip="Duplicate" aria-label="Duplicate ${aid}">⧉</button>`
|
|
590
|
+
+ `<button type="button" class="wf-act act-del" data-wf-act="delete" data-id="${aid}" tabindex="-1" data-tip="Delete" aria-label="Delete ${aid}">×</button>`
|
|
591
|
+
+ `</span></div>`;
|
|
583
592
|
}
|
|
584
593
|
html += '</div>';
|
|
585
594
|
}
|
|
@@ -695,6 +704,15 @@
|
|
|
695
704
|
}
|
|
696
705
|
if ($wfList) {
|
|
697
706
|
$wfList.addEventListener('click', (e) => {
|
|
707
|
+
// A row action (#85) takes precedence over selecting the workflow.
|
|
708
|
+
const act = e.target.closest('[data-wf-act]');
|
|
709
|
+
if (act) {
|
|
710
|
+
const id = act.dataset.id;
|
|
711
|
+
if (act.dataset.wfAct === 'rename') openRenameWorkflow(id);
|
|
712
|
+
else if (act.dataset.wfAct === 'duplicate') openDuplicateWorkflow(id);
|
|
713
|
+
else if (act.dataset.wfAct === 'delete') confirmDeleteWorkflow(id);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
698
716
|
const o = e.target.closest('.wf-option');
|
|
699
717
|
if (o) selectComboById(o.dataset.id);
|
|
700
718
|
});
|
|
@@ -703,6 +721,112 @@
|
|
|
703
721
|
if ($wfPopover && !$wfPopover.hidden && $wfCombo && !$wfCombo.contains(e.target)) closeCombo(false);
|
|
704
722
|
});
|
|
705
723
|
|
|
724
|
+
// ── Workflow lifecycle actions: rename / duplicate / delete (#85) ─────────────
|
|
725
|
+
// The server owns the fs work + `aware app compile`/`uninstall` + the app-id cascade
|
|
726
|
+
// (visual inputs, bound routines). Here we drive the modal, migrate the CLIENT-side
|
|
727
|
+
// saved input VALUES (localStorage, keyed by app id), set LS_LAST_APP so the
|
|
728
|
+
// apps-changed SSE → loadApps() reopens the right workflow, and toast the outcome.
|
|
729
|
+
function migrateInputsLS(fromId, toId) {
|
|
730
|
+
try {
|
|
731
|
+
const v = localStorage.getItem(lsInputsKey(fromId));
|
|
732
|
+
if (v != null) localStorage.setItem(lsInputsKey(toId), v);
|
|
733
|
+
} catch { /* private mode / quota */ }
|
|
734
|
+
}
|
|
735
|
+
function dropClientAppState(id) {
|
|
736
|
+
try { localStorage.removeItem(lsInputsKey(id)); } catch { /* ignore */ }
|
|
737
|
+
appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function openRenameWorkflow(oldId) {
|
|
741
|
+
closeCombo(false);
|
|
742
|
+
const res = await formModal({
|
|
743
|
+
title: 'Rename workflow',
|
|
744
|
+
sub: 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.',
|
|
745
|
+
fields: [{ name: 'name', label: 'New name', type: 'text', value: oldId, placeholder: 'e.g. tekla-bom-by-phase' }],
|
|
746
|
+
okLabel: 'Rename',
|
|
747
|
+
});
|
|
748
|
+
if (!res) return;
|
|
749
|
+
const newId = String(res.name || '').trim();
|
|
750
|
+
if (!newId || newId === oldId) return;
|
|
751
|
+
showToast('Renaming…', 'ok');
|
|
752
|
+
try {
|
|
753
|
+
const r = await api(`/api/app/${encodeURIComponent(oldId)}/rename`, { method: 'PATCH', body: JSON.stringify({ name: newId }) });
|
|
754
|
+
// Carry saved input VALUES + any unsaved in-memory edits/dirty baseline to the new id
|
|
755
|
+
// so a rename never silently drops the user's inputs (#85 review). loadApp() only seeds
|
|
756
|
+
// inputs when the id is absent, so pre-setting them here preserves unsaved edits.
|
|
757
|
+
migrateInputsLS(oldId, r.id);
|
|
758
|
+
if (appInputValues.has(oldId)) appInputValues.set(r.id, appInputValues.get(oldId));
|
|
759
|
+
if (dirtyBaseline.has(oldId)) dirtyBaseline.set(r.id, dirtyBaseline.get(oldId));
|
|
760
|
+
const wasOpen = currentId === oldId;
|
|
761
|
+
dropClientAppState(oldId);
|
|
762
|
+
if (wasOpen) { try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } }
|
|
763
|
+
await loadApps();
|
|
764
|
+
showToast(r.compiled ? `Renamed to “${r.id}”` : `Renamed to “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
|
|
765
|
+
} catch (e) {
|
|
766
|
+
console.error(e); // an unexpected client throw (not a clean server message) stays debuggable
|
|
767
|
+
showToast(e.message || 'Rename failed', 'warn');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function openDuplicateWorkflow(srcId) {
|
|
772
|
+
closeCombo(false);
|
|
773
|
+
const res = await formModal({
|
|
774
|
+
title: 'Duplicate workflow',
|
|
775
|
+
sub: 'Makes an independent copy you can edit and run separately — compiled and ready.',
|
|
776
|
+
fields: [{ name: 'name', label: 'New name', type: 'text', value: `${srcId}-copy`, placeholder: 'e.g. tekla-bom-by-phase-copy' }],
|
|
777
|
+
okLabel: 'Duplicate',
|
|
778
|
+
});
|
|
779
|
+
if (!res) return;
|
|
780
|
+
const newId = String(res.name || '').trim();
|
|
781
|
+
if (!newId) return;
|
|
782
|
+
showToast('Duplicating…', 'ok');
|
|
783
|
+
try {
|
|
784
|
+
const r = await api(`/api/app/${encodeURIComponent(srcId)}/duplicate`, { method: 'POST', body: JSON.stringify({ name: newId }) });
|
|
785
|
+
migrateInputsLS(srcId, r.id); // the copy starts with the source's saved inputs
|
|
786
|
+
try { localStorage.setItem(LS_LAST_APP, r.id); } catch { /* ignore */ } // open the copy
|
|
787
|
+
await loadApps();
|
|
788
|
+
showToast(r.compiled ? `Duplicated as “${r.id}”` : `Duplicated as “${r.id}” — needs Compile`, r.compiled ? 'ok' : 'warn');
|
|
789
|
+
} catch (e) {
|
|
790
|
+
console.error(e);
|
|
791
|
+
showToast(e.message || 'Duplicate failed', 'warn');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function confirmDeleteWorkflow(id) {
|
|
796
|
+
// Warn honestly if a routine (schedule OR trigger) is bound — the server cascade removes it.
|
|
797
|
+
let routineNote = '';
|
|
798
|
+
try {
|
|
799
|
+
const { routines } = await api('/api/routines');
|
|
800
|
+
const bound = (routines || []).filter((r) => r.workflow === id).length;
|
|
801
|
+
if (bound) routineNote = ` Its ${bound} bound routine${bound === 1 ? '' : 's'} will also be removed.`;
|
|
802
|
+
} catch {
|
|
803
|
+
// Couldn't read routines — never UNDER-warn before an irreversible delete: state the
|
|
804
|
+
// worst case so the user knows bound routines (if any) go too (the cascade still removes them).
|
|
805
|
+
routineNote = ' Any routines bound to it will also be removed.';
|
|
806
|
+
}
|
|
807
|
+
closeCombo(false);
|
|
808
|
+
const ok = await formModal({
|
|
809
|
+
title: 'Delete workflow?',
|
|
810
|
+
sub: `Permanently delete “${id}” — its source, lock and saved inputs.${routineNote} This can’t be undone.`,
|
|
811
|
+
fields: [],
|
|
812
|
+
okLabel: 'Delete',
|
|
813
|
+
danger: true,
|
|
814
|
+
});
|
|
815
|
+
if (!ok) return;
|
|
816
|
+
showToast('Deleting…', 'ok');
|
|
817
|
+
try {
|
|
818
|
+
await api(`/api/app/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
819
|
+
const wasOpen = currentId === id;
|
|
820
|
+
dropClientAppState(id);
|
|
821
|
+
if (wasOpen) { currentId = null; try { localStorage.removeItem(LS_LAST_APP); } catch { /* ignore */ } }
|
|
822
|
+
await loadApps();
|
|
823
|
+
showToast(`Deleted “${id}”`, 'ok');
|
|
824
|
+
} catch (e) {
|
|
825
|
+
console.error(e);
|
|
826
|
+
showToast(e.message || 'Delete failed', 'err'); // a failed destructive delete reads as an error
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
706
830
|
// ── app inputs + HTML Viewer ────────────────────────────────────────────────
|
|
707
831
|
|
|
708
832
|
// The id of the report-viewer node — the terminal node a user double-clicks to
|
|
@@ -1349,7 +1473,7 @@
|
|
|
1349
1473
|
// `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
|
|
1350
1474
|
// { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
|
|
1351
1475
|
const $formModal = document.getElementById('form-modal');
|
|
1352
|
-
function formModal({ title, sub = '', fields, okLabel = 'Save' }) {
|
|
1476
|
+
function formModal({ title, sub = '', fields, okLabel = 'Save', danger = false }) {
|
|
1353
1477
|
return new Promise((resolve) => {
|
|
1354
1478
|
const $title = document.getElementById('form-modal-title');
|
|
1355
1479
|
const $sub = document.getElementById('form-modal-sub');
|
|
@@ -1387,9 +1511,16 @@
|
|
|
1387
1511
|
return `<div class="modal-field"><label for="${id}">${escapeHtml(f.label || f.name)}</label>${ctl}</div>`;
|
|
1388
1512
|
}).join('');
|
|
1389
1513
|
$ok.textContent = okLabel;
|
|
1514
|
+
// A destructive confirm flips the emphasis: the commit button is neutral→red
|
|
1515
|
+
// (.danger) and the SAFE Cancel becomes the accent primary + gets focus, so a
|
|
1516
|
+
// stray Enter never deletes (#85).
|
|
1517
|
+
$ok.classList.toggle('danger', !!danger);
|
|
1518
|
+
$ok.classList.toggle('primary', !danger);
|
|
1519
|
+
$cancel.classList.toggle('primary', !!danger);
|
|
1390
1520
|
showModal($formModal);
|
|
1391
1521
|
const first = $body.querySelector('input:not(.fm-file-input),textarea');
|
|
1392
1522
|
if (first) { first.focus(); if (first.select) first.select(); }
|
|
1523
|
+
else if (danger) $cancel.focus();
|
|
1393
1524
|
// ── images-field setup ──────────────────────────────────────────────────────
|
|
1394
1525
|
const imageStates = new Map();
|
|
1395
1526
|
$body.querySelectorAll('.fm-images').forEach((box) => {
|
|
@@ -1505,15 +1636,19 @@
|
|
|
1505
1636
|
};
|
|
1506
1637
|
const done = (result) => {
|
|
1507
1638
|
hideModal($formModal);
|
|
1508
|
-
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $
|
|
1639
|
+
$ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
|
|
1640
|
+
$ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
|
|
1509
1641
|
resolve(result);
|
|
1510
1642
|
};
|
|
1511
1643
|
$ok.onclick = () => done(collect());
|
|
1512
1644
|
$cancel.onclick = () => done(null);
|
|
1513
1645
|
onBackdropDismiss($formModal, () => done(null));
|
|
1514
|
-
$body
|
|
1515
|
-
|
|
1516
|
-
|
|
1646
|
+
// Keydown at the MODAL level (not $body) so Esc cancels regardless of focus — e.g. a
|
|
1647
|
+
// destructive confirm where focus sits on Cancel, not in the (empty) body. Enter
|
|
1648
|
+
// commits only for a normal form (never a danger confirm; never inside a textarea).
|
|
1649
|
+
$formModal.onkeydown = (e) => {
|
|
1650
|
+
if (e.key === 'Escape') { e.preventDefault(); done(null); }
|
|
1651
|
+
else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
|
|
1517
1652
|
};
|
|
1518
1653
|
});
|
|
1519
1654
|
}
|
|
@@ -1919,7 +2054,25 @@
|
|
|
1919
2054
|
// The server derives the id from the file's `app:` field, installs it, and we select
|
|
1920
2055
|
// it so the user can Compile (we never auto-compile — Run stays a deliberate act).
|
|
1921
2056
|
const $importFile = document.getElementById('import-file');
|
|
1922
|
-
|
|
2057
|
+
// Prefer the File System Access API: its types[].description names the OS picker's filter
|
|
2058
|
+
// ("FloLess workflow"), whereas a plain <input accept=…> with several extensions renders as
|
|
2059
|
+
// the generic "Custom Files". Fall back to the hidden <input> where the API is absent
|
|
2060
|
+
// (Firefox/Safari). Called synchronously from the menu-click gesture (no await before the
|
|
2061
|
+
// picker call), so the required user-activation is preserved.
|
|
2062
|
+
async function triggerImport() {
|
|
2063
|
+
if (typeof window.showOpenFilePicker === 'function') {
|
|
2064
|
+
let handle;
|
|
2065
|
+
try {
|
|
2066
|
+
[handle] = await window.showOpenFilePicker({
|
|
2067
|
+
types: [{ description: 'FloLess workflow', accept: { 'text/plain': ['.flo', '.app', '.flow', '.aware'] } }],
|
|
2068
|
+
multiple: false,
|
|
2069
|
+
});
|
|
2070
|
+
} catch { return; } // the user dismissed the picker (AbortError) — nothing to import
|
|
2071
|
+
try { importFlo(await handle.getFile()); } catch { showToast('Could not read that file', 'err'); }
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
if ($importFile) $importFile.click();
|
|
2075
|
+
}
|
|
1923
2076
|
|
|
1924
2077
|
async function importFlo(file) {
|
|
1925
2078
|
if (!file) return;
|
|
@@ -3100,7 +3253,13 @@
|
|
|
3100
3253
|
// The host AI (or anyone) edited this app's .flo/.lock in the terminal —
|
|
3101
3254
|
// refresh so the canvas + gate reflect reality.
|
|
3102
3255
|
clearTimeout(reloadTimer);
|
|
3103
|
-
reloadTimer = setTimeout(() =>
|
|
3256
|
+
reloadTimer = setTimeout(() => {
|
|
3257
|
+
// Skip if the open app was renamed/removed out from under us (#85) — the
|
|
3258
|
+
// apps-changed handler already refreshes the picker, and reloading a gone id
|
|
3259
|
+
// would 404. dropClientAppState() removes it from `apps` before this fires.
|
|
3260
|
+
if (!currentId || !apps.has(currentId)) return;
|
|
3261
|
+
loadApp(currentId).catch(reportErr);
|
|
3262
|
+
}, 250);
|
|
3104
3263
|
} else if (m.type === 'seat-taken') {
|
|
3105
3264
|
// This session's seat was claimed by another device (newest-login-wins).
|
|
3106
3265
|
showToast('Signed in on another device — sign in here to continue.', 'err');
|