Finish Electron shell and workflow wiring
Template tests / tests (push) Failing after 4s

This commit is contained in:
Iisyourdad
2026-06-10 18:32:30 -05:00
parent a5bbdde480
commit f47aca67c2
22 changed files with 5002 additions and 2 deletions
+440
View File
@@ -0,0 +1,440 @@
'use strict';
/**
* Small modal factories used by the renderer. They stay intentionally plain:
* a modal title, a few form rows, and action buttons. No decorative clutter.
*/
function labeledRow(labelText, control, { stacked = false } = {}) {
return el(stacked ? 'div.form-row.stacked' : 'div.form-row', {},
el('label', {}, labelText),
control
);
}
function makeInput(value = '', type = 'text', attrs = {}) {
return el('input', { type, value, ...attrs });
}
function makeSelect(value, options) {
return el('select', {},
options.map((opt) => el('option', { value: opt.value, selected: opt.value === value }, opt.label))
);
}
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
return new Promise((resolve) => {
const field = multiline
? el('textarea', { rows: 6, placeholder }, value)
: el('input', { type: 'text', value, placeholder });
const { close } = openModal({
title,
body: labeledRow(label, field, { stacked: multiline }),
footer: [
el('button', { onClick: () => { close(); resolve(null); } }, 'Cancel'),
el('button.primary', { onClick: () => { close(); resolve(field.value); } }, 'OK'),
],
onClose: () => resolve(null),
});
field.addEventListener('keydown', (e) => {
if (!multiline && e.key === 'Enter') {
e.preventDefault();
close();
resolve(field.value);
}
});
setTimeout(() => field.focus(), 0);
});
}
function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onClose } = {}) {
return new Promise((resolve) => {
const input = el('input', {
type: 'search',
value: query,
placeholder: 'Search guides, steps, and commands',
autocomplete: 'off',
spellcheck: false,
});
const results = el('div.qa-results');
const hint = el('div.muted', {}, 'Type to search, arrows to move, Enter to open.');
let items = [];
let active = 0;
function renderItems() {
clearNode(results);
if (!items.length) {
results.append(el('div.muted', { style: { padding: '8px 2px' } }, 'No matches.'));
return;
}
items.forEach((item, idx) => {
results.append(el('div.qa-item', {
className: `qa-item${idx === active ? ' active' : ''}`,
onMouseenter: () => { active = idx; renderItems(); },
onClick: () => choose(idx),
},
el('span.kind', {}, item.kind || 'cmd'),
el('div', {},
el('div', { style: { fontWeight: 600 } }, item.label),
item.description ? el('div.snippet', {}, item.description) : null,
)));
});
}
function choose(idx = active) {
const item = items[idx];
if (!item) return;
close();
if (item.action) item.action();
if (onOpenItem) onOpenItem(item);
resolve(item);
}
async function refresh() {
const q = input.value.trim();
const commandMatches = commands.filter((cmd) => {
if (!q) return true;
const needle = q.toLowerCase();
return `${cmd.label} ${cmd.description || ''}`.toLowerCase().includes(needle);
}).map((cmd) => ({ ...cmd, kind: cmd.kind || 'cmd' }));
const searchResults = q && searchFn ? await searchFn(q) : [];
items = [...commandMatches, ...searchResults];
if (active >= items.length) active = 0;
renderItems();
}
const { close } = openModal({
title: 'Quick Actions',
body: el('div.quick-actions', {},
input,
hint,
results,
),
wide: true,
footer: [
el('button', { onClick: () => { close(); resolve(null); } }, 'Close'),
],
onClose: () => {
if (onClose) onClose();
resolve(null);
},
});
const debounced = debounce(refresh, 60);
input.addEventListener('input', debounced);
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(items.length - 1, active + 1); renderItems(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); renderItems(); }
else if (e.key === 'Enter') { e.preventDefault(); choose(); }
else if (e.key === 'Escape') { e.preventDefault(); close(); resolve(null); }
});
refresh();
setTimeout(() => input.focus(), 0);
});
}
function showSettingsDialog({
settings,
placeholders = {},
onSave,
} = {}) {
return new Promise((resolve) => {
const form = el('form', { className: 'settings-form' });
const appearance = makeSelect(settings.appearance || 'system', [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]);
const spellcheck = el('input', { type: 'checkbox', checked: Boolean(settings.spellcheck) });
const delayMs = makeInput(settings.capture?.delayMs ?? 0, 'number', { min: 0, step: 50 });
const captureMode = makeSelect(settings.capture?.mode || 'fullscreen', [
{ value: 'fullscreen', label: 'Fullscreen' },
{ value: 'window', label: 'Window' },
{ value: 'region', label: 'Region' },
]);
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text');
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text');
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
const placeholderRows = el('div', { className: 'placeholder-rows' });
const rows = [];
const addPlaceholderRow = (key = '', value = '') => {
const keyInput = makeInput(key);
const valueInput = makeInput(value);
const removeBtn = el('button.icon', {
type: 'button',
title: 'Remove placeholder',
onClick: () => {
row.remove();
rows.splice(rows.indexOf(row), 1);
},
}, '');
const row = el('div.placeholder-row', {},
keyInput,
valueInput,
removeBtn,
);
rows.push(row);
placeholderRows.append(row);
return row;
};
Object.entries(placeholders || {}).forEach(([k, v]) => addPlaceholderRow(k, v));
const addPlaceholderBtn = el('button', {
type: 'button',
onClick: () => addPlaceholderRow(),
}, 'Add placeholder');
form.append(
el('fieldset', {},
el('legend', {}, 'Appearance'),
labeledRow('Theme', appearance),
labeledRow('Spellcheck', spellcheck),
labeledRow('Open folder after export', openFolder),
),
el('fieldset', {},
el('legend', {}, 'Capture'),
labeledRow('Default mode', captureMode),
labeledRow('Delay (ms)', delayMs),
labeledRow('Click marker', clickMarker),
labeledRow('Capture outside clicks', captureOutside),
labeledRow('Confirm simple capture', confirmSimple),
labeledRow('Capture hotkey', captureHotkey),
labeledRow('Pause / resume hotkey', pauseHotkey),
),
el('fieldset', {},
el('legend', {}, 'Editor'),
labeledRow('Focused view for new steps', focusedDefault),
labeledRow('Preview step count', previewCount),
),
el('fieldset', {},
el('legend', {}, 'Backups'),
labeledRow('Keep last snapshots', keepLast),
),
el('fieldset', {},
el('legend', {}, 'Global placeholders'),
placeholderRows,
el('div.row', { style: { justifyContent: 'flex-start' } }, addPlaceholderBtn),
),
);
const { close } = openModal({
title: 'Settings',
body: form,
wide: true,
footer: [
el('button', { type: 'button', onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
type: 'submit',
onClick: async (e) => {
e.preventDefault();
const next = {
appearance: appearance.value,
spellcheck: spellcheck.checked,
capture: {
...settings.capture,
delayMs: Number(delayMs.value || 0),
mode: captureMode.value,
clickMarker: clickMarker.checked,
hotkeyCapture: captureHotkey.value.trim(),
hotkeyPauseResume: pauseHotkey.value.trim(),
captureOutsideClicks: captureOutside.checked,
confirmSimpleCapture: confirmSimple.checked,
},
editor: {
...settings.editor,
focusedViewDefaultForNewSteps: focusedDefault.checked,
},
exports: {
...settings.exports,
previewStepCount: Number(previewCount.value || 3),
openFolderAfterExport: openFolder.checked,
},
backups: {
...settings.backups,
keepLast: Number(keepLast.value || 0),
},
placeholders: rows.reduce((acc, row) => {
const inputs = row.querySelectorAll('input');
const key = inputs[0].value.trim();
const value = inputs[1].value;
if (key) acc[key] = value;
return acc;
}, {}),
};
await onSave(next);
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
form.addEventListener('submit', (e) => e.preventDefault());
});
}
function showExportDialog({
formats,
templatesByFormat = {},
defaultFormat = 'pdf',
defaultOutDir = '',
onChooseDir,
onExport,
onPreview,
} = {}) {
return new Promise((resolve) => {
const formatOptions = (formats || []).map((f) => {
if (typeof f === 'string') return { value: f, label: f };
return { value: f.id || f.value || f.name, label: f.label || f.id || f.value || f.name };
});
const formatSelect = makeSelect(defaultFormat, formatOptions);
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' });
const info = el('div.muted', {}, 'Templates are optional. If no template is selected, exporter defaults are used.');
function refreshTemplates() {
const list = templatesByFormat[formatSelect.value] || [];
clearNode(templateSelect);
templateSelect.append(el('option', { value: '' }, 'Default template'));
for (const name of list) templateSelect.append(el('option', { value: name }, name));
}
formatSelect.addEventListener('change', refreshTemplates);
refreshTemplates();
const body = el('div.export-dialog', {},
labeledRow('Format', formatSelect),
labeledRow('Template', templateSelect),
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
type: 'button',
disabled: typeof onChooseDir !== 'function',
onClick: async () => {
if (typeof onChooseDir !== 'function') return;
const chosen = await onChooseDir(formatSelect.value);
if (chosen) outDirInput.value = chosen;
},
}, 'Choose…'))),
info,
);
const { close } = openModal({
title: 'Export',
body,
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
const ok = await onPreview({
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
if (ok !== false) {
close();
resolve(true);
}
},
}, 'Preview'),
el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
const ok = await onExport({
format: formatSelect.value,
templateName: templateSelect.value || null,
outDir: outDirInput.value.trim() || null,
});
if (ok !== false) {
close();
resolve(true);
}
},
}, 'Export'),
],
wide: true,
onClose: () => resolve(false),
});
});
}
function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) {
return new Promise((resolve) => {
const linked = guide.linkedSource || {};
const conflict = lock && !lock.acquired;
const conflictInfo = lock && lock.conflict ? lock.conflict : {};
const lockInfo = conflict
? `Locked by ${conflictInfo.user || 'another user'}@${conflictInfo.host || 'another host'}`
: 'No active conflict';
const body = el('div', { className: 'linked-guide' },
el('div', { className: 'card-list' },
el('div.row', {}, el('span.muted', {}, 'Archive'), el('strong', {}, linked.path || 'Not linked')),
el('div.row', {}, el('span.muted', {}, 'Opened'), el('span', {}, fmtDate(linked.openedAt) || 'Unknown')),
el('div.row', {}, el('span.muted', {}, 'Last saved'), el('span', {}, fmtDate(linked.lastSavedAt) || 'Never')),
el('div.row', {}, el('span.muted', {}, 'Lock'), el('span', {}, lockInfo)),
),
conflict ? el('div', { className: 'warn-banner' }, 'Another editor has the archive locked. You can force-save if you intend to overwrite it.') : null,
);
const { close } = openModal({
title: 'Linked Guide',
body,
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Close'),
el('button', {
onClick: async () => {
await onOpenArchive?.(guide);
},
}, 'Show file'),
conflict ? el('button.primary', {
onClick: async () => {
await onForceSave?.(guide);
close();
resolve(true);
},
}, 'Force save') : el('button.primary', {
onClick: async () => {
await onSave?.(guide);
close();
resolve(true);
},
}, 'Save now'),
],
wide: true,
onClose: () => resolve(false),
});
});
}
function showInfoDialog(title, bodyText) {
return new Promise((resolve) => {
const { close } = openModal({
title,
body: el('div', {}, bodyText),
footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'OK')],
onClose: () => resolve(false),
});
});
}
window.StepForgeDialogs = {
promptText,
showQuickActions,
showSettingsDialog,
showExportDialog,
showLinkedGuideDialog,
showInfoDialog,
};