Files
StepForge/app/renderer/app.js
T
TylerandClaude Fable 5 ccbb9b03dc
Template tests / tests (pull_request) Failing after 34s
Enforce a truthful local-first AI/privacy contract
Phase 1 of the improvement plan (PR 3 of the sequence). The docs claimed
"fully offline"/"never talks to the network," but text-intel makes HTTP
requests to a configurable Ollama host that could be remote, with no timeout,
no cancellation, and no size limit; the Windows hook logged raw keystrokes
into capture metadata that could then be sent to that host.

Privacy — raw keystroke capture:
- New capture.captureTypedText setting, default false. With it off, printable
  characters are never buffered in JS and the Windows keyboard hook never even
  emits them across the process boundary (the flag is threaded into the C#).
  Shortcut/navigation detection (Ctrl+T, Enter, …) is unaffected.

AI network hardening (app/text-intel.js):
- Every Ollama call goes through fetchJson with an AbortController deadline
  (ai.timeoutMs, default 60s): a dead endpoint fails fast instead of leaving
  UI actions pending forever.
- Cancellation: in-flight requests are tracked and cancelInflight(guideId)
  aborts them; new ai:cancel IPC + api.ai.cancel are called when the editor
  closes, and shutdown cancels everything.
- Bounded concurrency (2) for AI network work.
- Screenshots are only attached when allowed (ai.attachScreenshots), the model
  is vision-capable, and the image is within ai.maxImageBytes — no more
  unbounded base64-expanded 4K bodies.

Local-first host policy (core/text-intel.js):
- New isLoopbackHost + validateOllamaHost. By default only a loopback Ollama
  endpoint is contacted; a remote host is refused with a clear message unless
  ai.allowRemoteHost is explicitly enabled. Blocked hosts are never contacted.

Honest documentation:
- README, package.json, and the welcome screen drop "fully offline"/"never
  talks to the network"/"Electron is the only dependency" for an accurate
  local-first contract that discloses the optional AI path and the bundled
  Tesseract OCR dependency.
- New docs/PRIVACY.md details exactly what is collected locally and the one
  outbound (opt-in, loopback-by-default) AI feature.

Tests: loopback/remote host matrix, remote-blocked-without-opt-in (and never
contacted), remote-allowed-with-opt-in, request timeout, explicit cancel vs
timeout, typed-text off-by-default vs opted-in, shortcut detection still works,
and a source guard that the C# CHAR emission stays behind the opt-in. 224 unit
tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:36:01 -07:00

963 lines
36 KiB
JavaScript

'use strict';
(() => {
const api = window.stepforge;
const dialogs = window.StepForgeDialogs || {};
class StepForgeApp {
constructor() {
this.view = document.getElementById('view');
this.topbarContext = document.getElementById('topbar-context');
this.searchInput = document.getElementById('global-search');
this.captureStatus = document.getElementById('capture-status');
this.homeBtn = document.getElementById('btn-home');
this.state = {
view: 'welcome',
query: '',
folderFilter: 'all',
library: { guides: [], folders: [], guideFolders: {} },
trash: [],
settings: null,
info: null,
selectMode: false,
selectedGuides: new Set(),
selectedTrash: new Set(),
};
this.editorMeta = null;
this.libraryRenderToken = 0;
this.view.innerHTML = `
<div id="welcome-host"></div>
<div id="library-host" class="hidden"></div>
<div id="editor-host" class="hidden"></div>
`;
this.welcomeHost = document.getElementById('welcome-host');
this.libraryHost = document.getElementById('library-host');
this.editorHost = document.getElementById('editor-host');
this.editor = new GuideEditor({
root: this.editorHost,
onMetaChange: (meta) => this.onEditorMeta(meta),
onToast: (msg, opts) => toast(msg, opts),
onBack: async (reason) => {
if (reason === 'new') {
await this.createGuide();
return;
}
await this.showLibrary();
},
});
this.searchInput.addEventListener('input', debounce(() => {
this.state.query = this.searchInput.value.trim();
if (this.state.view === 'library') this.renderLibrary();
}, 80));
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (this.state.view === 'library') this.openQuickActions();
}
if (e.key === 'Escape') {
this.searchInput.value = '';
this.state.query = '';
if (this.state.view === 'library') this.renderLibrary();
}
});
this.homeBtn.addEventListener('click', () => {
if (this.state.view !== 'welcome') this.showWelcome();
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
e.preventDefault();
this.openQuickActions();
}
});
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
api.capture.onState((payload) => this.updateCaptureState(payload));
api.capture.onStepUpdated((payload) => this.onStepUpdated(payload));
}
async onStepUpdated(payload) {
if (!payload || !payload.guideId || !payload.step) return;
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
const currentStep = this.editor.currentStep;
if (currentStep && currentStep.stepId === payload.step.stepId) {
await this.editor.reload(payload.step.stepId);
toast('Documentation generated.');
}
}
}
async onCaptureAdded(payload) {
if (!payload || !payload.guideId) return;
this.updateCaptureState(await api.capture.state());
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
return;
}
await this.refreshLibrary();
}
async init() {
this.renderWelcome();
try {
await this.refreshData();
this.updateCaptureState(await api.capture.state());
} catch (err) {
console.error(err);
}
}
async refreshData() {
const [info, settings, library, trash] = await Promise.all([
api.app.info(),
api.settings.all(),
api.library.list(),
api.library.trashList(),
]);
this.state.info = info;
document.body.classList.toggle('platform-linux', info.platform === 'linux');
this.state.settings = settings;
this.state.library = {
guides: library.guides || [],
folders: library.folders?.folders || [],
guideFolders: library.folders?.guideFolders || {},
};
this.state.trash = trash;
this.editor.setSettings(settings);
}
async refreshLibrary({ keepFilter = true } = {}) {
const folderFilter = keepFilter ? this.state.folderFilter : 'all';
await this.refreshData();
if (!this.folderExists(folderFilter) && !['all', 'favorites', 'trash'].includes(folderFilter)) {
this.state.folderFilter = 'all';
}
if (this.state.view === 'library') this.renderLibrary();
else this.renderTopbar();
}
folderExists(folderId) {
return (this.state.library.folders || []).some((f) => f.id === folderId);
}
setView(view) {
this.state.view = view;
this.welcomeHost.classList.toggle('hidden', view !== 'welcome');
this.libraryHost.classList.toggle('hidden', view !== 'library');
this.editorHost.classList.toggle('hidden', view !== 'editor');
this.searchInput.classList.toggle('hidden', view !== 'library');
this.renderTopbar();
// The capture bar is editor-only; re-evaluate its visibility now that
// the view changed.
this.updateCaptureState(this.captureState);
}
showWelcome() {
this.editor.setActive(false);
this.setView('welcome');
this.renderWelcome();
}
renderWelcome() {
this.setView('welcome');
clearNode(this.welcomeHost);
this.welcomeHost.append(
el('div.welcome', {},
el('div.welcome-title', {},
el('h1', {}, 'StepForge'),
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides. Local-first, no telemetry.'),
),
el('div.welcome-actions', {},
el('button.welcome-btn.primary', {
type: 'button',
onClick: () => this.startNewCapture(),
},
el('span.welcome-btn-label', {}, 'New Capture'),
el('span.welcome-btn-hint', {}, 'Start a guide and capture your screen'),
),
el('button.welcome-btn', {
type: 'button',
onClick: () => this.openExistingWorkspace(),
},
el('span.welcome-btn-label', {}, 'Existing Workspace'),
el('span.welcome-btn-hint', {}, 'Browse your guide library'),
),
el('button.welcome-btn', {
type: 'button',
onClick: () => this.openSettings(),
},
el('span.welcome-btn-label', {}, 'Settings'),
el('span.welcome-btn-hint', {}, 'Theme, capture, and export options'),
),
),
),
);
}
async startNewCapture() {
const guide = await api.library.create({ title: 'Untitled capture' });
await this.refreshData();
await this.openGuide(guide.guideId);
await this.armCaptureSession(guide.guideId);
}
async openExistingWorkspace() {
await this.refreshData();
this.state.query = '';
this.searchInput.value = '';
this.state.folderFilter = 'all';
await this.showLibrary();
}
async showLibrary(reason = null) {
this.editor.setActive(false);
this.setView('library');
if (reason === 'new') {
await this.createGuide();
return;
}
this.renderLibrary();
}
async openGuide(guideId, stepId = null) {
this.setView('editor');
this.editor.setActive(true);
await this.editor.open(guideId, stepId);
this.renderTopbar();
}
// Start a paused session, optionally show a reminder, and continue once
// the user acknowledges it.
async armCaptureSession(guideId, reminder = null) {
const state = await api.capture.session({ action: 'start', guideId });
this.updateCaptureState(state);
if (!reminder) return state;
const acknowledged = await dialogs.showRecordingReminder(reminder);
if (!acknowledged) return state;
const next = await api.capture.session({ action: 'resume', guideId });
this.updateCaptureState(next);
return next;
}
// Opens a guide and arms (paused) capture for it, so the red REC bar pops
// up right away with a "Start recording" option to resume capturing steps.
async openGuideAndArmCapture(guideId, stepId = null) {
await this.openGuide(guideId, stepId);
// Don't restart (and reset the count of) a session already running for this guide.
if (this.captureState?.active && this.captureState.guideId === guideId) return;
await this.armCaptureSession(guideId);
}
onEditorMeta(meta) {
this.editorMeta = meta;
if (this.state.view === 'editor') this.renderTopbar();
this.updateCaptureState(this.captureState || null);
}
updateCaptureState(state) {
this.captureState = state || { active: false };
clearNode(this.captureStatus);
// The capture bar is editor-only — hide it everywhere else (library, welcome).
if (this.state.view !== 'editor') {
this.captureStatus.classList.add('hidden');
return;
}
this.captureStatus.classList.remove('hidden');
const s = this.captureState;
// No active session: show a button to start a new one for the open guide.
if (!s.active) {
this.captureStatus.append(
el('span', {}, 'Recording - stopped'),
el('button', {
type: 'button',
onClick: () => this.armCaptureSession(this.editor.guideId),
}, 'New recording'),
);
return;
}
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
// What is currently triggering captures, so the user knows what to do.
const notStarted = s.paused && !s.count;
const trigger = notStarted ? 'ready'
: s.paused ? 'paused'
: s.clickCapture ? 'on click'
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
: 'hotkey only';
const pauseBtn = el('button', {
type: 'button',
title: notStarted ? 'StepForge tucks away and starts capturing' : '',
onClick: async () => {
if (notStarted) {
const acknowledged = await dialogs.showRecordingReminder();
if (!acknowledged) return;
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
pauseBtn,
);
}
renderTopbar() {
clearNode(this.topbarContext);
if (this.state.view === 'welcome') return;
if (this.state.view === 'library') {
this.topbarContext.append(
el('button', { type: 'button', onClick: () => this.createGuide() }, 'New'),
el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import'),
el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Linked'),
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
);
return;
}
const guide = this.editorMeta?.guide;
this.topbarContext.append(
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'),
el('button.primary', {
type: 'button',
title: 'Capture a screenshot step',
onClick: (e) => this.editor.openCaptureMenu(e),
}, 'Capture ▾'),
el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'),
el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'),
el('button', { type: 'button', title: 'Share this guide as a .sfgz file', onClick: () => this.editor.shareAsFile() }, 'Share'),
el('button', {
type: 'button',
onClick: (e) => {
const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: 'Generate all text fields with AI (experimental)', action: () => this.editor.generateAllTextFieldsWithAi() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
'sep',
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
{ label: 'Quick actions (Ctrl+/)', action: () => this.editor.openQuickActions() },
{ label: 'Settings…', action: () => this.openSettings() },
]);
},
}, 'More ▾'),
el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''),
);
}
async renderLibrary() {
this.setView('library');
this.editor.setActive(false);
clearNode(this.libraryHost);
const q = this.state.query.trim();
const folderLabel = this.filterLabel();
// Selecting only makes sense for the guide grid and the trash — drop out
// of select mode for search results.
const canSelect = !q;
if (!canSelect && this.state.selectMode) {
this.state.selectMode = false;
this.state.selectedGuides = new Set();
this.state.selectedTrash = new Set();
}
const body = el('div.library', {},
el('aside.lib-side', {},
el('h3', {}, 'Library'),
this.libraryNavItem('all', 'All guides', this.state.library.guides.length),
this.libraryNavItem('favorites', 'Favorites', this.state.library.guides.filter((g) => g.favorite).length),
this.libraryNavItem('trash', 'Trash', this.state.trash.length),
el('h3', {}, 'Folders'),
...this.renderFolderItems(this.state.library.folders || [], null, 0),
el('div', { style: { marginTop: '8px' } },
el('button', { type: 'button', onClick: () => this.createFolder() }, 'Add folder'),
),
),
el('main.lib-main', {},
el('div.lib-actions', {},
el('button.primary', { type: 'button', onClick: () => this.createGuide() }, 'New guide'),
el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import archive'),
el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Open linked'),
el('button', { type: 'button', onClick: () => this.openQuickActions() }, 'Quick actions'),
canSelect ? el('button', {
type: 'button',
className: this.state.selectMode ? 'primary' : '',
onClick: () => this.toggleSelectMode(),
}, 'Select') : null,
),
el('div.row', { style: { justifyContent: 'space-between', marginBottom: '14px' } },
el('div', {},
el('div', { style: { fontWeight: 650 } }, folderLabel),
q ? el('div.muted', {}, `Search: ${q}`) : el('div.muted', {}, `${this.state.library.guides.length} guides`),
),
el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.buildVersion || this.state.info.version}` : ''),
),
this.domBulkBar = el('div', {}),
this.domLibraryResults = el('div', {}),
),
);
this.libraryHost.append(body);
if (q) {
await this.renderSearchResults();
} else if (this.state.folderFilter === 'trash') {
this.renderTrashView();
} else {
this.renderGuideGrid();
}
this.renderBulkBar();
this.renderTopbar();
}
setFolderFilter(folderFilter) {
this.state.folderFilter = folderFilter;
this.state.selectMode = false;
this.state.selectedGuides = new Set();
this.state.selectedTrash = new Set();
this.renderLibrary();
}
libraryNavItem(id, label, count) {
const props = {
className: `nav-item${this.state.folderFilter === id ? ' active' : ''}`,
onClick: () => this.setFolderFilter(id),
};
if (!['all', 'favorites', 'trash'].includes(id)) {
props.onContextMenu = (e) => this.folderContextMenu(e, id);
}
return el('div.nav-item', props,
el('span', {}, label),
el('span.count', {}, count));
}
renderFolderItems(folders, parentId = null, depth = 0) {
const out = [];
const children = folders
.filter((folder) => (folder.parentId || null) === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of children) {
const count = Object.entries(this.state.library.guideFolders || {})
.filter(([, fid]) => fid === folder.id).length;
out.push(el('div.nav-item', {
className: `nav-item${this.state.folderFilter === folder.id ? ' active' : ''}`,
style: { paddingLeft: `${8 + depth * 12}px` },
onClick: () => this.setFolderFilter(folder.id),
onContextMenu: (e) => this.folderContextMenu(e, folder.id),
},
el('span', {}, folder.name),
el('span.count', {}, count)));
out.push(...this.renderFolderItems(folders, folder.id, depth + 1));
}
return out;
}
folderContextMenu(event, folderId) {
event.preventDefault();
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
if (!folder) return;
contextMenu(event.clientX, event.clientY, [
{ label: 'Rename folder', action: () => this.renameFolder(folderId) },
{ label: 'Delete folder', danger: true, action: () => this.deleteFolder(folderId) },
]);
}
filterLabel() {
if (this.state.folderFilter === 'all') return 'All guides';
if (this.state.folderFilter === 'favorites') return 'Favorites';
if (this.state.folderFilter === 'trash') return 'Trash';
const folder = (this.state.library.folders || []).find((f) => f.id === this.state.folderFilter);
return folder ? folder.name : 'All guides';
}
scopeGuide(guide) {
if (this.state.folderFilter === 'all') return true;
if (this.state.folderFilter === 'favorites') return Boolean(guide.favorite);
if (this.state.folderFilter === 'trash') return false;
return (this.state.library.guideFolders || {})[guide.guideId] === this.state.folderFilter;
}
async renderSearchResults() {
const token = ++this.libraryRenderToken;
const results = await api.search.query({ q: this.state.query });
if (token !== this.libraryRenderToken) return;
const guidesById = new Map(this.state.library.guides.map((g) => [g.guideId, g]));
const filtered = results.filter((r) => {
const guide = guidesById.get(r.guideId);
if (!guide) return false;
return this.scopeGuide(guide);
});
clearNode(this.domLibraryResults);
if (!filtered.length) {
this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Search'), 'No results for this query.'));
return;
}
this.domLibraryResults.append(
el('div.guide-grid', {},
...filtered.map((result) => {
const guide = guidesById.get(result.guideId);
const isStep = Boolean(result.stepId);
return this.resultCard(result, guide, isStep);
}),
),
);
}
renderGuideGrid() {
const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide));
clearNode(this.domLibraryResults);
if (!guides.length) {
this.domLibraryResults.append(
el('div.empty-state', {},
el('div.big', {}, '∅'),
this.state.folderFilter === 'trash'
? 'Trash is empty.'
: 'No guides in this section yet.',
),
);
return;
}
this.domLibraryResults.append(el('div.guide-grid', {}, ...guides.map((guide) => this.guideCard(guide))));
}
renderTrashView() {
clearNode(this.domLibraryResults);
if (!this.state.trash.length) {
this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Trash'), 'Nothing deleted yet.'));
return;
}
const selectMode = this.state.selectMode;
const items = this.state.trash.map((name) => {
const selected = this.state.selectedTrash.has(name);
return el('div.guide-card', {
className: `guide-card${selected ? ' selected' : ''}`,
onClick: () => {
if (selectMode) this.toggleTrashSelection(name);
},
onContextMenu: (e) => {
e.preventDefault();
if (selectMode) return;
contextMenu(e.clientX, e.clientY, [
{ label: 'Restore', action: () => this.restoreTrashItem(name) },
{ label: 'Empty trash', danger: true, action: () => this.purgeTrashItem() },
]);
},
},
el('h4', {}, name),
el('div.meta', {}, 'Deleted guide archive'));
});
this.domLibraryResults.append(el('div.guide-grid', {}, ...items));
}
guideCard(guide) {
const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null;
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
const badgeText = guide.linkedSource ? 'Linked' : guide.favorite ? 'Favorite' : 'Local';
const selectMode = this.state.selectMode;
const selected = this.state.selectedGuides.has(guide.guideId);
const description = (guide.descriptionHtml || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const card = el('div.guide-card', {
className: `guide-card${selected ? ' selected' : ''}`,
onClick: () => {
if (selectMode) this.toggleGuideSelection(guide.guideId);
else this.openGuideAndArmCapture(guide.guideId);
},
onContextMenu: (e) => {
e.preventDefault();
if (selectMode) return;
this.guideContextMenu(e, guide);
},
},
el('div.fav', {
className: `fav${guide.favorite ? ' on' : ''}`,
onClick: async (e) => {
e.stopPropagation();
if (selectMode) return;
await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite });
await this.refreshLibrary();
},
}, '★'),
el('h4', {}, guide.title || 'Untitled guide'),
el('div.meta', {},
el('span.badge', {}, badgeText),
el('span', {}, `${guide.stepCount || 0} steps`),
folder ? el('span', {}, folder.name) : null,
guide.locked ? el('span.badge', {}, 'Locked') : null,
),
description ? el('div.snippet', {}, description) : null,
el('div.muted', {}, fmtDate(guide.updatedAt)));
return card;
}
resultCard(result, guide, isStep) {
return el('div.guide-card', {
onClick: () => this.openGuideAndArmCapture(result.guideId, result.stepId || null),
},
el('h4', {}, isStep ? `${guide.title} · ${result.title}` : result.title),
el('div.meta', {},
el('span.badge', {}, isStep ? 'Step' : 'Guide'),
el('span', {}, guide.favorite ? 'Favorite' : 'Local'),
),
el('div.muted', {}, result.snippet || ''));
}
guideContextMenu(event, guide) {
const currentFolderId = (this.state.library.guideFolders || {})[guide.guideId] || null;
const folderItems = (this.state.library.folders || [])
.filter((folder) => folder.id !== currentFolderId)
.map((folder) => ({
label: `Move to ${folder.name}`,
action: () => this.moveGuideToFolder(guide.guideId, folder.id),
}));
if (currentFolderId) folderItems.push({ label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) });
const moveItems = folderItems.length ? ['sep', ...folderItems] : [];
contextMenu(event.clientX, event.clientY, [
{ label: 'Open guide', action: () => this.openGuideAndArmCapture(guide.guideId) },
{ label: 'Rename guide…', action: () => this.renameGuide(guide) },
{ label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) },
{ label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) },
{ label: 'Export', action: () => this.openGuideExport(guide.guideId) },
...moveItems,
'sep',
{ label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) },
]);
}
toggleSelectMode() {
this.state.selectMode = !this.state.selectMode;
this.state.selectedGuides = new Set();
this.state.selectedTrash = new Set();
this.renderLibrary();
}
toggleGuideSelection(guideId) {
if (this.state.selectedGuides.has(guideId)) this.state.selectedGuides.delete(guideId);
else this.state.selectedGuides.add(guideId);
this.renderGuideGrid();
this.renderBulkBar();
}
selectAllGuides() {
const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide));
this.state.selectedGuides = new Set(guides.map((g) => g.guideId));
this.renderGuideGrid();
this.renderBulkBar();
}
clearSelection() {
this.state.selectedGuides = new Set();
this.renderGuideGrid();
this.renderBulkBar();
}
toggleTrashSelection(name) {
if (this.state.selectedTrash.has(name)) this.state.selectedTrash.delete(name);
else this.state.selectedTrash.add(name);
this.renderTrashView();
this.renderBulkBar();
}
selectAllTrash() {
this.state.selectedTrash = new Set(this.state.trash);
this.renderTrashView();
this.renderBulkBar();
}
clearTrashSelection() {
this.state.selectedTrash = new Set();
this.renderTrashView();
this.renderBulkBar();
}
async bulkRestoreTrash() {
const names = [...this.state.selectedTrash];
if (!names.length) return;
await Promise.all(names.map((name) => api.library.trashRestore({ name })));
this.state.selectedTrash = new Set();
await this.refreshLibrary();
}
async bulkPurgeTrash() {
const names = [...this.state.selectedTrash];
if (!names.length) return;
const ok = await confirmDialog(`Permanently delete ${names.length} item${names.length === 1 ? '' : 's'}? This cannot be undone.`, { danger: true, okLabel: 'Delete forever' });
if (!ok) return;
await api.library.trashPurge({ names });
this.state.selectedTrash = new Set();
await this.refreshLibrary();
}
renderBulkBar() {
if (!this.domBulkBar) return;
clearNode(this.domBulkBar);
if (!this.state.selectMode) return;
if (this.state.folderFilter === 'trash') {
const n = this.state.selectedTrash.size;
const allSelected = this.state.trash.length > 0 && n === this.state.trash.length;
this.domBulkBar.append(
el('div.bulk-bar', {},
el('span', {}, n ? `${n} selected` : 'Select items to act on them'),
el('span.spacer', {}),
el('button', {
type: 'button',
onClick: () => (allSelected ? this.clearTrashSelection() : this.selectAllTrash()),
}, allSelected ? 'Clear selection' : 'Select all'),
el('button', { type: 'button', disabled: !n, onClick: () => this.bulkRestoreTrash() }, 'Restore'),
el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkPurgeTrash() }, 'Delete forever'),
),
);
return;
}
const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide));
const n = this.state.selectedGuides.size;
const allSelected = guides.length > 0 && n === guides.length;
this.domBulkBar.append(
el('div.bulk-bar', {},
el('span', {}, n ? `${n} selected` : 'Select guides to act on them'),
el('span.spacer', {}),
el('button', {
type: 'button',
onClick: () => (allSelected ? this.clearSelection() : this.selectAllGuides()),
}, allSelected ? 'Clear selection' : 'Select all'),
el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(true) }, 'Favorite'),
el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(false) }, 'Unfavorite'),
el('button', { type: 'button', disabled: !n, onClick: (e) => this.openBulkMoveMenu(e) }, 'Move to folder ▾'),
el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkDelete() }, 'Delete'),
),
);
}
openBulkMoveMenu(event) {
const rect = event.target.getBoundingClientRect();
const folderItems = (this.state.library.folders || []).map((folder) => ({
label: folder.name,
action: () => this.bulkMoveToFolder(folder.id),
}));
contextMenu(rect.left, rect.bottom + 4, [
{ label: 'No folder', action: () => this.bulkMoveToFolder(null) },
...(folderItems.length ? ['sep', ...folderItems] : []),
]);
}
async bulkSetFavorite(favorite) {
const ids = [...this.state.selectedGuides];
if (!ids.length) return;
await Promise.all(ids.map((guideId) => api.library.setFavorite({ guideId, favorite })));
await this.refreshLibrary();
}
async bulkMoveToFolder(folderId) {
const ids = [...this.state.selectedGuides];
if (!ids.length) return;
await Promise.all(ids.map((guideId) => api.folders.moveGuide({ guideId, folderId })));
await this.refreshLibrary();
}
async bulkDelete() {
const ids = [...this.state.selectedGuides];
if (!ids.length) return;
const ok = await confirmDialog(`Delete ${ids.length} guide${ids.length === 1 ? '' : 's'}? They'll move to Trash.`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
await Promise.all(ids.map((guideId) => api.library.delete({ guideId })));
this.state.selectedGuides = new Set();
await this.refreshLibrary();
}
async createGuide() {
const title = await dialogs.promptText({
title: 'New Guide',
label: 'Title',
value: 'Untitled guide',
placeholder: 'Untitled guide',
});
if (title == null) return;
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
await this.refreshLibrary();
// Arm a (paused) capture session like every other open path, so the
// "Start recording" bar appears and actually controls this new guide.
// Without this, a guide created from the library opened with no session,
// so Start recording had nothing to resume (or resumed a stale one).
await this.openGuideAndArmCapture(guide.guideId);
}
async createFolder() {
const name = await dialogs.promptText({ title: 'New folder', label: 'Folder name', value: '' });
if (name == null || !name.trim()) return;
await api.folders.create({ name: name.trim(), parentId: null });
await this.refreshLibrary();
}
async renameFolder(folderId) {
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
if (!folder) return;
const name = await dialogs.promptText({ title: 'Rename folder', label: 'Folder name', value: folder.name });
if (name == null || !name.trim()) return;
await api.folders.rename({ folderId, name: name.trim() });
await this.refreshLibrary();
}
async deleteFolder(folderId) {
const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
if (!folder) return;
const ok = await confirmDialog(`Delete the folder “${folder.name}”? Guides stay in the library.`);
if (!ok) return;
await api.folders.delete({ folderId });
await this.refreshLibrary();
}
async moveGuideToFolder(guideId, folderId) {
await api.folders.moveGuide({ guideId, folderId });
await this.refreshLibrary();
}
async toggleFavorite(guide) {
await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite });
await this.refreshLibrary();
}
async duplicateGuide(guideId) {
await api.library.duplicate({ guideId });
await this.refreshLibrary();
}
async deleteGuide(guideId) {
const guide = this.state.library.guides.find((g) => g.guideId === guideId);
if (!guide) return;
const ok = await confirmDialog(`Delete “${guide.title}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
await api.library.delete({ guideId });
await this.refreshLibrary();
}
async restoreTrashItem(name) {
await api.library.trashRestore({ name });
await this.refreshLibrary();
}
async purgeTrashItem() {
const ok = await confirmDialog('Permanently empty the trash?', { danger: true, okLabel: 'Empty trash' });
if (!ok) return;
await api.library.trashPurge();
await this.refreshLibrary();
}
async openGuideExport(guideId) {
const previous = this.editor.guideId;
await this.openGuide(guideId);
await this.editor.openExportDialog();
if (previous && previous !== guideId) {
// keep the newly opened guide active
}
}
async renameGuide(guide = this.editorMeta?.guide) {
if (!guide) return;
const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title });
if (title == null || !title.trim()) return;
const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide;
fullGuide.title = title.trim();
await api.guide.save({ guide: fullGuide });
if (this.state.view === 'editor' && this.editor.guideId === fullGuide.guideId) {
await this.editor.reload(this.editor.selectedStepId);
}
await this.refreshLibrary();
if (this.state.view === 'editor') this.renderTopbar();
}
async importArchive(mode = 'copy') {
const result = await api.archive.open({ mode });
if (!result || !result.ok) return;
await this.refreshLibrary();
await this.openGuide(result.guide.guideId);
}
async openSettings() {
const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders();
await dialogs.showSettingsDialog({
api,
settings,
placeholders,
onSave: async (next) => {
await api.settings.set({ keyPath: 'appearance', value: next.appearance });
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'ai', value: next.ai });
await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {});
this.state.settings = await api.settings.all();
},
});
await this.refreshData();
this.renderTopbar();
if (this.state.view === 'library') this.renderLibrary();
}
async openQuickActions() {
if (this.state.view === 'editor') {
await this.editor.openQuickActions();
return;
}
const commands = [
{ kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.createGuide() },
{ kind: 'cmd', label: 'Import archive', description: 'Open a .sfgz guide archive', action: () => this.importArchive('copy') },
{ kind: 'cmd', label: 'Open linked archive', description: 'Import a linked guide from .sfgz', action: () => this.importArchive('linked') },
{ kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() },
{ kind: 'cmd', label: 'Refresh library', description: 'Reload guides and folders', action: () => this.refreshLibrary() },
];
await dialogs.showQuickActions({
commands,
searchFn: async (query) => {
const results = await api.search.query({ q: query });
return results.map((result) => ({
kind: result.stepId ? 'step' : 'guide',
label: result.stepId ? `${result.title}` : result.title,
description: result.snippet || '',
action: () => this.openGuideAndArmCapture(result.guideId, result.stepId || null),
}));
},
});
}
}
window.StepForgeApp = StepForgeApp;
// Links never navigate this window. http(s)/mailto links from guide content
// open externally via the scheme-validated main-process handler; internal
// step:/# links are handled by their own click handlers; everything else is
// inert. The main process additionally denies all navigation, so this is the
// user-experience half of a two-layer guarantee.
document.addEventListener('click', (e) => {
const anchor = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
if (/^(https?|mailto):/i.test(href)) {
e.preventDefault();
api.shell.openExternal({ url: href });
return;
}
// Ensure a stray href can never navigate the privileged window.
if (!href.startsWith('#')) e.preventDefault();
}, true);
function boot() {
const app = new StepForgeApp();
app.init();
window.stepforgeApp = app;
}
boot();
})();