const app = document.querySelector('#app');
const toastRoot = document.querySelector('#toast-root');
const icons = {
overview: ' ',
repository: ' ',
deploy: ' ',
settings: ' ',
search: ' ',
refresh: ' ',
folder: ' ',
git: ' ',
branch: ' ',
file: ' ',
check: ' ',
warning: ' ',
error: ' ',
arrowRight: ' ',
arrowUp: ' ',
arrowDown: ' ',
external: ' ',
play: ' ',
terminal: ' ',
clock: ' ',
sun: ' ',
moon: ' ',
plus: ' ',
trash: ' ',
close: ' ',
copy: ' ',
chevron: ' ',
link: ' ',
cloud: ' ',
pulse: ' ',
history: ' ',
more: ' ',
star: ' ',
archive: ' ',
shield: ' ',
rocket: ' ',
layers: ' ',
undo: ' ',
menu: ' ',
download: ' ',
server: ' ',
key: ' ',
update: ' ',
wrench: ' '
};
function icon(name, className = '') {
return `${icons[name] || icons.file} `;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]);
}
function attr(value) { return escapeHtml(value).replace(/`/g, '`'); }
function formatDate(value) {
if (!value) return 'Unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const diff = Date.now() - date.getTime();
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`;
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined });
}
function truncate(value, length = 76) { const text = String(value || ''); return text.length > length ? `${text.slice(0, length - 1)}…` : text; }
function shortSha(value) { return String(value || '').slice(0, 7) || '—'; }
function defaultWorkspaceRoot() { return ui.boot?.state?.workspaceRoots?.[0] || null; }
function safeCloneFolderName(repository) {
return String(repository?.name || 'repository').replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository';
}
function displayCloneTarget(repository) {
const root = defaultWorkspaceRoot();
if (!root) return null;
const separator = ui.boot?.platform === 'win32' ? '\\' : '/';
return `${String(root).replace(/[\\/]+$/, '')}${separator}${safeCloneFolderName(repository)}`;
}
function clonePrimaryLabel(repository) {
return defaultWorkspaceRoot() ? `Clone to ${safeCloneFolderName(repository)}` : 'Choose project root & clone';
}
function isTerminalOperation(status) { return ['success', 'failed', 'cancelled', 'rolled-back'].includes(status); }
function toneForStatus(status) {
if (['success', 'healthy', 'complete', 'rolled-back'].includes(status)) return 'success';
if (['failed', 'failure', 'unhealthy', 'danger'].includes(status)) return 'danger';
if (['queued', 'running', 'requested', 'warning', 'active'].includes(status)) return 'warning';
return '';
}
const ui = {
boot: null,
repositories: [],
currentView: 'overview',
selectedRepoId: null,
repositoryTab: 'changes',
selectedFile: null,
selectedFiles: new Set(),
selectedProfileId: null,
diff: '',
history: [],
branches: [],
stashes: [],
search: '',
repoSearch: '',
commitMessage: '',
loading: false,
loadingMessage: '',
modal: null,
setupStep: 0,
systemPreflight: null,
deploymentPreflight: null,
diagnosticsStatus: null,
lastDiagnosticBundle: null,
setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] },
setupValidation: null,
activeDeployment: null,
operationPollTimer: null,
isMock: false,
refreshError: null,
autoRefreshPending: false,
paletteQuery: '',
updateStatus: null,
updateChecking: false,
servers: [],
serverInspection: null,
gitRecovery: null
};
function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; }
function selectedProfile(repository = selectedRepository()) {
if (!repository?.deploymentProfiles?.length) return null;
return repository.deploymentProfiles.find((profile) => profile.id === ui.selectedProfileId)
|| repository.deploymentProfiles.find((profile) => profile.branch === repository.localStatus?.branch.head)
|| repository.deploymentProfiles[0];
}
function operations() { return ui.boot?.state?.operations || []; }
function repositoryOperations(repository) { return operations().filter((operation) => operation.repository === repository?.fullName); }
function applyTheme(appearance) {
const resolved = appearance === 'system' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : appearance;
document.documentElement.dataset.theme = resolved || 'dark';
}
function showToast(title, message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `${icon(type === 'error' ? 'error' : type === 'success' ? 'check' : 'warning')}
${escapeHtml(title)} ${escapeHtml(message)}
`;
toastRoot.append(toast);
setTimeout(() => toast.remove(), 5600);
}
function setLoading(loading, message = '') { ui.loading = loading; ui.loadingMessage = message; render(); }
function updateOperationInState(operation) {
if (!operation || !ui.boot) return;
const list = operations();
ui.boot.state.operations = [operation, ...list.filter((item) => item.id !== operation.id)].slice(0, 250);
if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation;
}
function stopOperationPolling() {
if (ui.operationPollTimer) clearTimeout(ui.operationPollTimer);
ui.operationPollTimer = null;
}
function startOperationPolling() {
stopOperationPolling();
const operationId = ui.activeDeployment?.id;
if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return;
const seconds = Math.max(2, Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3);
ui.operationPollTimer = setTimeout(async () => {
try {
const operation = await window.forgeflow.refreshOperations(operationId);
if (operation) updateOperationInState(operation);
render();
if (operation && !isTerminalOperation(operation.status)) startOperationPolling();
else stopOperationPolling();
} catch (error) {
showToast('Deployment status refresh failed', error.message, 'error');
stopOperationPolling();
}
}, seconds * 1000);
}
async function bootstrap() {
try {
ui.boot = await window.forgeflow.bootstrap();
ui.isMock = String(ui.boot.appVersion).includes('demo');
ui.diagnosticsStatus = ui.boot.diagnostics || null;
applyTheme(ui.boot.state.appearance);
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
if (ui.boot.state.setupComplete) {
await refreshRepositories(false);
const reconciled = await refreshActiveOperations(false);
if ((Array.isArray(reconciled) ? reconciled : []).some((operation) => isTerminalOperation(operation.status))) await refreshRepositories(false);
}
window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh());
window.forgeflow.onOperationsChanged?.((payload) => {
const changed = payload?.operations || [];
for (const operation of changed) updateOperationInState(operation);
if (changed.some((operation) => isTerminalOperation(operation.status))) scheduleAutoRefresh(250);
render();
});
window.forgeflow.onUpdatesChanged?.((payload) => {
ui.updateStatus = payload;
render();
if (payload?.available) showToast('ForgeFlow update available', `Version ${payload.remoteVersion} is ready to download.`, 'success');
});
render();
const updateResult = ui.boot.updateResult;
if (updateResult?.state === 'success') {
const restartNote = updateResult.restartLaunched ? '' : ' Automatic restart was unavailable, but the update itself succeeded.';
showToast('ForgeFlow updated successfully', `Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`, 'success');
} else if (updateResult?.state === 'rolled-back') {
showToast('ForgeFlow update rolled back', updateResult.message || 'The update failed and the previous version was restored.', 'error');
} else if (updateResult?.state === 'failed') {
showToast('ForgeFlow update failed', updateResult.message || 'See the update log for technical details.', 'error');
}
setTimeout(() => { void refreshDeploymentTruth(false); }, 500);
} catch (error) {
app.innerHTML = `${icon('error')}ForgeFlow could not start ${escapeHtml(error.message)}
`;
}
}
function scheduleAutoRefresh() {
if (ui.loading || ui.autoRefreshPending || !ui.boot?.state?.preferences?.autoRefresh) return;
ui.autoRefreshPending = true;
setTimeout(async () => {
ui.autoRefreshPending = false;
await refreshRepositories(false, true);
}, 450);
}
async function refreshRepositories(withLoader = true, silent = false) {
if (withLoader) setLoading(true, 'Refreshing Local → Gitea → Server state…');
try {
const selectedId = ui.selectedRepoId;
ui.repositories = await window.forgeflow.refreshRepositories();
ui.refreshError = null;
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
const repository = selectedRepository();
if (repository && !repository.deploymentProfiles.some((profile) => profile.id === ui.selectedProfileId)) ui.selectedProfileId = selectedProfile(repository)?.id || null;
if (repository) {
const availablePaths = new Set((repository.localStatus?.files || []).map((file) => file.path));
ui.selectedFiles = new Set([...ui.selectedFiles].filter((filePath) => availablePaths.has(filePath)));
if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) {
ui.selectedFile = repository.localStatus?.files?.[0]?.path || null;
ui.diff = '';
}
}
if (!ui.selectedRepoId && ui.currentView === 'repository' && ui.repositories.length) selectRepository(ui.repositories[0].id, false);
} catch (error) {
ui.refreshError = error.message;
if (!silent) showToast('Refresh failed', error.message, 'error');
} finally {
if (withLoader) setLoading(false); else render();
}
}
async function refreshActiveOperations(showErrors = true) {
try {
const updated = await window.forgeflow.refreshOperations();
for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation);
return updated;
} catch (error) {
if (showErrors) showToast('Deployment status unavailable', error.message, 'error');
return [];
}
}
async function refreshDeploymentTruth(showErrors = false) {
const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile }))
);
if (!targets.length) return { checked: 0, failed: 0 };
const failures = [];
const queue = [...targets];
const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
while (queue.length) {
const target = queue.shift();
try {
target.profile.state = await window.forgeflow.refreshProfileState(target.repository.fullName, target.profile.id);
} catch (error) {
failures.push({ repository: target.repository.fullName, profile: target.profile.name, message: error.message });
}
}
});
await Promise.all(workers);
await refreshRepositories(false);
if (showErrors && failures.length) {
showToast('Some environments could not be checked', `${failures.length} profile${failures.length === 1 ? '' : 's'} could not be refreshed. Open Deployments for details.`, 'error');
}
return { checked: targets.length, failed: failures.length };
}
function selectRepository(id, shouldRender = true) {
ui.selectedRepoId = id;
ui.currentView = 'repository';
ui.repositoryTab = 'changes';
ui.commitMessage = '';
ui.history = [];
ui.branches = [];
ui.stashes = [];
ui.gitRecovery = null;
const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null;
const files = repository?.localStatus?.files || [];
ui.selectedFiles = new Set(files.map((file) => file.path));
ui.selectedFile = files[0]?.path || null;
ui.diff = '';
if (ui.selectedFile && repository?.localPath) loadDiff(repository, ui.selectedFile);
if (shouldRender) render();
}
async function loadDiff(repository, filePath) {
ui.diff = 'Loading diff…';
render();
try {
const file = repository.localStatus?.files.find((item) => item.path === filePath);
ui.diff = await window.forgeflow.repositoryDiff(repository.localPath, filePath, Boolean(file?.staged && !file?.unstaged));
} catch (error) { ui.diff = `Unable to load diff: ${error.message}`; }
render();
}
function repositoryAction(repository) {
if (!repository.localPath) return { kind: 'link', title: 'Connect this repository', detail: 'Link an existing local folder or clone it from Gitea.' };
const status = repository.localStatus;
if (!status) return { kind: 'error', title: 'Local repository unavailable', detail: repository.attentionReason || 'The linked folder could not be read.' };
if (status.counts.conflicts) return { kind: 'conflict', title: 'Resolve merge conflicts', detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} block synchronization.` };
if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` };
if (status.branch.behind && status.branch.ahead) return { kind: 'diverged', title: 'Branches have diverged', detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.` };
if (status.branch.behind) return { kind: 'pull', title: 'Synchronize from Gitea', detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? '' : 's'} behind.` };
if (status.branch.ahead) return { kind: 'push', title: 'Push local commits', detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} ready to push.` };
if (!repository.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' };
const profile = selectedProfile(repository);
if (profile?.branch !== status.branch.head) return { kind: 'branch-profile', title: 'No deployment for this branch', detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.` };
if (repository.readyToDeploy) return { kind: 'deploy', title: 'Ready for deployment', detail: `Commit ${status.shortHead} can be released to ${profile.environment}.` };
return { kind: 'clean', title: 'Repository synchronized', detail: 'No local or remote action is required.' };
}
function navButton(view, label, iconName, count = '') {
return `${icon(iconName)}${label} ${count !== '' ? `${count} ` : ''} `;
}
function renderTitlebar() {
const state = ui.boot?.state;
const user = state?.gitea?.user;
const connected = Boolean(state?.gitea?.hasToken);
const repository = selectedRepository();
const title = ui.currentView === 'repository' && repository ? repository.fullName : ({ overview: 'Release overview', deployments: 'Deployments', diagnostics: 'Diagnostics', settings: 'Settings', 'deployment-run': 'Deployment run' }[ui.currentView] || 'Workspace');
return `
ForgeFlow by ITWorx.tech ${escapeHtml(title)}
`;
}
function renderRepositoryRow(repository) {
const status = repository.localStatus;
const badges = [];
if (status?.counts.conflicts) badges.push('! ');
else if (status?.counts.changed) badges.push(`${status.counts.changed} `);
if (status?.branch.ahead) badges.push(`↑${status.branch.ahead} `);
if (status?.branch.behind) badges.push(`↓${status.branch.behind} `);
if (repository.readyToDeploy) badges.push('↗ ');
if (!repository.localPath) badges.push('— ');
const branch = status?.branch.head || repository.defaultBranch || 'remote';
return `
${repository.favorite ? icon('star') : icon(repository.localPath ? 'git' : 'cloud')}
${escapeHtml(repository.name)} ${escapeHtml(branch)} ${status?.shortHead ? `• ${escapeHtml(status.shortHead)} ` : ''}
${badges.join('')}
`;
}
function renderSidebar() {
const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase();
const repositories = ui.repositories.filter((repository) => !query || `${repository.name} ${repository.fullName} ${repository.description}`.toLowerCase().includes(query));
const favorites = repositories.filter((repository) => repository.favorite);
const others = repositories.filter((repository) => !repository.favorite);
const attention = ui.repositories.filter((repository) => repository.attention || repository.localStatus?.counts.changed || repository.localStatus?.branch.ahead || repository.readyToDeploy).length;
const rows = (list) => list.map(renderRepositoryRow).join('');
return ``;
}
function renderSummaryCard(label, value, note, iconName, tone = '') {
return `${icon(iconName)}
${label}
${value}
${note}
`;
}
function queueActionFor(repository) {
const action = repositoryAction(repository);
const mapping = {
link: ['folder', 'Link folder', 'Local project is not connected', ''],
error: ['error', 'Inspect problem', action.detail, 'danger'],
conflict: ['warning', 'Resolve conflicts', action.detail, 'danger'],
commit: ['file', 'Review & commit', action.detail, 'warning'],
diverged: ['warning', 'Resolve divergence', action.detail, 'danger'],
pull: ['arrowDown', 'Synchronize', action.detail, 'warning'],
push: ['arrowUp', 'Push commits', action.detail, ''],
configure: ['settings', 'Configure deploy', action.detail, ''],
'branch-profile': ['branch', 'Select profile', action.detail, ''],
deploy: ['rocket', 'Deploy release', action.detail, 'success'],
clean: ['check', 'Synchronized', action.detail, 'success']
};
return mapping[action.kind] || mapping.clean;
}
function renderOverview() {
const changed = ui.repositories.filter((repository) => repository.localStatus?.counts.changed).length;
const unpushed = ui.repositories.filter((repository) => repository.localStatus?.branch.ahead).length;
const deployable = ui.repositories.filter((repository) => repository.readyToDeploy).length;
const unhealthy = ui.repositories.flatMap((repository) => repository.deploymentProfiles || []).filter((profile) => profile.state?.healthy === false).length;
const queue = ui.repositories.filter((repository) => repositoryAction(repository).kind !== 'clean').slice(0, 8);
const recent = operations().slice(0, 7);
const active = recent.filter((operation) => !isTerminalOperation(operation.status));
return `
${ui.refreshError ? `
${icon('error')} ${escapeHtml(ui.refreshError)}
` : ''}
${renderSummaryCard('Local work', changed, changed === 1 ? 'repository has changes' : 'repositories have changes', 'file', changed ? 'warning' : 'success')}
${renderSummaryCard('Unpushed', unpushed, 'repositories ahead of Gitea', 'arrowUp', unpushed ? 'warning' : 'success')}
${renderSummaryCard('Ready', deployable, 'exact commits ready to deploy', 'rocket', deployable ? 'success' : '')}
${renderSummaryCard('Health', unhealthy || active.length, unhealthy ? 'unhealthy environments' : active.length ? 'operations in progress' : 'all checked environments healthy', 'pulse', unhealthy ? 'danger' : active.length ? 'warning' : 'success')}
Action queue Sorted by required attention
${queue.length ? queue.map((repository) => { const [iconName, label, reason, tone] = queueActionFor(repository); return `
${icon(iconName)} ${escapeHtml(repository.name)}
${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || 'remote')} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ''}
${escapeHtml(label)} ${escapeHtml(reason)}
Open ${icon('arrowRight')} `; }).join('') : '
✓
Everything is synchronized No repository needs immediate attention.
'}
${recent.length ? recent.map((operation) => `
${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || 'environment')}
${escapeHtml(operation.action === 'rollback' ? 'Rollback' : 'Deploy')} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}
${formatDate(operation.updatedAt || operation.createdAt)} `).join('') : '
No deployment history yet.
'}
${readinessRow('Git executable', ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
${readinessRow('Gitea connection', ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || 'Not configured')}
${readinessRow('Workspace folders', ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
${readinessRow('Automatic awareness', ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? 'Manual refresh only' : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
`;
}
function readinessRow(label, ok, detail) { return `${escapeHtml(label)} ${escapeHtml(detail)}
`; }
function releaseNode(label, value, description, tone = '') { return `${label}
${escapeHtml(value)} ${escapeHtml(description)}
`; }
function renderDiff(diff) {
if (!diff) return '↔
No textual diff Select another file or open the project folder for binary changes.
';
return escapeHtml(diff).split('\n').map((line) => {
const type = line.startsWith('+') && !line.startsWith('+++') ? 'add' : line.startsWith('-') && !line.startsWith('---') ? 'remove' : line.startsWith('@@') ? 'hunk' : '';
return `${line || ' '} `;
}).join('');
}
function fileStatusCode(file) {
if (file.conflict) return 'U';
if (file.untracked) return '?';
return ({ modified: 'M', added: 'A', deleted: 'D', renamed: 'R', copied: 'C', 'type-changed': 'T' }[file.status] || 'M');
}
function renderChanges(repository) {
const status = repository.localStatus;
if (!repository.localPath) {
const target = displayCloneTarget(repository);
return `${icon('link')}
Connect a local project Clone directly into your default project root, or link an existing working tree.
${target ? `
${icon('folder')}Automatic destination ${escapeHtml(target)}
` : '
No default project root is configured. ForgeFlow will ask for one.
'}
${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))} ${icon('link')}Link existing folder Choose another location
`;
}
if (!status) return `${icon('error')}
Repository unavailable ${escapeHtml(repository.attentionReason || 'The local working tree could not be read.')}
`;
if (!status.files.length) return `${icon('check')}
Working tree clean Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.
${icon('refresh')}Fetch remote state ${icon('folder')}Open project
`;
return `${ui.selectedFiles.size} selected · ${status.counts.changed} changed · ${status.counts.staged} staged${ui.selectedFiles.size === status.files.length ? 'Clear' : 'Select all'}
${status.files.map((file) => `
${fileStatusCode(file)} ${escapeHtml(file.path)} ${file.staged ? '●' : '○'}
`).join('')}
`;
}
function renderHistory(repository) {
if (!repository.localPath) return 'Link a local repository to view commit history.
';
if (!ui.history.length) return `${icon('history')}
Load local commit history Review the last commits from this working tree.
Load history `;
return `Commit Message Author Date ${ui.history.map((commit) => `${escapeHtml(commit.shortSha)} ${escapeHtml(commit.subject)} ${escapeHtml(commit.author)} ${formatDate(commit.date)} `).join('')}
`;
}
function environmentState(profile) {
const state = profile.state || {};
if (state.healthy === false) return { label: 'Unhealthy', tone: 'danger' };
if (state.healthy === true) return { label: 'Healthy', tone: 'success' };
if (profile.provider === 'ssh-unraid' || state.statusConfigured || state.healthConfigured) return { label: 'Not checked', tone: '' };
return { label: 'Status not configured', tone: '' };
}
function dockerManIntegration(profile) {
const state = profile.state || {};
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort);
const iconExpected = iconMode !== 'none';
const templateReady = Boolean(state.dockerMan?.templateExists);
const webUiReady = !webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady;
const iconReady = !iconExpected || Boolean(state.dockerMan?.icon) || templateReady;
return {
iconMode,
templateReady,
webUiReady,
iconReady,
ready: Boolean(state.containerRunning && webUiReady && iconReady)
};
}
function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {};
const health = environmentState(profile);
const ready = repository.readyToDeploy && repository.localStatus?.branch.head === profile.branch;
const isSsh = profile.provider === 'ssh-unraid';
const providerDetail = isSsh
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || '';
return `Provider ${isSsh ? 'SSH / Unraid' : 'Gitea Actions'} Live version ${state.liveSha ? shortSha(state.liveSha) : 'Unknown'} Previous version ${state.previousSha ? shortSha(state.previousSha) : 'Unknown'} Last checked ${state.checkedAt ? formatDate(state.checkedAt) : 'Never'} ${isSsh ? `Container ${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? ' · stopped' : state.containerRunning ? ' · running' : ''} DockerMan ${dockerManReady ? (templateReady ? 'Labels/template active' : 'WebUI/icon labels active') : `WebUI ${webUiReady ? 'ready' : 'missing'} · icon ${iconReady ? 'ready' : 'missing'}`} ` : ''}Rollback ${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}
${icon('shield')}Preflight ${icon('refresh')}Reconcile ${webUi ? `${icon('external')}Open Web UI ` : ''}${isSsh ? `${icon('wrench')}${dockerManReady ? 'Reapply DockerMan metadata' : 'Repair DockerMan integration'} ` : ''}${ready ? `${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)} ` : ''}Edit ${state.previousSha && rollbackConfigured ? `${icon('undo')}Rollback ` : ''}
`;
}
function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || [];
const repoOps = repositoryOperations(repository).slice(0, 10);
return `
Deployment environments Exact-commit Gitea Actions or pinned SSH / Unraid deployments ${icon('plus')}Add environment ${profiles.length ? `
${profiles.map((profile) => renderProfileCard(repository, profile)).join('')}
` : '
↗
No deployment profile Connect a Gitea Actions workflow or a trusted SSH / Unraid server.
Configure deployment '}
Release history ${repoOps.length ? `
Action Environment Commit Status Updated ${repoOps.map((operation) => `${escapeHtml(operation.action || 'deploy')} ${escapeHtml(operation.environment)} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} ${escapeHtml(operation.status)} ${formatDate(operation.updatedAt || operation.createdAt)} Open `).join('')}
` : '
No releases for this repository yet.
'}
`;
}
function renderGitTools(repository) {
if (!repository.localPath) return 'Link a local repository to manage branches and stashes.
';
const recovery = ui.gitRecovery;
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `${recovery ? `
${locks.length ? `${locks.length} lock${locks.length === 1 ? '' : 's'}` : 'No Git locks'} ${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : 'No matching active Git process detected'}
${locks.length ? `
` : ''}${recommendations.length ? `
` : ''}` : '
Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.
'}
${icon('wrench')}Repair proven stale locks ${icon('refresh')}Refresh Git state ${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `${icon('link')}Repair origin ` : ''}
Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.
`;
}
function renderRepositorySettings(repository) {
const automaticTarget = displayCloneTarget(repository);
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
return `Repository identity ${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'} ${originNeedsRepair ? `${icon('link')}Use current Gitea origin ` : ''}${repository.localPath ? `${icon('pulse')}Scan Git health ${icon('link')}Remove link ` : `${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))} Choose another location `}
Repository behavior ${icon('shield')}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.
`;
}
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profile = selectedProfile(repository);
const serverState = profile?.state || {};
const localTone = status?.counts.conflicts ? 'danger' : status?.counts.changed ? 'warning' : status ? 'success' : '';
const remoteTone = status?.branch.behind ? 'danger' : status?.branch.ahead ? 'warning' : status?.branch.upstream ? 'success' : '';
const serverTone = serverState.healthy === false ? 'danger' : serverState.healthy === true ? 'success' : '';
const content = ({ changes: renderChanges, history: renderHistory, deployments: renderRepositoryDeployments, gittools: renderGitTools, settings: renderRepositorySettings }[ui.repositoryTab] || renderChanges)(repository);
return `
${releaseNode('Local', status?.shortHead || 'Not linked', status ? `${status.counts.changed} changes · ${status.branch.head}` : 'No working tree', localTone)}${releaseNode('Gitea', status?.shortHead || 'Unknown', status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : 'Branch not published', remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ''}`, serverState.liveSha ? shortSha(serverState.liveSha) : 'Unknown', profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : 'not checked') : 'No deployment profile', serverTone)}
${[['changes','Changes'],['history','History'],['deployments','Deployments'],['gittools','Git tools'],['settings','Project settings']].map(([id,label]) => `${label} `).join('')} ${content}
`;
}
function renderActionPanel(repository) {
const action = repositoryAction(repository);
const status = repository.localStatus;
const profile = selectedProfile(repository);
let body = '';
if (action.kind === 'link') {
const target = displayCloneTarget(repository);
body = `${icon('link')}
${action.title} ${action.detail}
${target ? `
Project root ${escapeHtml(defaultWorkspaceRoot())} New folder ${escapeHtml(safeCloneFolderName(repository))}
` : '
No default project root is configured yet.
'}
${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))} Link existing folder Choose another clone location `;
}
else if (action.kind === 'commit') {
const commitReady = Boolean(ui.selectedFiles.size && ui.commitMessage.trim());
const commitBlocker = !ui.selectedFiles.size ? 'Select at least one changed file.' : !ui.commitMessage.trim() ? 'Enter a commit message to enable commit and push.' : 'Ready to commit. ForgeFlow stages the selected files automatically.';
body = `Commit message required ${ui.selectedFiles.size} of ${status.counts.changed} files selected Ctrl+Enter
${icon(commitReady ? 'check' : 'warning')}${escapeHtml(commitBlocker)}
${icon('arrowUp')}Commit selected & push to Gitea ${icon('git')}Commit selected locally Manual staging is optional. Use it only to review the Git index before committing.
Stage selected files Unstage selected files ${icon('archive')}Stash all changes
`;
}
else if (action.kind === 'pull') body = `${icon('arrowDown')}
${action.title} ${action.detail}
Fast-forward from Gitea `;
else if (action.kind === 'push') body = `${icon('arrowUp')}
${action.title} ${action.detail}
Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? '' : 's'} `;
else if (action.kind === 'diverged' || action.kind === 'conflict' || action.kind === 'error') body = `${icon('error')}
${action.title} ${action.detail}
${action.kind === 'diverged' ? `
${icon('wrench')}Open guided repository repair ` : ''}
Open project folder Refresh status `;
else if (action.kind === 'configure') body = `${icon('settings')}
${action.title} ${action.detail}
Configure first environment `;
else if (action.kind === 'branch-profile') body = `${icon('branch')}
${action.title} ${action.detail}
${repository.deploymentProfiles.length > 1 ? `
Deployment profile ${repository.deploymentProfiles.map((item) => `${escapeHtml(item.name)} · ${escapeHtml(item.branch)} `).join('')} ` : ''}
Edit profile `;
else if (action.kind === 'deploy') body = `${icon('rocket')}
Release ${escapeHtml(status.shortHead)} ${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.
${repository.deploymentProfiles.length > 1 ? `
Environment ${repository.deploymentProfiles.map((item) => `${escapeHtml(item.name)} · ${escapeHtml(item.environment)} `).join('')} ` : ''}
Local ${escapeHtml(status.shortHead)} Gitea ${escapeHtml(status.shortHead)} Target ${escapeHtml(profile.environment)}
${icon('rocket')}Deploy ${escapeHtml(status.shortHead)} → ${escapeHtml(profile.environment)} ${profile.state?.previousSha && profile.rollbackWorkflowFile ? `
${icon('undo')}Rollback to ${shortSha(profile.state.previousSha)} ` : ''}
`;
else body = `${icon('check')}
${action.title} ${action.detail}
${profile ? `
${icon('pulse')}Check ${escapeHtml(profile.environment)} ` : ''}
`;
return ``;
}
function renderDeployments() {
const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })));
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
const missingDockerMan = cards.filter(({ profile }) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready);
return `${active.length ? `
${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active. ForgeFlow reconciles these against the live server automatically.
` : ''}
${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '
No deployment environments configured Open a repository and add an environment.
'}
All operations Newest first ${operations().length ? `
Repository Action Environment Commit Status Updated ${operations().map((operation) => `${escapeHtml(operation.repository)} ${escapeHtml(operation.action || 'deploy')} ${escapeHtml(operation.environment || '—')} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} ${escapeHtml(operation.status)} ${formatDate(operation.updatedAt || operation.createdAt)} Open `).join('')}
` : '
'}
`;
}
function renderSettings() {
const state = ui.boot.state;
const prefs = state.preferences || {};
const update = ui.updateStatus;
const servers = state.servers || [];
return `${icon('settings')}General ${icon('update')}Updates ${icon('server')}Servers ${icon('trash')}Reset setup
Gitea connection ${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || 'user')}` : 'Not connected'} ${escapeHtml(state.gitea.baseUrl || 'No Gitea instance configured')}
Validate & save
ForgeFlow updates Secure source update from ${escapeHtml(state.updates?.owner || 'Jens')}/${escapeHtml(state.updates?.repo || 'ForgeFlow')} ${icon('update')}${ui.updateChecking ? 'Checking…' : 'Check now'} ${icon(update?.available ? 'download' : 'check')}${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`} ${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : 'No update check in this session.'}
${update?.available && !update.downloaded ? `${icon('download')}Download update ` : ''}${update?.downloaded ? `${icon('update')}Apply & restart ` : ''}Save update settings
${icon('shield')}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.
SSH / Unraid servers Credentials are entered locally and encrypted with the Windows credential protection used by Electron. ${icon('plus')}Add server ${servers.length ? `${servers.map((server) => `
${icon('server')}
${escapeHtml(server.name)} ${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)} ${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : 'Host identity not trusted yet'}
Test & trust Edit ${icon('trash')}
`).join('')}
` : 'No SSH server configured. Add your Unraid server before creating an SSH deployment profile.
'}
Git remote maintenance Standardize linked repositories to the current Gitea SSH URLs. ${icon('link')}Normalize all origins This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.
Project roots The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.
${state.workspaceRoots.map((root, index) => `
${index === 0 ? 'Default ' : ''}${icon('trash')}
`).join('')}
${icon('plus')}Add project root Save folders & rescan
Background awareness Save awareness settings
Appearance Dark Light Follow system
Danger zone Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.
Reset ForgeFlow
`;
}
function preflightTone(status) {
return status === 'pass' ? 'success' : status === 'fail' ? 'danger' : status === 'warning' ? 'warning' : '';
}
function renderPreflightChecks(report, emptyMessage = 'Run the preflight to verify this configuration.') {
if (!report?.checks?.length) return `${escapeHtml(emptyMessage)}
`;
return `${report.checks.map((item) => `
${item.status === 'pass' ? icon('check') : item.status === 'fail' ? icon('error') : icon('warning')} ${escapeHtml(item.label)} ${escapeHtml(item.detail)} ${item.help ? `${escapeHtml(item.help)} ` : ''}
${escapeHtml(item.status)} `).join('')}
`;
}
function renderDiagnostics() {
const prefs = ui.boot.state.preferences || {};
const status = ui.diagnosticsStatus || ui.boot.diagnostics || {};
const report = ui.systemPreflight;
return `
${icon('shield')}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.
Files ${escapeHtml(status.fileCount ?? '—')}
Total size ${escapeHtml(status.totalSize || '—')}
Latest event ${status.latestAt ? formatDate(status.latestAt) : 'None'}
Retention ${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days
Location ${escapeHtml(status.directory || 'Unavailable')}
Level ${escapeHtml(status.level || prefs.diagnosticLevel || 'info')}
${status.lastWriteError ? `
Error ${escapeHtml(status.lastWriteError)}
` : ''}
${icon('folder')}Open logs ${icon('trash')}Clear logs
System preflight Git, writable storage, credential protection, folders and Gitea ${icon('shield')}Run checks ${report ? `${report.summary.ready ? 'Ready' : `${report.summary.blocking.length} blocking`} ${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed ` : 'Not run in this session '}
${renderPreflightChecks(report)}
Export support bundle Configuration summary, repository states, operations, preflight and redacted JSONL logs ${icon('archive')}Create diagnostic ZIP
${ui.lastDiagnosticBundle ? `
${icon('check')}
${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}
Show file ` : ''}
`;
}
function renderPipelineView() {
const operation = ui.activeDeployment;
if (!operation) return 'No deployment operation selected.
';
const logs = (operation.logs || []).join('\n');
return `${escapeHtml(operation.status)} ${escapeHtml(operation.profileName || operation.workflowFile || '')} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}
${escapeHtml(operation.status)} ${(operation.stages || []).map((stage) => `
${stage.status === 'complete' ? icon('check') : stage.status === 'failed' ? icon('error') : stage.status === 'active' ? icon('pulse') : icon('clock')} ${escapeHtml(stage.label)}
`).join('')}
${operation.jobs?.length ? `
Runner jobs Job Status Started Completed ${operation.jobs.map((job) => `${escapeHtml(job.name)} ${escapeHtml(job.conclusion || job.status)} ${job.startedAt ? formatDate(job.startedAt) : '—'} ${job.completedAt ? formatDate(job.completedAt) : '—'} `).join('')}
` : ''}
Deployment output ${icon('copy')}Copy
${escapeHtml(logs || 'Waiting for operation output…')} ${operation.failure ? `
${icon('error')}
${escapeHtml(operation.failure.stage)} ${escapeHtml(operation.failure.message)}
` : ''}
`;
}
function renderStatusbar() {
const state = ui.boot?.state;
const repository = selectedRepository();
const active = operations().filter((operation) => !isTerminalOperation(operation.status)).length;
return `${icon('git')}${escapeHtml(ui.boot?.git?.version || 'Git unavailable')} ${icon('folder')}${state?.workspaceRoots?.length || 0} roots ${repository?.localStatus ? `${icon('branch')}${escapeHtml(repository.localStatus.branch.head)} ` : ''}
${ui.autoRefreshPending ? `${icon('refresh')}Change detected ` : ''}${active ? `${icon('pulse')}${active} active ` : ''}ForgeFlow ${escapeHtml(ui.boot?.appVersion || '')}
`;
}
function renderSetup() {
const steps = ['Readiness', 'Gitea', 'Folders', 'Discovery', 'Ready'];
let body = '';
if (ui.setupStep === 0) {
body = `Check this computer ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.
${icon('shield')}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.
${renderPreflightChecks(ui.systemPreflight, 'Run the readiness check to verify this computer.')}
${icon('archive')}Export setup diagnostics Available even before Gitea is connected.
`;
} else if (ui.setupStep === 1) {
body = `Connect your Gitea instance Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.
${ui.setupValidation ? `
${icon('check')}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || 'version unknown')}
` : `
${icon('shield')}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.
`}
`;
} else if (ui.setupStep === 2) {
body = `Select development folders Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.
${ui.setupDraft.roots.map((root,index) => `
${icon('trash')}
`).join('')}
${icon('plus')}Add development folder `;
} else if (ui.setupStep === 3) {
body = `Discovering repositories Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.
Scanning configured folders… `;
} else {
body = `ForgeFlow is ready ${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.
Gitea connected ${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || 'user')}
Workspace discovery ${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories
Safe diagnostics Structured local logs with credential redaction are enabled by default.
${ui.setupDraft.discovered.length ? ui.setupDraft.discovered.slice(0, 8).map((item) => `
${icon(item.error ? 'error' : 'git')}
${escapeHtml(item.localPath.split(/[\\/]/).pop())} ${escapeHtml(item.localPath)}
${item.error ? 'Unreadable' : 'Ready'} `).join('') : '
No repositories found. You can link or clone repositories later.
'}
`;
}
const nextAction = ui.setupStep === 0
? (ui.systemPreflight?.summary?.ready ? 'Continue ' : 'Run readiness check ')
: ui.setupStep === 1 ? 'Validate & continue '
: ui.setupStep === 2 ? `Scan folders `
: ui.setupStep === 4 ? 'Enter ForgeFlow ' : '';
return ``;
}
function renderModal() {
if (!ui.modal) return '';
const repository = selectedRepository() || ui.repositories.find((repo) => repo.fullName === ui.modal.repositoryFullName);
if (ui.modal.type === 'deployment-config') {
const existing = repository?.deploymentProfiles?.find((profile) => profile.id === ui.modal.profileId) || {};
const servers = ui.boot.state.servers || [];
const provider = ui.modal.provider || existing.provider || (servers.length ? 'ssh-unraid' : 'gitea-actions');
const ssh = provider === 'ssh-unraid';
const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository);
return `${icon('shield')}${ssh ? 'ForgeFlow connects over pinned SSH, refuses tracked server-side changes, deploys the exact Git SHA and preserves untracked runtime data.' : 'ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID.'}
`;
}
if (ui.modal.type === 'deployment-preflight') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
const report = ui.deploymentPreflight;
return `${icon(report?.summary?.ready ? 'shield' : 'error')}
${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'} ${escapeHtml(repository?.fullName || '')} → ${escapeHtml(profile?.environment || '')}
${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`} ${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed
${renderPreflightChecks(report)}
`;
}
if (ui.modal.type === 'deploy-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
return `${icon('rocket')}
Deploy ${escapeHtml(repository.localStatus.shortHead)} → ${escapeHtml(profile.environment)} ${escapeHtml(repository.fullName)}
Exact commit ${escapeHtml(repository.localStatus.head)} Branch ${escapeHtml(profile.branch)} Provider ${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)} Healthcheck ${escapeHtml(profile.healthcheckUrl || 'Not configured')}
${ui.deploymentPreflight ? `
${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.
` : ''}
`;
}
if (ui.modal.type === 'rollback-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId);
const target = profile?.state?.previousSha;
return `${icon('undo')}
Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)} The target must still exist on origin/${escapeHtml(profile?.branch || '')}.
Target commit ${escapeHtml(target || 'Unavailable')} Provider ${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')} Current live ${escapeHtml(profile?.state?.liveSha || 'Unknown')}
`;
}
if (ui.modal.type === 'server-config') {
const server = (ui.boot.state.servers || []).find((item) => item.id === ui.modal.serverId) || {};
const authType = ui.modal.authType || server.authType || 'privateKey';
return `${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.
`;
}
if (ui.modal.type === 'command-palette') return renderCommandPalette();
return '';
}
function paletteCommands() {
const repository = selectedRepository();
return [
{ id: 'overview', label: 'Go to release overview', detail: 'Workspace', icon: 'overview', enabled: true },
{ id: 'refresh', label: 'Refresh all repositories', detail: 'Local and Gitea', icon: 'refresh', enabled: true },
{ id: 'deployments', label: 'Open deployments', detail: 'Release history', icon: 'deploy', enabled: true },
{ id: 'diagnostics', label: 'Open diagnostics', detail: 'Logs, preflight and support bundle', icon: 'shield', enabled: true },
{ id: 'settings', label: 'Open settings', detail: 'Connections and awareness', icon: 'settings', enabled: true },
{ id: 'open-folder', label: 'Open selected project folder', detail: repository?.name || 'No repository selected', icon: 'folder', enabled: Boolean(repository?.localPath) },
{ id: 'git-tools', label: 'Open branch and stash tools', detail: repository?.name || 'No repository selected', icon: 'branch', enabled: Boolean(repository?.localPath) },
{ id: 'deploy-selected', label: 'Deploy selected repository', detail: repository?.readyToDeploy ? `${repository.name} ${repository.localStatus.shortHead}` : 'Not ready', icon: 'rocket', enabled: Boolean(repository?.readyToDeploy) }
];
}
function renderCommandPalette() {
const query = ui.paletteQuery.toLowerCase();
const commands = paletteCommands().filter((command) => !query || `${command.label} ${command.detail}`.toLowerCase().includes(query));
return ``;
}
function render() {
if (!ui.boot) return;
const repository = selectedRepository();
const main = ui.currentView === 'overview' ? renderOverview() : ui.currentView === 'deployments' ? renderDeployments() : ui.currentView === 'settings' ? renderSettings() : ui.currentView === 'diagnostics' ? renderDiagnostics() : ui.currentView === 'deployment-run' ? renderPipelineView() : repository ? renderRepositoryWorkspace(repository) : renderOverview();
const withPanel = ui.currentView === 'repository' && repository;
app.innerHTML = `${renderTitlebar()}
${renderSidebar()}
${withPanel ? renderActionPanel(repository) : ''}${ui.loading ? `
${escapeHtml(ui.loadingMessage || 'Working…')} ` : ''} ${renderStatusbar()}
${ui.boot.state.setupComplete ? '' : renderSetup()}${renderModal()}`;
if (ui.modal?.type === 'command-palette') requestAnimationFrame(() => document.querySelector('#palette-input')?.focus());
}
async function runOperation(message, operation, successMessage, { refresh = true } = {}) {
setLoading(true, message);
try {
const result = await operation();
if (successMessage) showToast('Done', successMessage, 'success');
if (refresh) await refreshRepositories(false);
return result;
} catch (error) {
const pushAfterCommit = error.code === 'PUSH_AFTER_COMMIT_FAILED';
showToast(pushAfterCommit ? 'Commit saved locally; push failed' : 'Operation failed', error.message, 'error');
// Always reload the real Git state. A failed stage must keep changes visible, while a
// failed push after a successful commit must immediately surface as an ahead branch.
await refreshRepositories(false, true);
if (pushAfterCommit) {
ui.selectedFiles.clear();
ui.selectedFile = null;
ui.diff = '';
ui.commitMessage = '';
render();
}
return null;
} finally { setLoading(false); }
}
async function executeDeployment(profileId) {
const repository = selectedRepository();
const profile = repository?.deploymentProfiles?.find((item) => item.id === profileId) || selectedProfile(repository);
if (!repository || !profile) return;
ui.modal = null;
setLoading(true, profile.provider === 'ssh-unraid' ? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…` : `Dispatching ${profile.name} workflow…`);
try {
ui.activeDeployment = await window.forgeflow.deploy(repository, profile.id, repository.localStatus.head);
updateOperationInState(ui.activeDeployment);
ui.currentView = 'deployment-run';
showToast('Deployment started', `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, 'success');
startOperationPolling();
} catch (error) { showToast('Deployment failed to start', error.message, 'error'); }
setLoading(false);
}
async function executeRollback(profileId) {
const repository = selectedRepository();
const profile = repository?.deploymentProfiles?.find((item) => item.id === profileId);
const target = profile?.state?.previousSha;
if (!repository || !profile || !target) return;
ui.modal = null;
setLoading(true, profile?.provider === 'ssh-unraid' ? `Rolling back ${profile.remoteFolder} over SSH…` : `Dispatching rollback to ${shortSha(target)}…`);
try {
ui.activeDeployment = await window.forgeflow.rollback(repository, profile.id, target);
updateOperationInState(ui.activeDeployment);
ui.currentView = 'deployment-run';
showToast('Rollback requested', `${profile.environment} → ${shortSha(target)}`, 'success');
} catch (error) { showToast('Rollback failed to start', error.message, 'error'); }
setLoading(false);
}
async function loadGitTools(repository) {
if (!repository?.localPath) return;
setLoading(true, 'Loading branches and stashes…');
try {
[ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
window.forgeflow.gitRecoveryStatus(repository.localPath)
]);
ui.repositoryTab = 'gittools';
} catch (error) { showToast('Git tools unavailable', error.message, 'error'); }
setLoading(false);
}
function profileRepository(profileId) {
return ui.repositories.find((repository) => repository.deploymentProfiles?.some((profile) => profile.id === profileId));
}
async function runSystemPreflight({ setup = false } = {}) {
setLoading(true, 'Checking local readiness…');
try {
ui.systemPreflight = await window.forgeflow.setupPreflight({
baseUrl: ui.setupDraft.baseUrl,
token: ui.setupDraft.token,
roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots
});
if (!setup) ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
ui.systemPreflight.summary.ready ? 'Readiness checks passed' : 'Readiness needs attention',
ui.systemPreflight.summary.ready
? `${ui.systemPreflight.summary.counts.pass} checks passed.`
: `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`,
ui.systemPreflight.summary.ready ? 'success' : 'error'
);
return ui.systemPreflight;
} catch (error) {
showToast('Readiness check failed', error.message, 'error');
return null;
} finally { setLoading(false); }
}
async function runDeploymentPreflight(repository, profileId, { showModal = true } = {}) {
if (!repository || !profileId) return null;
if (String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false);
ui.selectedProfileId = profileId;
ui.deploymentPreflight = null;
setLoading(true, 'Verifying repository, workflow and server…');
try {
const report = await window.forgeflow.deploymentPreflight(repository, profileId);
ui.deploymentPreflight = report;
if (showModal) ui.modal = { type: 'deployment-preflight', profileId, repositoryFullName: repository.fullName };
return report;
} catch (error) {
showToast('Deployment preflight failed', error.message, 'error');
return null;
} finally { setLoading(false); }
}
app.addEventListener('click', async (event) => {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
let repository = selectedRepository();
if (target.dataset.repositoryId) {
const actionRepository = ui.repositories.find((item) => String(item.id) === String(target.dataset.repositoryId));
if (actionRepository) repository = actionRepository;
}
if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); }
else if (action === 'select-repo') selectRepository(target.dataset.id);
else if (action === 'refresh') {
await refreshRepositories(true);
await refreshActiveOperations(false);
await refreshDeploymentTruth(false);
}
else if (action === 'refresh-operations') {
setLoading(true, 'Refreshing deployment operations and live server state…');
await refreshActiveOperations();
await refreshDeploymentTruth(true);
setLoading(false);
}
else if (action === 'toggle-theme') { const appearance = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyTheme(appearance); ui.boot.state = await window.forgeflow.setAppearance(appearance); render(); }
else if (action === 'open-palette') { ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); }
else if (action === 'repo-tab') {
ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === 'gittools' && !ui.branches.length) await loadGitTools(repository); else render();
}
else if (action === 'toggle-favorite') { ui.boot.state = await window.forgeflow.favoriteRepository(repository.fullName, !repository.favorite); repository.favorite = !repository.favorite; render(); }
else if (action === 'select-file') { if (event.target.matches('input[type=checkbox]')) return; ui.selectedFile = target.dataset.path; await loadDiff(repository, ui.selectedFile); }
else if (action === 'toggle-all-files') { const files = repository.localStatus?.files || []; ui.selectedFiles = ui.selectedFiles.size === files.length ? new Set() : new Set(files.map((file) => file.path)); render(); }
else if (action === 'copy-diff') { await navigator.clipboard.writeText(ui.diff || ''); showToast('Copied', 'Diff copied to clipboard.', 'success'); }
else if (action === 'copy-logs') { const text = (ui.activeDeployment?.logs || []).join('\n'); await navigator.clipboard.writeText(text); showToast('Copied', 'Safe operation output copied. Open Gitea for raw runner logs.', 'success'); }
else if (action === 'stage-selected') { if (!repository?.localPath || !ui.selectedFiles.size) return; await runOperation('Staging selected files…', () => window.forgeflow.stageFiles(repository.localPath, [...ui.selectedFiles]), 'Files staged.'); }
else if (action === 'unstage-selected') { if (!repository?.localPath || !ui.selectedFiles.size) return; await runOperation('Unstaging selected files…', () => window.forgeflow.unstageFiles(repository.localPath, [...ui.selectedFiles]), 'Files unstaged.'); }
else if (action === 'commit-push' || action === 'commit-only') {
if (!repository?.localPath || !ui.commitMessage.trim() || !ui.selectedFiles.size) return;
const result = await runOperation(action === 'commit-push' ? 'Committing and pushing…' : 'Creating local commit…', () => action === 'commit-push' ? window.forgeflow.commitAndPush(repository.localPath, ui.commitMessage, [...ui.selectedFiles]) : window.forgeflow.commit(repository.localPath, ui.commitMessage, [...ui.selectedFiles]), action === 'commit-push' ? 'Changes committed and pushed to Gitea.' : 'Local commit created.');
if (result) { ui.commitMessage = ''; ui.selectedFiles.clear(); ui.selectedFile = null; ui.diff = ''; }
}
else if (action === 'push') await runOperation('Pushing local commits…', () => window.forgeflow.push(repository.localPath), 'Push completed.');
else if (action === 'fetch') await runOperation('Fetching from Gitea…', () => window.forgeflow.fetch(repository.localPath), 'Remote state refreshed.');
else if (action === 'pull') await runOperation('Synchronizing from Gitea…', () => window.forgeflow.pull(repository.localPath), 'Local branch fast-forwarded.');
else if (action === 'load-history') { setLoading(true, 'Loading commit history…'); try { ui.history = await window.forgeflow.history(repository.localPath, 50); } catch (error) { showToast('History unavailable', error.message, 'error'); } setLoading(false); }
else if (action === 'load-git-tools') await loadGitTools(repository);
else if (action === 'create-branch') { const branch = document.querySelector('#new-branch-name')?.value.trim(); if (branch) await runOperation(`Creating ${branch}…`, () => window.forgeflow.createBranch(repository.localPath, branch), `Switched to ${branch}.`); await loadGitTools(selectedRepository()); }
else if (action === 'checkout-branch') { await runOperation(`Switching to ${target.dataset.branch}…`, () => window.forgeflow.checkoutBranch(repository.localPath, target.dataset.branch), `Switched to ${target.dataset.branch}.`); await loadGitTools(selectedRepository()); }
else if (action === 'stash-changes') { const result = await runOperation('Stashing local changes…', () => window.forgeflow.stash(repository.localPath, `ForgeFlow ${new Date().toLocaleString()}`), 'Local changes stashed.'); if (result) ui.stashes = result.stashes; }
else if (action === 'pop-stash') { const result = await runOperation(`Applying ${target.dataset.stashRef}…`, () => window.forgeflow.popStash(repository.localPath, target.dataset.stashRef), 'Stash applied.'); if (result) ui.stashes = result.stashes; }
else if (action === 'open-path') await window.forgeflow.openPath(repository.localPath).catch((error) => showToast('Could not open folder', error.message, 'error'));
else if (action === 'open-gitea') await window.forgeflow.openExternal(repository.htmlUrl).catch((error) => showToast('Could not open Gitea', error.message, 'error'));
else if (action === 'link-repo') { const localPath = await window.forgeflow.selectDirectory({ title: `Link local folder for ${repository.name}` }); if (localPath) { ui.repositories = await runOperation('Linking local repository…', () => window.forgeflow.linkRepository(repository.fullName, localPath), 'Local folder linked.', { refresh: false }) || ui.repositories; selectRepository(repository.id); } }
else if (action === 'unlink-repo') { ui.repositories = await runOperation('Removing local link…', () => window.forgeflow.unlinkRepository(repository.fullName), 'Repository link removed.', { refresh: false }) || ui.repositories; selectRepository(repository.id); }
else if (action === 'clone-repo' || action === 'clone-repo-custom') {
const mode = action === 'clone-repo-custom' ? 'custom' : 'default';
const clone = await runOperation(
mode === 'custom' ? 'Choosing location and cloning repository…' : `Cloning ${repository.name} into the default project root…`,
() => window.forgeflow.cloneRepository(repository.fullName, mode),
null,
{ refresh: false }
);
if (clone?.cancelled) return;
if (clone?.target) {
if (clone.state) ui.boot.state = clone.state;
ui.repositories = clone.repositories || await window.forgeflow.refreshRepositories();
selectRepository(repository.id);
showToast(clone.reused ? 'Existing repository linked' : 'Repository cloned', clone.target, 'success');
}
}
else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); }
else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { type: 'deployment-config', profileId: target.dataset.profileId || null, provider: repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId)?.provider }; render(); }
else if (action === 'close-modal') { ui.modal = null; render(); }
else if (action === 'select-profile-icon') {
const iconPath = await window.forgeflow.selectImageFile({ title: 'Select DockerMan PNG icon', defaultPath: document.querySelector('#profile-icon-file')?.value || undefined });
if (iconPath) { document.querySelector('#profile-icon-file').value = iconPath; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'upload'; }
}
else if (action === 'clear-profile-icon') { const input = document.querySelector('#profile-icon-file'); if (input) input.value = ''; const mode = document.querySelector('#profile-icon-mode'); if (mode) mode.value = 'builtin'; }
else if (action === 'save-deployment-profile') {
const provider = document.querySelector('#profile-provider').value;
const profile = {
id: target.dataset.profileId || undefined,
provider,
name: document.querySelector('#profile-name').value.trim(),
environment: document.querySelector('#profile-environment').value.trim(),
branch: document.querySelector('#profile-branch').value.trim(),
healthcheckUrl: document.querySelector('#profile-healthcheck')?.value.trim() || '',
confirmationRequired: document.querySelector('#profile-confirmation').checked,
...(provider === 'ssh-unraid' ? {
serverId: document.querySelector('#profile-server').value,
remoteFolder: document.querySelector('#profile-remote-folder').value.trim(),
cloneUrl: document.querySelector('#profile-clone-url').value.trim(),
alignRemote: document.querySelector('#profile-align-remote').checked,
generatedCompose: document.querySelector('#profile-generated-compose').value === 'true',
composeFile: document.querySelector('#profile-compose-file').value.trim(),
composeService: document.querySelector('#profile-compose-service').value.trim(),
containerName: document.querySelector('#profile-container-name').value.trim(),
hostPort: Number(document.querySelector('#profile-host-port').value) || null,
containerPort: Number(document.querySelector('#profile-container-port').value) || null,
webUiUrl: document.querySelector('#profile-web-ui').value.trim(),
iconMode: document.querySelector('#profile-icon-mode').value,
iconUrl: document.querySelector('#profile-icon-url').value.trim(),
iconFilePath: document.querySelector('#profile-icon-file').value.trim(),
dockerShell: document.querySelector('#profile-docker-shell').value,
preservePaths: document.querySelector('#profile-preserve-paths').value.split(',').map((item) => item.trim()).filter(Boolean)
} : {
workflowFile: document.querySelector('#profile-workflow').value.trim(),
rollbackWorkflowFile: document.querySelector('#profile-rollback-workflow').value.trim(),
statusUrl: document.querySelector('#profile-status-url').value.trim()
})
};
setLoading(true, 'Saving deployment environment…');
try { const result = await window.forgeflow.saveDeploymentProfile(repository.fullName, profile); ui.boot.state = result.state; ui.modal = null; await refreshRepositories(false); ui.selectedProfileId = result.profile.id; showToast('Deployment configured', `${profile.name} targets ${profile.environment}.`, 'success'); } catch (error) { showToast('Could not save profile', error.message, 'error'); }
setLoading(false);
}
else if (action === 'delete-deployment-profile') { if (!confirm('Delete this deployment profile? Operation history is retained.')) return; setLoading(true, 'Deleting deployment profile…'); try { const result = await window.forgeflow.deleteDeploymentProfile(repository.fullName, target.dataset.profileId); ui.boot.state = result.state; ui.modal = null; await refreshRepositories(false); showToast('Profile deleted', 'Deployment environment removed.', 'success'); } catch (error) { showToast('Could not delete profile', error.message, 'error'); } setLoading(false); }
else if (action === 'run-deployment-preflight') {
if (!repository) repository = profileRepository(target.dataset.profileId);
await runDeploymentPreflight(repository, target.dataset.profileId);
}
else if (action === 'deploy-profile') {
if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false);
const profile = repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId) || selectedProfile(repository);
ui.selectedProfileId = profile?.id || null;
if (!profile) return;
const report = await runDeploymentPreflight(repository, profile.id, { showModal: true });
if (!report) return;
}
else if (action === 'continue-after-preflight') {
const profile = selectedRepository()?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId);
if (!profile || !ui.deploymentPreflight?.summary?.ready) return;
if (profile.confirmationRequired !== false) { ui.modal = { type: 'deploy-confirm', profileId: profile.id }; render(); }
else await executeDeployment(profile.id);
}
else if (action === 'confirm-deploy') await executeDeployment(target.dataset.profileId);
else if (action === 'rollback-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); if (repository && String(repository.id) !== String(ui.selectedRepoId)) selectRepository(repository.id, false); ui.modal = { type: 'rollback-confirm', profileId: target.dataset.profileId }; render(); }
else if (action === 'confirm-rollback') await executeRollback(target.dataset.profileId);
else if (action === 'refresh-profile-state') {
if (!repository) repository = profileRepository(target.dataset.profileId);
const profile = repository?.deploymentProfiles?.find((item) => item.id === target.dataset.profileId);
setLoading(true, `Checking ${profile?.environment || 'environment'}…`);
try { const state = await window.forgeflow.refreshProfileState(repository.fullName, target.dataset.profileId); profile.state = state; showToast('Environment checked', state.healthy === false ? 'Healthcheck reports an unhealthy state.' : state.liveSha ? `Server reports ${shortSha(state.liveSha)}.` : 'Connection checked; no live SHA reported.', state.healthy === false ? 'error' : 'success'); } catch (error) { showToast('Status check failed', error.message, 'error'); }
setLoading(false);
}
else if (action === 'repair-missing-dockerman') {
const targets = ui.repositories.flatMap((candidate) =>
(candidate.deploymentProfiles || [])
.filter((profile) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready)
.map((profile) => ({ repository: candidate, profile }))
);
if (!targets.length) return;
if (!confirm(`Recreate ${targets.length} running container${targets.length === 1 ? '' : 's'} with the missing DockerMan WebUI, icon and template metadata?`)) return;
setLoading(true, 'Repairing missing DockerMan integrations…');
let repaired = 0;
const failures = [];
for (const item of targets) {
try {
await window.forgeflow.applyDockerManMetadata(item.repository, item.profile.id);
repaired += 1;
} catch (error) {
failures.push(`${item.repository.name}: ${error.message}`);
}
}
await refreshDeploymentTruth(false);
showToast(
failures.length ? 'DockerMan repair partially completed' : 'DockerMan integrations repaired',
failures.length ? `${repaired} repaired, ${failures.length} failed.` : `${repaired} running container${repaired === 1 ? '' : 's'} updated.`,
failures.length ? 'error' : 'success'
);
setLoading(false);
}
else if (action === 'apply-dockerman-metadata') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Applying DockerMan labels, template, icon and WebUI metadata…');
try { await window.forgeflow.applyDockerManMetadata(repository, target.dataset.profileId); await refreshRepositories(false); showToast('DockerMan integration repaired', 'The container was recreated with labels, a persistent template, WebUI and icon metadata.', 'success'); }
catch (error) { showToast('Could not repair DockerMan integration', error.message, 'error'); }
setLoading(false);
}
else if (action === 'reconcile-deployment') {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, 'Reconciling ForgeFlow with the live Unraid container…');
try { await window.forgeflow.reconcileDeployment(repository.fullName, target.dataset.profileId); await refreshActiveOperations(false); await refreshRepositories(false); showToast('Deployment reconciled', 'Live SHA, container health and operation status were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile deployment', error.message, 'error'); }
setLoading(false);
}
else if (action === 'open-profile-webui') await window.forgeflow.openExternal(target.dataset.url);
else if (action === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); startOperationPolling(); } }
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing deployment status…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); if (!isTerminalOperation(operation.status)) startOperationPolling(); } catch (error) { showToast('Status refresh failed', error.message, 'error'); } setLoading(false); }
else if (action === 'open-run-url') await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
else if (action === 'close-deployment') { stopOperationPolling(); ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
else if (action === 'setup-run-preflight') await runSystemPreflight({ setup: true });
else if (action === 'setup-continue') { if (ui.systemPreflight?.summary?.ready) { ui.setupStep = 1; render(); } }
else if (action === 'setup-validate') { setLoading(true, 'Validating Gitea connection…'); try { ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft); ui.setupDraft.baseUrl = ui.setupValidation.baseUrl; ui.setupDraft.user = ui.setupValidation.user; ui.setupStep = 2; } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
else if (action === 'setup-add-root') { const root = await window.forgeflow.selectDirectory({ title: 'Select a development folder' }); if (root && !ui.setupDraft.roots.includes(root)) ui.setupDraft.roots.push(root); render(); }
else if (action === 'setup-remove-root') { ui.setupDraft.roots.splice(Number(target.dataset.index), 1); render(); }
else if (action === 'setup-next') { if (ui.setupStep === 2) { ui.setupStep = 3; ui.setupDraft.discovered = []; render(); try { ui.setupDraft.discovered = await window.forgeflow.discoverRepositories(ui.setupDraft.roots); } catch (error) { showToast('Discovery failed', error.message, 'error'); } ui.setupStep = 4; render(); } }
else if (action === 'setup-back') { ui.setupStep = Math.max(0, ui.setupStep - 1); render(); }
else if (action === 'setup-finish') { setLoading(true, 'Saving configuration…'); try { const result = await window.forgeflow.completeSetup({ baseUrl: ui.setupDraft.baseUrl, token: ui.setupDraft.token, user: ui.setupDraft.user, workspaceRoots: ui.setupDraft.roots }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Setup complete', result.tokenState.persistent ? 'Your token is stored securely.' : 'Your token is available for this session only.', 'success'); } catch (error) { showToast('Could not complete setup', error.message, 'error'); } setLoading(false); }
else if (action === 'check-updates') {
ui.updateChecking = true; render();
try { ui.updateStatus = await window.forgeflow.checkForUpdates(); showToast(ui.updateStatus.available ? 'Update available' : 'ForgeFlow is current', ui.updateStatus.available ? `Version ${ui.updateStatus.remoteVersion} can be downloaded.` : `Version ${ui.updateStatus.currentVersion} is the newest release.`, ui.updateStatus.available ? 'success' : 'info'); }
catch (error) { showToast('Update check failed', error.message, 'error'); }
ui.updateChecking = false; render();
}
else if (action === 'save-update-settings') {
const updates = {
owner: document.querySelector('#update-owner').value.trim(),
repo: document.querySelector('#update-repo').value.trim(),
branch: document.querySelector('#update-branch').value.trim(),
autoCheck: document.querySelector('#update-auto-check').value === 'true'
};
try { ui.boot.state = await window.forgeflow.setUpdatePreferences(updates); ui.updateStatus = null; showToast('Update settings saved', 'The next check will use this repository and branch.', 'success'); }
catch (error) { showToast('Could not save update settings', error.message, 'error'); }
render();
}
else if (action === 'download-update') {
setLoading(true, 'Downloading and verifying the exact ForgeFlow update…');
try { ui.updateStatus = await window.forgeflow.downloadUpdate(); showToast('Update downloaded', `Version ${ui.updateStatus.remoteVersion} passed the archive check.`, 'success'); }
catch (error) { showToast('Update download failed', error.message, 'error'); }
setLoading(false);
}
else if (action === 'apply-update') {
if (!confirm(`Apply ForgeFlow ${ui.updateStatus?.remoteVersion || 'update'} now? ForgeFlow closes, validates the update and restarts automatically.`)) return;
setLoading(true, 'Launching safe updater…');
try { await window.forgeflow.applyUpdate(); showToast('Update launched', 'ForgeFlow will close and restart after validation.', 'success'); }
catch (error) { showToast('Could not launch update', error.message, 'error'); setLoading(false); }
}
else if (action === 'open-add-server') { ui.modal = { type: 'server-config', serverId: null, authType: 'privateKey' }; render(); }
else if (action === 'edit-server') { const server = (ui.boot.state.servers || []).find((item) => item.id === target.dataset.serverId); ui.modal = { type: 'server-config', serverId: target.dataset.serverId, authType: server?.authType || 'privateKey' }; render(); }
else if (action === 'select-private-key') {
const keyPath = await window.forgeflow.selectKeyFile({ title: 'Select SSH private key', defaultPath: document.querySelector('#server-private-key')?.value || undefined });
if (keyPath) document.querySelector('#server-private-key').value = keyPath;
}
else if (action === 'save-server') {
const authType = document.querySelector('#server-auth-type').value;
const server = {
id: target.dataset.serverId || undefined,
name: document.querySelector('#server-name').value.trim(),
host: document.querySelector('#server-host').value.trim(),
port: Number(document.querySelector('#server-port').value),
username: document.querySelector('#server-username').value.trim(),
authType,
basePath: document.querySelector('#server-base-path').value.trim(),
privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '',
hostFingerprint: document.querySelector('#server-fingerprint').value.trim()
};
const password = document.querySelector('#server-password')?.value || '';
const passphrase = document.querySelector('#server-passphrase')?.value || '';
setLoading(true, 'Saving encrypted SSH configuration…');
try {
const result = await window.forgeflow.saveServer(server, password, passphrase);
ui.boot.state = result.state; ui.modal = null; showToast('Server saved', 'Run Test & trust before creating a deployment.', 'success');
} catch (error) { showToast('Could not save server', error.message, 'error'); }
setLoading(false);
}
else if (action === 'test-server') {
setLoading(true, 'Connecting to Unraid and checking Git and Docker Compose…');
try { const result = await window.forgeflow.testServer(target.dataset.serverId); ui.boot.state = result.state; showToast('SSH server ready', `${result.server.name} presented ${result.fingerprint}.`, 'success'); }
catch (error) { showToast('SSH test failed', error.message, 'error'); }
setLoading(false);
}
else if (action === 'delete-server') {
if (!confirm('Delete this server and all deployment profiles linked to it?')) return;
try { ui.boot.state = await window.forgeflow.deleteServer(target.dataset.serverId); ui.modal = null; await refreshRepositories(false); showToast('Server deleted', 'Linked SSH deployment profiles were removed.', 'success'); }
catch (error) { showToast('Could not delete server', error.message, 'error'); }
}
else if (action === 'add-root') { const root = await window.forgeflow.selectDirectory({ title: 'Add development folder' }); if (root && !ui.boot.state.workspaceRoots.includes(root)) ui.boot.state.workspaceRoots.push(root); render(); }
else if (action === 'remove-root') { ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1); render(); }
else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); }
else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); }
else if (action === 'repair-origin') {
if (!repository?.localPath || !repository.sshUrl) return;
if (!confirm(`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`)) return;
setLoading(true, 'Updating Git origin…');
try { await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); await refreshRepositories(false); showToast('Git origin updated', repository.sshUrl, 'success'); }
catch (error) { showToast('Could not update origin', error.message, 'error'); }
setLoading(false);
}
else if (action === 'normalize-origins') {
if (!confirm('Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.')) return;
setLoading(true, 'Normalizing linked Git origins…');
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast('Git origins normalized', `${result.changes.length} repository origin${result.changes.length === 1 ? '' : 's'} updated.`, 'success');
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'scan-git-recovery') {
if (!repository?.localPath) return;
setLoading(true, 'Scanning Git directory and active processes…');
try { ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); ui.repositoryTab = 'gittools'; showToast('Git health scan complete', `${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`, ui.gitRecovery.lockReport.locks.length ? 'info' : 'success'); }
catch (error) { showToast('Git health scan failed', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-git-locks' || action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.')) return;
setLoading(true, 'Safely repairing stale Git locks…');
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, false); ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath); await refreshRepositories(false); showToast('Git locks repaired', `${result.removed.length} stale lock file(s) removed.`, 'success'); }
catch (error) {
if (error.code === 'GIT_PROCESS_PROBE_UNAVAILABLE' && confirm(`${error.message}
Force repair after you have closed all Git tools for this repository?`)) {
try { const result = await window.forgeflow.repairGitLocks(repository.localPath, true); showToast('Git locks force-repaired', `${result.removed.length} lock file(s) removed.`, 'success'); await refreshRepositories(false); }
catch (forceError) { showToast('Could not repair Git locks', forceError.message, 'error'); }
} else showToast('Could not repair Git locks', error.message, 'error');
}
setLoading(false); render();
}
else if (action === 'reconcile-repository') {
if (!repository?.localPath) return;
setLoading(true, 'Refreshing repository truth from Git…');
try { ui.gitRecovery = await window.forgeflow.reconcileRepository(repository.localPath); await refreshRepositories(false); showToast('Repository reconciled', 'Branch, upstream, lock and working-tree state were refreshed.', 'success'); }
catch (error) { showToast('Could not reconcile repository', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-repository-sync') {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
const destructive = strategy === 'backup-reset';
const message = destructive
? 'Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded.'
: `Run the repository-specific ${strategy} repair now?`;
if (!confirm(message)) return;
setLoading(true, destructive ? 'Creating safety branch and repairing divergence…' : 'Repairing repository synchronization…');
try {
const result = await window.forgeflow.repairRepositorySync(repository.localPath, strategy);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(repository.localPath);
await refreshRepositories(false);
showToast('Repository synchronization repaired', result.backupBranch ? `Safety branch created: ${result.backupBranch}` : `Completed ${strategy}.`, 'success');
} catch (error) { showToast('Synchronization repair failed', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') {
const preferences = {
diagnosticsEnabled: document.querySelector('#diagnostics-enabled').value === 'true',
diagnosticLevel: document.querySelector('#diagnostic-level').value,
logRetentionDays: Number(document.querySelector('#diagnostic-retention').value),
maxLogFileMb: Number(document.querySelector('#diagnostic-max-file').value)
};
setLoading(true, 'Saving diagnostic policy…');
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast('Diagnostic policy saved', 'New events now use the updated retention and logging level.', 'success');
} catch (error) { showToast('Could not save diagnostics', error.message, 'error'); }
setLoading(false);
}
else if (action === 'export-diagnostics') {
const privacyMode = document.querySelector('#diagnostic-privacy')?.value || 'standard';
setLoading(true, 'Creating redacted diagnostic bundle…');
try {
const bundle = await window.forgeflow.exportDiagnostics(privacyMode);
if (bundle) {
ui.lastDiagnosticBundle = bundle;
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast('Diagnostic bundle created', `${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`, 'success');
}
} catch (error) { showToast('Could not export diagnostics', error.message, 'error'); }
setLoading(false);
}
else if (action === 'show-diagnostic-bundle') {
if (!ui.lastDiagnosticBundle?.path) return;
await window.forgeflow.showDiagnosticBundle(ui.lastDiagnosticBundle.path).catch((error) => showToast('Could not show bundle', error.message, 'error'));
}
else if (action === 'open-diagnostics-folder') await window.forgeflow.openDiagnosticsFolder().catch((error) => showToast('Could not open diagnostic folder', error.message, 'error'));
else if (action === 'clear-diagnostics') {
if (!confirm('Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.')) return;
try { ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics(); showToast('Diagnostic logs cleared', 'A new session marker was created.', 'success'); render(); }
catch (error) { showToast('Could not clear logs', error.message, 'error'); }
}
else if (action === 'reset-app') { if (!confirm('Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.')) return; ui.boot.state = await window.forgeflow.reset(); ui.repositories = []; ui.setupStep = 0; ui.setupValidation = null; ui.systemPreflight = null; ui.deploymentPreflight = null; ui.lastDiagnosticBundle = null; ui.setupDraft = { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] }; render(); }
else if (action === 'run-command') {
const command = target.dataset.command; ui.modal = null;
if (command === 'overview') ui.currentView = 'overview';
else if (command === 'deployments') ui.currentView = 'deployments';
else if (command === 'diagnostics') ui.currentView = 'diagnostics';
else if (command === 'settings') ui.currentView = 'settings';
else if (command === 'refresh') await refreshRepositories(true);
else if (command === 'open-folder' && repository?.localPath) await window.forgeflow.openPath(repository.localPath);
else if (command === 'git-tools' && repository) await loadGitTools(repository);
else if (command === 'deploy-selected' && repository?.readyToDeploy) { const profile = selectedProfile(repository); if (profile) await runDeploymentPreflight(repository, profile.id); }
render();
}
});
app.addEventListener('input', (event) => {
if (event.target.id === 'global-search') { ui.search = event.target.value; render(); document.querySelector('#global-search')?.focus(); }
else if (event.target.id === 'repo-filter') { ui.repoSearch = event.target.value; render(); document.querySelector('#repo-filter')?.focus(); }
else if (event.target.id === 'commit-message') { ui.commitMessage = event.target.value; const position = event.target.selectionStart; render(); const next = document.querySelector('#commit-message'); if (next) { next.focus(); next.setSelectionRange(position, position); } }
else if (event.target.id === 'setup-url') ui.setupDraft.baseUrl = event.target.value;
else if (event.target.id === 'setup-token') ui.setupDraft.token = event.target.value;
else if (event.target.id === 'palette-input') { ui.paletteQuery = event.target.value; render(); }
});
app.addEventListener('change', async (event) => {
if (event.target.matches('[data-file-select]')) { const filePath = event.target.dataset.fileSelect; if (event.target.checked) ui.selectedFiles.add(filePath); else ui.selectedFiles.delete(filePath); render(); }
else if (event.target.id === 'appearance-select') { ui.boot.state = await window.forgeflow.setAppearance(event.target.value); applyTheme(event.target.value); render(); }
else if (event.target.id === 'action-profile-select') { ui.selectedProfileId = event.target.value; render(); }
else if (event.target.id === 'profile-provider') { ui.modal.provider = event.target.value; render(); }
else if (event.target.id === 'server-auth-type') { ui.modal.authType = event.target.value; render(); }
});
document.addEventListener('keydown', (event) => {
if ((event.key === 'Enter' || event.key === ' ') && event.target.matches('.file-row[data-action="select-file"]')) { event.preventDefault(); event.target.click(); return; }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); ui.paletteQuery = ''; ui.modal = { type: 'command-palette' }; render(); return; }
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter' && ui.currentView === 'repository') { const button = document.querySelector('[data-action="commit-push"]:not(:disabled)'); if (button) button.click(); }
if (event.key === 'F5') { event.preventDefault(); refreshRepositories(true); }
if (event.key === 'Escape' && ui.modal) { ui.modal = null; render(); }
});
window.addEventListener('error', (event) => {
window.forgeflow.reportRendererEvent?.('error', 'uncaught-error', {
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack
}).catch(() => {});
});
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
window.forgeflow.reportRendererEvent?.('error', 'unhandled-rejection', {
message: reason?.message || String(reason || 'Unknown rejection'),
stack: reason?.stack
}).catch(() => {});
});
bootstrap();