Files
ForgeFlow/src/renderer/app.js
T

3364 lines
192 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const app = document.querySelector("#app");
const toastRoot = document.querySelector("#toast-root");
const icons = {
overview:
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
repository:
'<path d="M6 3v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
deploy: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
settings:
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.09A1.7 1.7 0 0 0 8.95 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3v-4h.09A1.7 1.7 0 0 0 4.6 8.95a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 8.95 4.6a1.7 1.7 0 0 0 1.03-1.56V3h4v.09A1.7 1.7 0 0 0 15.05 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 8.95a1.7 1.7 0 0 0 1.56 1.03H21v4h-.09A1.7 1.7 0 0 0 19.4 15Z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
refresh: '<path d="M20 11a8 8 0 1 0-2.34 5.66"/><path d="M20 4v7h-7"/>',
folder: '<path d="M3 7h6l2 2h10v10H3z"/><path d="M3 7V5h6l2 2"/>',
git: '<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8a2 2 0 0 1 2 2v0a5 5 0 0 1-5 5H6"/>',
branch:
'<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8M18 8a7 7 0 0 1-7 7H6"/>',
file: '<path d="M6 2h8l4 4v16H6z"/><path d="M14 2v5h5"/>',
check: '<path d="m5 12 4 4L19 6"/>',
warning: '<path d="M12 3 2 21h20L12 3Z"/><path d="M12 9v5M12 18h.01"/>',
error: '<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',
arrowRight: '<path d="M5 12h14M14 7l5 5-5 5"/>',
arrowUp: '<path d="M12 19V5m-5 5 5-5 5 5"/>',
arrowDown: '<path d="M12 5v14m-5-5 5 5 5-5"/>',
external:
'<path d="M14 3h7v7M10 14 21 3"/><path d="M21 14v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h6"/>',
play: '<path d="m8 5 11 7-11 7V5Z"/>',
terminal: '<path d="m4 6 5 5-5 5M11 18h9"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/>',
moon: '<path d="M20 15.2A8 8 0 0 1 8.8 4 8.5 8.5 0 1 0 20 15.2Z"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/>',
close: '<path d="m6 6 12 12M18 6 6 18"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
chevron: '<path d="m9 18 6-6-6-6"/>',
link: '<path d="M10 13a5 5 0 0 0 7.07.07l2-2A5 5 0 0 0 12 4l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.07-.07l-2 2A5 5 0 0 0 12 20l1.15-1.15"/>',
cloud:
'<path d="M17.5 19H6a4 4 0 0 1-.4-7.98A6.5 6.5 0 0 1 18 9.5h.5a4.75 4.75 0 0 1-1 9.5Z"/>',
pulse: '<path d="M3 12h4l2-6 4 12 2-6h6"/>',
history:
'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
star: '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"/>',
archive: '<path d="M4 7h16v13H4zM3 3h18v4H3zM9 11h6"/>',
shield:
'<path d="M12 3 20 6v6c0 5-3.4 8-8 9-4.6-1-8-4-8-9V6l8-3Z"/><path d="m9 12 2 2 4-5"/>',
rocket:
'<path d="M14 5c2-2 5-2 6-2 0 1 0 4-2 6l-4 4-5-4 5-4Z"/><path d="m9 9-4 1-2 3 6 1M14 14l-1 6-3 2-1-6"/><path d="m5 19 3-3"/>',
layers:
'<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/>',
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/>',
server:
'<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l2 2M14 9l2 2"/>',
update:
'<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
wrench:
'<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>',
};
function icon(name, className = "") {
return `<span class="icon ${className}" aria-hidden="true"><svg viewBox="0 0 24 24">${icons[name] || icons.file}</svg></span>`;
}
function escapeHtml(value) {
return String(value ?? "").replace(
/[&<>'"]/g,
(character) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[
character
],
);
}
function attr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
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,
troubleshooter: null,
deploymentDiscovery: null,
serverDiscovery: [],
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,
diffHunks: null,
conflictState: null,
branchProtection: null,
pullRequests: [],
auditEvents: [],
};
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")}<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(message)}</span></div>`;
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 = `<div class="boot-screen">${icon("error")}<strong>ForgeFlow could not start</strong><span>${escapeHtml(error.message)}</span></div>`;
}
}
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) {
let discovery = [];
try {
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
ui.serverDiscovery = discovery;
const adopted = discovery.reduce(
(total, server) => total + Number(server.adopted || 0),
0,
);
if (adopted > 0) {
await refreshRepositories(false, true);
showToast(
"Server workloads discovered",
`${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`,
"success",
);
}
} catch (error) {
if (showErrors)
showToast("Server discovery unavailable", error.message, "error");
}
const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({
repository,
profile,
})),
);
if (!targets.length) return { checked: 0, failed: 0, discovery };
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, discovery };
}
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;
ui.branchProtection = 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 (repository?.owner?.login && repository?.localStatus?.branch?.head) {
window.forgeflow
.branchProtection(repository.fullName, repository.localStatus.branch.head)
.then((protection) => {
if (String(ui.selectedRepoId) === String(id)) {
ui.branchProtection = protection;
render();
}
})
.catch(() => {});
}
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),
);
ui.diffHunks = file?.unstaged
? await window.forgeflow
.repositoryDiffHunks(repository.localPath, filePath)
.catch(() => null)
: null;
} 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 `<button class="nav-button ${ui.currentView === view ? "active" : ""}" data-action="navigate" data-view="${view}">${icon(iconName)}<span>${label}</span>${count !== "" ? `<span class="nav-count">${count}</span>` : ""}</button>`;
}
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 `<header class="titlebar">
<div class="titlebar-left"><div class="wordmark"><img class="brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/><span>ForgeFlow</span><small>by ITWorx.tech</small></div><span class="workspace-name">${escapeHtml(title)}</span></div>
<div class="titlebar-right">
<button class="command-trigger" data-action="open-palette">${icon("search")}<span>Commands</span><kbd>Ctrl K</kbd></button>
<div class="search-wrap">${icon("search")}<input id="global-search" class="global-search" value="${attr(ui.search)}" placeholder="Search repositories…" aria-label="Search repositories" /></div>
<span class="connection-chip" title="${connected ? `Connected as ${attr(user?.login || "user")}` : "Not connected"}"><span class="dot" style="${connected ? "" : "background:var(--danger)"}"></span>${connected ? escapeHtml(user?.login || "Gitea") : "Offline"}</span>
<button class="icon-button" data-action="refresh" title="Refresh repositories">${icon("refresh")}</button>
<button class="icon-button" data-action="toggle-theme" title="Toggle theme">${icon(document.documentElement.dataset.theme === "dark" ? "sun" : "moon")}</button>
</div>
</header>`;
}
function renderRepositoryRow(repository) {
const status = repository.localStatus;
const badges = [];
if (status?.counts.conflicts)
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
else if (status?.counts.changed)
badges.push(
`<span class="mini-badge warning" title="Changed files">${status.counts.changed}</span>`,
);
if (status?.branch.ahead)
badges.push(
`<span class="mini-badge" title="Commits ahead">↑${status.branch.ahead}</span>`,
);
if (status?.branch.behind)
badges.push(
`<span class="mini-badge danger" title="Commits behind">↓${status.branch.behind}</span>`,
);
if (repository.readyToDeploy)
badges.push(
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
);
if (!repository.localPath)
badges.push('<span class="mini-badge" title="No local folder">—</span>');
const branch = status?.branch.head || repository.defaultBranch || "remote";
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}">
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
<span class="repo-badges">${badges.join("")}</span>
</button>`;
}
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 `<aside class="sidebar">
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}</nav>
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
<div class="repo-list">
${favorites.length ? `<div class="repo-group-label">Favorites</div>${rows(favorites)}` : ""}
${favorites.length && others.length ? '<div class="repo-group-label">All repositories</div>' : ""}
${others.length ? rows(others) : !favorites.length ? '<div class="empty-state compact"><p>No matching repositories.</p></div>' : ""}
</div>
<div class="sidebar-footer"><div class="sidebar-diagnostic-state"><span class="state-dot ${ui.diagnosticsStatus?.lastWriteError ? "danger" : ui.diagnosticsStatus?.enabled === false ? "" : "success"}"></span><div><strong>${ui.diagnosticsStatus?.lastWriteError ? "Diagnostic write error" : ui.diagnosticsStatus?.enabled === false ? "Diagnostics disabled" : "Safe diagnostics active"}</strong><span>${ui.diagnosticsStatus?.lastWriteError ? "Open Diagnostics for details" : "Credentials are redacted locally"}</span></div></div></div>
</aside>`;
}
function renderSummaryCard(label, value, note, iconName, tone = "") {
return `<div class="summary-card ${tone}">${icon(iconName)}<div class="eyebrow">${label}</div><div class="summary-value">${value}</div><div class="summary-label">${note}</div></div>`;
}
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 projectIllustration(kind = "flow") {
return `<div class="project-illustration ${attr(kind)}" data-project-illustration aria-hidden="true">
<div class="illustration-glow"></div><svg viewBox="0 0 260 150" role="presentation">
<path class="orbit orbit-a" d="M32 92 C72 20 190 18 230 82"/><path class="orbit orbit-b" d="M42 116 C98 150 190 136 222 60"/>
<g class="illustration-core"><rect x="83" y="39" width="94" height="74" rx="17"/><path d="M103 66h54M103 79h36M103 92h45"/><circle cx="160" cy="92" r="5"/></g>
<g class="illustration-node node-a"><circle cx="37" cy="92" r="12"/><path d="m32 92 4 4 7-9"/></g>
<g class="illustration-node node-b"><circle cx="226" cy="82" r="12"/><path d="M221 82h10M226 77v10"/></g>
<g class="illustration-node node-c"><circle cx="74" cy="31" r="8"/></g>
<circle class="signal signal-a" cx="0" cy="0" r="4"/><circle class="signal signal-b" cx="0" cy="0" r="3"/>
</svg><span class="illustration-label">${kind === "deploy" ? "Live release topology" : kind === "repo" ? "Project signal" : "Release flow"}</span>
</div>`;
}
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 `<div class="page">
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
<div class="summary-grid">
${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")}
</div>
<section class="section-block"><div class="section-heading"><h2>Action queue</h2><span class="meta">Sorted by required attention</span></div><div class="action-queue">
${
queue.length
? queue
.map((repository) => {
const [iconName, label, reason, tone] =
queueActionFor(repository);
return `<div class="queue-row"><span class="queue-icon ${tone}">${icon(iconName)}</span><div><div class="queue-title">${escapeHtml(repository.name)}</div><div class="queue-sub">${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}</div></div><div class="queue-reason"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(reason)}</span></div><button class="button" data-action="select-repo" data-id="${attr(repository.id)}">Open ${icon("arrowRight")}</button></div>`;
})
.join("")
: '<div class="empty-state"><div class="empty-icon">✓</div><h3>Everything is synchronized</h3><p>No repository needs immediate attention.</p></div>'
}
</div></section>
<section class="section-block two-column">
<div class="panel"><div class="panel-header"><h2>Recent deployments</h2><button class="button ghost" data-action="navigate" data-view="deployments">View all</button></div><div class="activity-list">${recent.length ? recent.map((operation) => `<div class="activity-item"><span class="activity-dot ${toneForStatus(operation.status)}"></span><div><div class="activity-title">${escapeHtml(operation.repository)}${escapeHtml(operation.environment || "environment")}</div><div class="activity-sub">${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}</div></div><span class="activity-time">${formatDate(operation.updatedAt || operation.createdAt)}</span></div>`).join("") : '<div class="empty-state compact"><p>No deployment history yet.</p></div>'}</div></div>
<div class="panel"><div class="panel-header"><h2>Workspace readiness</h2></div><div class="panel-body readiness-list">
${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`)}
</div></div>
</section>
</div>`;
}
function readinessRow(label, ok, detail) {
return `<div class="readiness-row"><span class="state-dot ${ok ? "success" : "danger"}"></span><div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div></div>`;
}
function releaseNode(label, value, description, tone = "") {
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
}
function diffAtmosphere(diff) {
if (!ui.selectedFile) return "";
const lines = String(diff || "").split("\n");
const additions = lines.filter(
(line) => line.startsWith("+") && !line.startsWith("+++"),
).length;
const removals = lines.filter(
(line) => line.startsWith("-") && !line.startsWith("---"),
).length;
const extension =
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
"FILE";
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>${removals}</i></strong></div></div>`;
}
function renderDiff(diff) {
if (!diff)
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
const rendered = escapeHtml(diff)
.split("\n")
.map((line) => {
const type =
line.startsWith("+") && !line.startsWith("+++")
? "add"
: line.startsWith("-") && !line.startsWith("---")
? "remove"
: line.startsWith("@@")
? "hunk"
: "";
return `<span class="diff-line ${type}">${line || " "}</span>`;
})
.join("");
return `${rendered}${diffAtmosphere(diff)}`;
}
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 `<div class="empty-state full"><div class="empty-icon">${icon("link")}</div><h3>Connect a local project</h3><p>Clone directly into your default project root, or link an existing working tree.</p>${target ? `<div class="notice"><span>${icon("folder")}Automatic destination</span><strong class="mono">${escapeHtml(target)}</strong></div>` : '<div class="notice warning">No default project root is configured. ForgeFlow will ask for one.</div>'}<div class="stack horizontal"><button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button" data-action="link-repo">${icon("link")}Link existing folder</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button></div></div>`;
}
if (!status)
return `<div class="empty-state full"><div class="empty-icon">${icon("error")}</div><h3>Repository unavailable</h3><p>${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}</p></div>`;
if (!status.files.length)
return `<div class="empty-state full"><div class="empty-icon">${icon("check")}</div><h3>Working tree clean</h3><p>Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.</p><div class="stack horizontal"><button class="button" data-action="fetch">${icon("refresh")}Fetch remote state</button><button class="button" data-action="open-path">${icon("folder")}Open project</button></div></div>`;
const selected = status.files.find((file) => file.path === ui.selectedFile);
const conflictActions = selected?.conflict
? `<div class="notice danger"><div><strong>Conflicted file</strong><p>Choose one side, or edit the file and mark it resolved.</p></div><div class="stack horizontal compact"><button class="button" data-action="resolve-conflict" data-resolution="ours">Use ours</button><button class="button" data-action="resolve-conflict" data-resolution="theirs">Use theirs</button><button class="button primary" data-action="resolve-conflict" data-resolution="resolved">Mark resolved</button></div></div>`
: "";
return `<div class="changes-layout"><section class="file-panel"><div class="file-panel-tools"><span><strong>${ui.selectedFiles.size}</strong> selected · ${status.counts.changed} changed · ${status.counts.staged} staged</span><button class="button ghost small" data-action="toggle-all-files">${ui.selectedFiles.size === status.files.length ? "Clear" : "Select all"}</button></div><div class="file-list" tabindex="0" aria-label="Changed files">${status.files.map((file) => `<div class="file-row ${ui.selectedFile === file.path ? "active" : ""}" role="button" tabindex="0" data-action="select-file" data-path="${attr(file.path)}"><input type="checkbox" data-file-select="${attr(file.path)}" ${ui.selectedFiles.has(file.path) ? "checked" : ""} aria-label="Include ${attr(file.path)}"/><span class="file-status ${attr(file.status)}">${fileStatusCode(file)}</span><span class="file-path" title="${attr(file.path)}">${escapeHtml(file.path)}</span><span title="${file.staged ? "Staged" : "Unstaged"}">${file.staged ? "●" : "○"}</span></div>`).join("")}</div>${status.counts.conflicts ? `<div class="card-actions"><button class="button danger" data-action="load-conflicts">${icon("warning")}Conflict guide</button></div>` : ""}</section><section class="diff-panel">${conflictActions}<div class="diff-toolbar"><span class="diff-title">${escapeHtml(ui.selectedFile || "Select a file")}</span><div class="stack horizontal compact">${ui.diffHunks?.partialSupported ? `<button class="button small" data-action="open-hunk-staging">Stage hunks</button>` : ""}${ui.selectedFile ? `<button class="button ghost small" data-action="open-file-editor">${icon("external")}Editor</button>` : ""}<span class="status-pill">${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}</span><button class="icon-button" data-action="copy-diff" title="Copy diff">${icon("copy")}</button></div></div><div class="diff-view">${renderDiff(ui.diff)}</div></section></div>`;
}
function renderHistory(repository) {
if (!repository.localPath)
return '<div class="empty-state full"><p>Link a local repository to view commit history.</p></div>';
if (!ui.history.length)
return `<div class="empty-state full"><div class="empty-icon">${icon("history")}</div><h3>Load local commit history</h3><p>Review the last commits from this working tree.</p><button class="button primary" data-action="load-history">Load history</button></div>`;
return `<div class="tab-page"><div class="panel"><table class="data-table"><thead><tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr></thead><tbody>${ui.history.map((commit) => `<tr><td class="mono">${escapeHtml(commit.shortSha)}</td><td>${escapeHtml(commit.subject)}</td><td>${escapeHtml(commit.author)}</td><td>${formatDate(commit.date)}</td></tr>`).join("")}</tbody></table></div></div>`;
}
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 deploymentIdentity(profile, repository) {
const name = String(
profile.state?.containerName ||
profile.containerName ||
profile.remoteFolder ||
repository.name ||
"container",
);
let hash = 0;
for (const character of name)
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
}
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.adoptedFromServer ? " · automatically discovered" : ""}`
: `${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 || "";
const identity = deploymentIdentity(profile, repository);
const syncLabel = state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.giteaSha && state.liveSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
}
function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || [];
const repoOps = repositoryOperations(repository).slice(0, 10);
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">Exact-commit Gitea Actions or pinned SSH / Unraid deployments</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
}
function renderGitTools(repository) {
if (!repository.localPath)
return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
const recovery = ui.gitRecovery;
const locks = recovery?.lockReport?.locks || [];
const activeProcesses = recovery?.lockReport?.processes?.active || [];
const recommendations = recovery?.recommendations || [];
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
}
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,
);
const pullRequests = ui.pullRequests || [];
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${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.</div></section></div>`;
}
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 `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
<div class="release-rail">${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)}</div>
<nav class="tabs">${[
["changes", "Changes"],
["history", "History"],
["deployments", "Deployments"],
["gittools", "Git tools"],
["settings", "Project settings"],
]
.map(
([id, label]) =>
`<button class="tab ${ui.repositoryTab === id ? "active" : ""}" data-action="repo-tab" data-tab="${id}">${label}</button>`,
)
.join("")}</nav><div class="repo-content">${content}</div></div>`;
}
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 = `<div class="panel-callout"><div class="callout-icon">${icon("link")}</div><h2>${action.title}</h2><p>${action.detail}</p>${target ? `<div class="deploy-proof"><span>Project root</span><strong class="mono">${escapeHtml(defaultWorkspaceRoot())}</strong><span>New folder</span><strong class="mono">${escapeHtml(safeCloneFolderName(repository))}</strong></div>` : '<div class="notice warning">No default project root is configured yet.</div>'}<button class="button primary block" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button block" style="margin-top:8px" data-action="link-repo">Link existing folder</button><button class="button ghost block" style="margin-top:8px" data-action="clone-repo-custom">Choose another clone location</button></div>`;
} else if (action.kind === "commit") {
const hasStagedSelection = status.counts.staged > 0;
const commitReady = Boolean(
(ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(),
);
const commitBlocker =
!ui.selectedFiles.size && !hasStagedSelection
? "Select files or stage one or more hunks."
: !ui.commitMessage.trim()
? "Enter a commit message to enable commit and push."
: ui.selectedFiles.size
? "Ready to commit. ForgeFlow stages the selected files automatically."
: "Ready to commit only the reviewed staged hunks.";
body = `<label class="field-label" for="commit-message">Commit message <span class="required-mark">required</span></label><textarea id="commit-message" class="textarea" placeholder="Describe what changed and why…">${escapeHtml(ui.commitMessage)}</textarea><div class="field-hint"><span>${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}</span><span>Ctrl+Enter</span></div><div class="commit-readiness ${commitReady ? "ready" : "blocked"}">${icon(commitReady ? "check" : "warning")}<span>${escapeHtml(commitBlocker)}</span></div><button class="button primary block" style="margin-top:10px" data-action="commit-push" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("arrowUp")}${ui.selectedFiles.size ? "Commit selected" : "Commit staged hunks"} & push to Gitea</button><button class="button block" style="margin-top:8px" data-action="commit-only" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("git")}${ui.selectedFiles.size ? "Commit selected locally" : "Commit staged hunks locally"}</button><div class="stage-note">Partial hunk staging is preserved when no complete files are selected.</div><div class="stack" style="margin-top:8px"><button class="button block" data-action="stage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Stage selected files</button><button class="button block" data-action="unstage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Unstage selected files</button><button class="button block" data-action="stash-changes">${icon("archive")}Stash all changes</button></div>`;
} else if (action.kind === "pull")
body = `<div class="panel-callout"><div class="callout-icon warning">${icon("arrowDown")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`;
else if (action.kind === "push")
body = `<div class="panel-callout"><div class="callout-icon">${icon("arrowUp")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"}</button></div>`;
else if (
action.kind === "diverged" ||
action.kind === "conflict" ||
action.kind === "error"
)
body = `<div class="panel-callout"><div class="callout-icon danger">${icon("error")}</div><h2>${action.title}</h2><p>${action.detail}</p>${action.kind === "diverged" ? `<button class="button primary block" data-action="load-git-tools">${icon("wrench")}Open guided repository repair</button>` : ""}<button class="button block" style="margin-top:8px" data-action="open-path">Open project folder</button><button class="button block" style="margin-top:8px" data-action="refresh">Refresh status</button></div>`;
else if (action.kind === "configure")
body = `<div class="panel-callout"><div class="callout-icon">${icon("settings")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`;
else if (action.kind === "branch-profile")
body = `<div class="panel-callout"><div class="callout-icon">${icon("branch")}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join("")}</select>` : ""}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit profile</button></div>`;
else if (action.kind === "deploy")
body = `<div class="panel-callout"><div class="callout-icon success">${icon("rocket")}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join("")}</select>` : ""}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(status.shortHead)}${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ""}</div>`;
else
body = `<div class="panel-callout"><div class="callout-icon success">${icon("check")}</div><h2>${action.title}</h2><p>${action.detail}</p>${profile ? `<button class="button block" data-action="refresh-profile-state" data-profile-id="${attr(profile.id)}">${icon("pulse")}Check ${escapeHtml(profile.environment)}</button>` : ""}</div>`;
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
}
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 `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${
operations().length
? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations()
.map(
(operation) =>
`<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`,
)
.join("")}</tbody></table>`
: '<div class="empty-state compact"><p>No operations recorded.</p></div>'
}</div></section></div>`;
}
function renderSettings() {
const state = ui.boot.state;
const prefs = state.preferences || {};
const update = ui.updateStatus;
const servers = state.servers || [];
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${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.</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
<section class="settings-group"><h2>Appearance</h2><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></section>
<section class="settings-group danger-zone"><h2>Danger zone</h2><p>Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.</p><button class="button danger" data-action="reset-app">Reset ForgeFlow</button></section>
</div></div>`;
}
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 `<div class="empty-state compact"><p>${escapeHtml(emptyMessage)}</p></div>`;
return `<div class="preflight-list">${report.checks.map((item) => `<div class="preflight-row"><span class="preflight-state ${preflightTone(item.status)}">${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}</span><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(item.detail)}</span>${item.help ? `<small>${escapeHtml(item.help)}</small>` : ""}</div><span class="status-pill ${preflightTone(item.status)}">${escapeHtml(item.status)}</span></div>`).join("")}</div>`;
}
function renderDiagnostics() {
const prefs = ui.boot.state.preferences || {};
const status = ui.diagnosticsStatus || ui.boot.diagnostics || {};
const report = ui.systemPreflight;
const trouble = ui.troubleshooter;
const troubleRows =
trouble?.issues
?.map(
(item, index) =>
`<div class="preflight-row"><span class="preflight-state ${item.severity === "error" ? "danger" : "warning"}">${icon(item.severity === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}</span></div>${item.repairable ? `<button class="button ${item.safe ? "primary" : "danger"}" data-action="troubleshooter-repair" data-issue-index="${index}">${icon("wrench")}${item.safe ? "Repair" : "Review & repair"}</button>` : '<span class="status-pill">Manual review</span>'}</div>`,
)
.join("") || "";
return `<div class="page diagnostics-page"><div class="page-header"><div><div class="eyebrow">Local troubleshooting</div><h1>Diagnostics & support bundle</h1><p>ForgeFlow records structured development diagnostics locally while removing tokens, passwords, authorization headers, private keys and user-home paths.</p></div><button class="button primary" data-action="export-diagnostics">${icon("archive")}Export safe bundle</button></div>
<div class="notice success">${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.</div>
<div class="diagnostic-grid">
<section class="panel"><div class="panel-header"><h2>Log storage</h2><span class="status-pill ${status.lastWriteError ? "danger" : status.enabled ? "success" : ""}">${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}</span></div><div class="panel-body"><div class="diagnostic-metrics"><div><span>Files</span><strong>${escapeHtml(status.fileCount ?? "—")}</strong></div><div><span>Total size</span><strong>${escapeHtml(status.totalSize || "—")}</strong></div><div><span>Latest event</span><strong>${status.latestAt ? formatDate(status.latestAt) : "None"}</strong></div><div><span>Retention</span><strong>${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days</strong></div></div><div class="context-summary" style="margin-top:12px"><div class="context-row"><span>Location</span><strong>${escapeHtml(status.directory || "Unavailable")}</strong></div><div class="context-row"><span>Level</span><strong>${escapeHtml(status.level || prefs.diagnosticLevel || "info")}</strong></div>${status.lastWriteError ? `<div class="context-row"><span>Error</span><strong>${escapeHtml(status.lastWriteError)}</strong></div>` : ""}</div><div class="card-actions"><button class="button" data-action="open-diagnostics-folder">${icon("folder")}Open logs</button><button class="button danger" data-action="clear-diagnostics">${icon("trash")}Clear logs</button></div></div></section>
<section class="panel"><div class="panel-header"><h2>Recording policy</h2></div><div class="panel-body"><div class="form-grid"><div class="field"><label>Diagnostic logging</label><select id="diagnostics-enabled" class="select"><option value="true" ${prefs.diagnosticsEnabled !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.diagnosticsEnabled === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Minimum level</label><select id="diagnostic-level" class="select"><option value="debug" ${prefs.diagnosticLevel === "debug" ? "selected" : ""}>Debug</option><option value="info" ${!prefs.diagnosticLevel || prefs.diagnosticLevel === "info" ? "selected" : ""}>Info</option><option value="warning" ${prefs.diagnosticLevel === "warning" ? "selected" : ""}>Warning</option><option value="error" ${prefs.diagnosticLevel === "error" ? "selected" : ""}>Error only</option></select></div><div class="field"><label>Retention days</label><input id="diagnostic-retention" class="input" type="number" min="1" max="90" value="${attr(prefs.logRetentionDays || 14)}"/></div><div class="field"><label>Maximum file size (MB)</label><input id="diagnostic-max-file" class="input" type="number" min="1" max="50" value="${attr(prefs.maxLogFileMb || 8)}"/></div></div><button class="button primary" style="margin-top:12px" data-action="save-diagnostics-preferences">Save diagnostic policy</button></div></section>
</div>
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
</div>`;
}
function renderPipelineView() {
const operation = ui.activeDeployment;
if (!operation)
return '<div class="empty-state full"><p>No deployment operation selected.</p></div>';
const logs = (operation.logs || []).join("\n");
return `<div class="deployment-view"><div class="page-header"><div><div class="eyebrow">${escapeHtml(operation.action || "deployment")} · ${escapeHtml(operation.environment || "")}</div><h1>${escapeHtml(operation.repository)}</h1><p>Exact commit <span class="mono">${escapeHtml(operation.sha || "")}</span></p></div><div class="stack horizontal"><button class="button" data-action="refresh-current-operation">${icon("refresh")}Refresh</button>${operation.runUrl ? `<button class="button" data-action="open-run-url">${icon("external")}Open in Gitea</button>` : ""}<button class="button" data-action="close-deployment">Close</button></div></div><section class="pipeline-card"><div class="pipeline-head"><div><h2>${escapeHtml(operation.status)}</h2><p>${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}</p></div><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></div><div class="pipeline-stages">${(operation.stages || []).map((stage) => `<div class="pipeline-stage ${stage.status}"><span class="stage-icon">${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}</span><span>${escapeHtml(stage.label)}</span></div>`).join("")}</div></section>${operation.jobs?.length ? `<section class="section-block"><div class="section-heading"><h2>Runner jobs</h2></div><div class="panel"><table class="data-table"><thead><tr><th>Job</th><th>Status</th><th>Started</th><th>Completed</th></tr></thead><tbody>${operation.jobs.map((job) => `<tr><td>${escapeHtml(job.name)}</td><td><span class="status-pill ${toneForStatus(job.conclusion || job.status)}">${escapeHtml(job.conclusion || job.status)}</span></td><td>${job.startedAt ? formatDate(job.startedAt) : "—"}</td><td>${job.completedAt ? formatDate(job.completedAt) : "—"}</td></tr>`).join("")}</tbody></table></div></section>` : ""}<div class="log-view"><div class="log-toolbar"><span>Deployment output</span><button class="button ghost" data-action="copy-logs">${icon("copy")}Copy</button></div><pre class="log-lines">${escapeHtml(logs || "Waiting for operation output…")}</pre></div>${operation.failure ? `<div class="notice danger" style="margin-top:14px">${icon("error")}<div><strong>${escapeHtml(operation.failure.stage)}</strong><p>${escapeHtml(operation.failure.message)}</p></div></div>` : ""}</div>`;
}
function renderStatusbar() {
const state = ui.boot?.state;
const repository = selectedRepository();
const active = operations().filter(
(operation) => !isTerminalOperation(operation.status),
).length;
return `<footer class="statusbar"><div class="statusbar-left"><span class="statusbar-item ${ui.boot?.git?.available ? "success" : "danger"}">${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}</span><span class="statusbar-item">${icon("folder")}${state?.workspaceRoots?.length || 0} roots</span>${repository?.localStatus ? `<span class="statusbar-item">${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}</span>` : ""}</div><div class="statusbar-right">${ui.autoRefreshPending ? `<span class="statusbar-item warning">${icon("refresh")}Change detected</span>` : ""}${active ? `<span class="statusbar-item warning">${icon("pulse")}${active} active</span>` : ""}<span class="statusbar-item">ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}</span></div></footer>`;
}
function renderSetup() {
const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"];
let body = "";
if (ui.setupStep === 0) {
body = `<div class="setup-body"><h1>Check this computer</h1><p>ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.</p><div class="notice" style="margin-top:16px">${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.</div><div class="setup-preflight">${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}</div><div class="setup-support-actions"><button class="button ghost" data-action="export-diagnostics">${icon("archive")}Export setup diagnostics</button><span class="meta">Available even before Gitea is connected.</span></div></div>`;
} else if (ui.setupStep === 1) {
body = `<div class="setup-body"><h1>Connect your Gitea instance</h1><p>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.</p><div class="form-grid" style="margin-top:24px"><div class="field full"><label>Instance URL</label><input id="setup-url" class="input" value="${attr(ui.setupDraft.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label>Access token</label><input id="setup-token" class="input" type="password" value="${attr(ui.setupDraft.token)}" placeholder="Paste token locally" autocomplete="off" /></div></div>${ui.setupValidation ? `<div class="notice success" style="margin-top:14px">${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}</div>` : `<div class="notice" style="margin-top:14px">${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.</div>`}</div>`;
} else if (ui.setupStep === 2) {
body = `<div class="setup-body"><h1>Select development folders</h1><p>Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.</p><div class="stack" style="margin-top:22px">${ui.setupDraft.roots.map((root, index) => `<div class="root-row"><input class="input" value="${attr(root)}" readonly/><button class="icon-button" data-action="setup-remove-root" data-index="${index}">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="setup-add-root">${icon("plus")}Add development folder</button></div></div>`;
} else if (ui.setupStep === 3) {
body = `<div class="setup-body"><h1>Discovering repositories</h1><p>Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.</p><div class="discovery-progress"><div class="spinner"></div><strong>Scanning configured folders…</strong></div></div>`;
} else {
body = `<div class="setup-body"><h1>ForgeFlow is ready</h1><p>${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.</p><div class="setup-summary"><div class="readiness-row"><span class="state-dot success"></span><div><strong>Gitea connected</strong><span>${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Workspace discovery</strong><span>${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Safe diagnostics</strong><span>Structured local logs with credential redaction are enabled by default.</span></div></div></div><div class="discovery-list">${
ui.setupDraft.discovered.length
? ui.setupDraft.discovered
.slice(0, 8)
.map(
(item) =>
`<div class="discovery-row">${icon(item.error ? "error" : "git")}<div><strong>${escapeHtml(item.localPath.split(/[\\/]/).pop())}</strong><span>${escapeHtml(item.localPath)}</span></div><span class="status-pill ${item.error ? "danger" : "success"}">${item.error ? "Unreadable" : "Ready"}</span></div>`,
)
.join("")
: '<div class="empty-state compact"><p>No repositories found. You can link or clone repositories later.</p></div>'
}</div></div>`;
}
const nextAction =
ui.setupStep === 0
? ui.systemPreflight?.summary?.ready
? '<button class="button primary" data-action="setup-continue">Continue</button>'
: '<button class="button primary" data-action="setup-run-preflight">Run readiness check</button>'
: ui.setupStep === 1
? '<button class="button primary" data-action="setup-validate">Validate & continue</button>'
: ui.setupStep === 2
? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? "" : "disabled"}>Scan folders</button>`
: ui.setupStep === 4
? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>'
: "";
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo setup-brand-logo-dark" src="./assets/itworx-wordmark-dark.png" alt="ITWorx.tech"/><img class="setup-brand-logo setup-brand-logo-light" src="./assets/itworx-wordmark-light.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step, index) => `<div class="setup-step ${ui.setupStep === index ? "active" : ui.setupStep > index ? "complete" : ""}"><span class="step-number">${ui.setupStep > index ? "✓" : index + 1}</span><span>${step}</span></div>`).join("")}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? "disabled" : ""}>Back</button>${nextAction}</footer></div></section></div>`;
}
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 storedProfile =
repository?.deploymentProfiles?.find(
(profile) => profile.id === ui.modal.profileId,
) || {};
const discovery =
ui.deploymentDiscovery?.repository === repository?.fullName
? ui.deploymentDiscovery
: null;
const existing = { ...storedProfile, ...(discovery?.profile || {}) };
if (discovery?.provenance) existing.provenance = discovery.provenance;
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 `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Use the server as source of truth"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "Select the server and folder, then let ForgeFlow read Git, Compose, Docker and DockerMan instead of guessing values."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan server" : "Import existing deployment"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
ssh
? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin to this URL</span></label>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || "docker-compose.yml")}"/></div>
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
`
: `
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${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."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-preflight") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
const report = ui.deploymentPreflight;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? "" : "danger"}">${icon(report?.summary?.ready ? "shield" : "error")}<div><strong>${report?.summary?.ready ? "Environment is ready to test" : "Deployment is blocked"}</strong><span>${escapeHtml(repository?.fullName || "")}${escapeHtml(profile?.environment || "")}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? "success" : "danger"}">${report?.summary?.ready ? "Ready" : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || "")}">${icon("rocket")}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit environment</button>`}</footer></section></div>`;
}
if (ui.modal.type === "deploy-confirm") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "rollback-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
const target = profile?.state?.previousSha;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon("undo")}<div><strong>Rollback ${escapeHtml(profile?.environment || "")} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || "")}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || "Unavailable")}</strong><span>Provider</span><strong>${profile?.provider === "ssh-unraid" ? "SSH exact-SHA reset" : escapeHtml(profile?.rollbackWorkflowFile || "Not configured")}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || "Unknown")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || "")}" ${target ? "" : "disabled"}>Rollback exact commit</button></footer></section></div>`;
}
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 `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
}
if (ui.modal.type === "hunk-staging") {
const hunks = ui.diffHunks?.hunks || [];
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Stage selected hunks</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><p>${escapeHtml(ui.selectedFile)}</p><div class="stack">${hunks.map((hunk) => `<label class="check-field"><input type="checkbox" data-hunk-index="${hunk.index}" checked/><span><strong>${escapeHtml(hunk.heading)}</strong><small>${hunk.additions} additions · ${hunk.deletions} deletions</small></span></label><pre class="log-lines">${escapeHtml(hunk.lines.join("\n"))}</pre>`).join("")}</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="stage-chosen-hunks">Stage selected hunks</button></footer></section></div>`;
}
if (ui.modal.type === "pull-request") {
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Create Gitea pull request</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Source branch</label><input class="input" value="${attr(repository?.localStatus?.branch?.head || "")}" readonly/></div><div class="field"><label>Target branch</label><input id="pr-base" class="input" value="${attr(repository?.defaultBranch || "main")}"/></div><div class="field full"><label>Title</label><input id="pr-title" class="input" value="${attr(ui.modal.title || "")}"/></div><div class="field full"><label>Description</label><textarea id="pr-body" class="textarea">${escapeHtml(ui.modal.body || "")}</textarea></div></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="create-pull-request">Create pull request</button></footer></section></div>`;
}
if (ui.modal.type === "conflict-guide") {
const state = ui.conflictState || {};
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Conflict guide · ${escapeHtml(state.operation || "Git")}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body">${state.files?.length ? `<p>Resolve these files, then continue:</p><div class="tool-list">${state.files.map((file) => `<div class="tool-row"><strong>${escapeHtml(file)}</strong><button class="button" data-action="open-conflict-file" data-path="${attr(file)}">Open in editor</button></div>`).join("")}</div>` : '<div class="notice success">All conflicts are marked resolved. You can continue the Git operation.</div>'}</div><footer class="modal-footer"><button class="button danger" data-action="abort-git-operation">Abort operation</button><span class="modal-spacer"></span><button class="button" data-action="close-modal">Close</button><button class="button primary" data-action="continue-git-operation" ${state.canContinue ? "" : "disabled"}>Continue</button></footer></section></div>`;
}
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 `<div class="modal-backdrop palette-backdrop"><section class="command-palette"><div class="palette-search">${icon("search")}<input id="palette-input" value="${attr(ui.paletteQuery)}" placeholder="Type a command…" autofocus/></div><div class="palette-list">${commands.map((command) => `<button class="palette-row" data-action="run-command" data-command="${command.id}" ${command.enabled ? "" : "disabled"}>${icon(command.icon)}<span><strong>${escapeHtml(command.label)}</strong><small>${escapeHtml(command.detail)}</small></span><kbd>↵</kbd></button>`).join("")}</div><div class="palette-footer">Esc to close · Ctrl K anywhere</div></section></div>`;
}
function enhanceRenderedUi() {
const repository = selectedRepository();
if (ui.modal?.type === "deployment-config") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || {};
const policy = profile.deploymentPolicy || {};
document
.querySelector(".modal-body .form-grid")
?.insertAdjacentHTML(
"beforeend",
`<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr((policy.maintenanceWindows || []).map((window) => `${window.days.join(",")}:${window.start}-${window.end}`).join(" | "))}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`,
);
}
if (ui.modal?.type === "deploy-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
document
.querySelector(".modal-body")
?.insertAdjacentHTML(
"beforeend",
`<div class="form-grid" style="margin-top:14px"><div class="field full"><label>Release note ${profile?.deploymentPolicy?.requireNote ? "(required)" : "(optional)"}</label><textarea id="deployment-note" class="textarea" placeholder="What is being released and why?"></textarea></div><label class="check-field"><input id="deployment-override" type="checkbox"/><span>Emergency policy override</span></label><div class="field"><label>Override reason</label><input id="deployment-override-reason" class="input" placeholder="Required when overriding"/></div></div>`,
);
}
if (ui.currentView === "diagnostics") {
const container = document.querySelector(".diagnostics-page");
container?.insertAdjacentHTML(
"beforeend",
`<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>`,
);
}
}
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 = `<div class="app-shell">${renderTitlebar()}<div class="app-body">${renderSidebar()}<main class="workspace ${withPanel ? "with-panel" : ""}"><section class="main-canvas ${withPanel ? "repository-canvas" : ""}">${main}</section>${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `<div class="loading-overlay"><div class="boot-screen"><div class="spinner"></div><strong>${escapeHtml(ui.loadingMessage || "Working…")}</strong></div></div>` : ""}</main></div>${renderStatusbar()}</div>${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`;
enhanceRenderedUi();
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;
const deploymentOptions = {
note: document.querySelector("#deployment-note")?.value.trim() || "",
override: document.querySelector("#deployment-override")?.checked === true,
overrideReason:
document.querySelector("#deployment-override-reason")?.value.trim() || "",
};
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,
deploymentOptions,
);
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 if (ui.repositoryTab === "settings") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
"open",
);
} catch (error) {
ui.pullRequests = [];
showToast("Could not load pull requests", error.message, "error");
}
render();
} 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 === "open-hunk-staging") {
if (ui.diffHunks?.partialSupported) {
ui.modal = { type: "hunk-staging" };
render();
}
} else if (action === "stage-chosen-hunks") {
const indexes = [
...document.querySelectorAll("[data-hunk-index]:checked"),
].map((input) => Number(input.dataset.hunkIndex));
if (!indexes.length) return;
const result = await runOperation(
"Staging selected hunks…",
() =>
window.forgeflow.stageHunks(
repository.localPath,
ui.selectedFile,
indexes,
),
"Selected hunks staged.",
);
if (result) {
ui.modal = null;
await loadDiff(selectedRepository(), ui.selectedFile);
}
} else if (action === "open-file-editor") {
await window.forgeflow
.openEditor(repository.localPath, ui.selectedFile)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-editor") {
await window.forgeflow
.openEditor(repository.localPath)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-terminal") {
await window.forgeflow
.openTerminal(repository.localPath)
.catch((error) =>
showToast("Could not open terminal", error.message, "error"),
);
} else if (action === "load-conflicts") {
ui.conflictState = await window.forgeflow.conflictState(
repository.localPath,
);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "resolve-conflict") {
if (
!ui.selectedFile ||
!confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`)
)
return;
ui.conflictState = await window.forgeflow.resolveConflict(
repository.localPath,
ui.selectedFile,
target.dataset.resolution,
);
await refreshRepositories(false);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "open-conflict-file") {
await window.forgeflow.openEditor(
repository.localPath,
target.dataset.path,
);
} else if (action === "continue-git-operation") {
ui.conflictState = await window.forgeflow.continueGitOperation(
repository.localPath,
);
ui.modal = null;
await refreshRepositories(false);
showToast(
"Git operation continued",
"The repository operation completed.",
"success",
);
} else if (action === "abort-git-operation") {
if (
!confirm(
"Abort the active Git operation? Conflict-resolution work may be discarded.",
)
)
return;
await window.forgeflow.abortGitOperation(repository.localPath);
ui.modal = null;
await refreshRepositories(false);
} else if (action === "check-branch-protection") {
ui.branchProtection = await window.forgeflow.branchProtection(
repository.fullName,
repository.localStatus.branch.head,
);
showToast(
ui.branchProtection.protected
? "Protected branch"
: "Branch is not protected",
ui.branchProtection.protected
? `${ui.branchProtection.requiredApprovals} approval(s) required.`
: "Direct pushes are permitted by the reported branch rule.",
ui.branchProtection.protected ? "info" : "success",
);
render();
} else if (action === "open-pull-request") {
const subject =
ui.history[0]?.subject || repository.localStatus.branch.head;
ui.modal = {
type: "pull-request",
title: subject,
body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`,
};
render();
} else if (action === "load-pull-requests") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
"open",
);
render();
} catch (error) {
showToast("Could not load pull requests", error.message, "error");
}
} else if (action === "open-pull-request-url") {
if (target.dataset.url)
await window.forgeflow.openExternal(target.dataset.url);
} else if (action === "create-pull-request") {
setLoading(true, "Creating pull request…");
try {
const pull = await window.forgeflow.createPullRequest(
repository.fullName,
document.querySelector("#pr-title").value,
document.querySelector("#pr-body").value,
document.querySelector("#pr-base").value,
);
ui.modal = null;
ui.pullRequests = await window.forgeflow
.pullRequests(repository.fullName, "open")
.catch(() => ui.pullRequests);
showToast("Pull request created", `#${pull.number}`, "success");
if (pull.html_url) await window.forgeflow.openExternal(pull.html_url);
} catch (error) {
showToast("Could not create pull request", error.message, "error");
}
setLoading(false);
} 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 && !repository.localStatus?.counts?.staged)
)
return;
const selected = [...ui.selectedFiles];
const result = await runOperation(
action === "commit-push"
? "Committing and pushing…"
: "Creating local commit…",
() =>
action === "commit-push"
? selected.length
? window.forgeflow.commitAndPush(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStagedAndPush(
repository.localPath,
ui.commitMessage,
)
: selected.length
? window.forgeflow.commit(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStaged(
repository.localPath,
ui.commitMessage,
),
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.deploymentDiscovery = null;
ui.modal = {
type: "deployment-config",
profileId: null,
provider: (ui.boot.state.servers || []).length
? "ssh-unraid"
: "gitea-actions",
};
render();
} else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null;
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 === "discover-existing-deployment") {
const serverId = document.querySelector("#profile-server")?.value;
const remoteFolder =
document.querySelector("#profile-remote-folder")?.value.trim() ||
safeCloneFolderName(repository);
if (!serverId) {
showToast(
"Select an Unraid server",
"Configure and select the server before importing an existing deployment.",
"error",
);
return;
}
setLoading(
true,
"Reading Git, Compose, Docker and DockerMan from the server…",
);
try {
const result = await window.forgeflow.discoverExistingDeployment(
repository,
serverId,
remoteFolder,
);
ui.deploymentDiscovery = { ...result, repository: repository.fullName };
showToast(
"Existing deployment imported",
`${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`,
"success",
);
render();
} catch (error) {
showToast("Could not import deployment", error.message, "error");
}
setLoading(false);
} else if (action === "save-deployment-profile") {
const previousProfile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || {};
const provider = document.querySelector("#profile-provider").value;
let maintenanceWindows = [];
try {
maintenanceWindows = (
document.querySelector("#profile-policy-windows")?.value || ""
)
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const match = item.match(
/^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/,
);
if (!match) throw new Error(`Invalid maintenance window: ${item}`);
return {
days: match[1].split(",").map(Number),
start: match[2],
end: match[3],
};
});
} catch (error) {
showToast("Could not save profile", error.message, "error");
return;
}
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,
deploymentPolicy: {
frozen:
document.querySelector("#profile-policy-frozen")?.checked === true,
freezeReason:
document
.querySelector("#profile-policy-freeze-reason")
?.value.trim() || "",
requireNote:
document.querySelector("#profile-policy-note")?.checked === true,
maintenanceWindows,
},
...(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),
adoptedFromServer: Boolean(ui.deploymentDiscovery),
serverSourceOfTruth: Boolean(ui.deploymentDiscovery),
detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt ||
null,
provenance:
ui.deploymentDiscovery?.provenance ||
previousProfile.provenance ||
{},
detectedMetadata:
ui.deploymentDiscovery?.profile?.detectedMetadata ||
previousProfile.detectedMetadata ||
{},
}
: {
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;
ui.deploymentDiscovery = 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 integrity 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 === "save-desktop-preferences") {
const splitArgs = (selector) =>
document
.querySelector(selector)
.value.split("|")
.map((item) => item.trim())
.filter(Boolean);
const preferences = {
editor: {
executable: document
.querySelector("#pref-editor-executable")
.value.trim(),
args: splitArgs("#pref-editor-args"),
},
terminal: {
executable: document
.querySelector("#pref-terminal-executable")
.value.trim(),
args: splitArgs("#pref-terminal-args"),
},
notificationsEnabled: document.querySelector("#pref-notifications")
.checked,
trayEnabled: document.querySelector("#pref-tray").checked,
closeToTray: document.querySelector("#pref-close-tray").checked,
startAtLogin: document.querySelector("#pref-login").checked,
};
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
showToast(
"Desktop integration saved",
"Editor, terminal, tray and notification settings are active.",
"success",
);
} catch (error) {
showToast("Could not save desktop integration", error.message, "error");
}
render();
} else if (
action === "export-config-backup" ||
action === "import-config-backup"
) {
const passphrase = document.querySelector("#backup-passphrase").value;
if (passphrase.length < 12) {
showToast("Passphrase too short", "Use at least 12 characters.", "error");
return;
}
setLoading(
true,
action === "export-config-backup"
? "Encrypting configuration backup…"
: "Decrypting and validating configuration…",
);
try {
const result =
action === "export-config-backup"
? await window.forgeflow.exportConfigurationBackup(passphrase)
: await window.forgeflow.importConfigurationBackup(passphrase);
if (result?.state) {
ui.boot.state = result.state;
await refreshRepositories(false);
}
if (result)
showToast(
action === "export-config-backup"
? "Encrypted backup created"
: "Configuration restored",
action === "export-config-backup"
? result.filePath
: `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`,
"success",
);
} catch (error) {
showToast("Configuration backup failed", 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-troubleshooter") {
setLoading(
true,
"Scanning repositories, Git operations and deployment servers…",
);
try {
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
showToast(
"Troubleshooter completed",
ui.troubleshooter.summary.total
? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.`
: "No problems were detected.",
ui.troubleshooter.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Troubleshooter failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-auto-repair") {
const safeIssues = (ui.troubleshooter?.issues || []).filter(
(item) => item.repairable && item.safe,
);
if (
!safeIssues.length ||
!confirm(
`Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`,
)
)
return;
setLoading(true, "Applying safe one-click repairs…");
try {
const results =
await window.forgeflow.troubleshooterAutoRepair(safeIssues);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
const failed = results.filter((item) => !item.ok);
showToast(
failed.length
? "Repairs partially completed"
: "Safe repairs completed",
`${results.length - failed.length} repaired, ${failed.length} failed.`,
failed.length ? "error" : "success",
);
} catch (error) {
showToast("Automatic repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-repair") {
const issue =
ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)];
if (!issue) return;
const warning = issue.safe
? `Repair “${issue.title}” now?`
: `“${issue.title}” requires a safety branch or another potentially destructive change. Continue?`;
if (!confirm(warning)) return;
setLoading(true, `Repairing ${issue.title}…`);
try {
const result = await window.forgeflow.troubleshooterRepair(issue);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
showToast(
"Problem repaired",
result?.backupBranch
? `Safety branch created: ${result.backupBranch}`
: issue.title,
"success",
);
} catch (error) {
showToast("Repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "run-system-preflight") await runSystemPreflight();
else if (action === "load-audit-log") {
try {
ui.auditEvents = await window.forgeflow.listAuditEvents(500);
render();
} catch (error) {
showToast("Could not load audit log", error.message, "error");
}
} else if (action === "export-audit-json" || action === "export-audit-csv") {
try {
const result = await window.forgeflow.exportAuditLog(
action.endsWith("csv") ? "csv" : "json",
);
if (result)
showToast(
"Audit log exported",
`${result.count} records exported.`,
"success",
);
} catch (error) {
showToast("Could not export audit log", error.message, "error");
}
} 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();
}
});
document.addEventListener("pointermove", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration) {
const bounds = illustration.getBoundingClientRect();
illustration.style.setProperty(
"--tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
);
illustration.style.setProperty(
"--tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
);
}
const diffPanel = event.target.closest?.(".diff-panel");
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
if (atmosphere) {
const bounds = diffPanel.getBoundingClientRect();
atmosphere.style.setProperty(
"--diff-tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`,
);
atmosphere.style.setProperty(
"--diff-tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`,
);
}
});
document.addEventListener("pointerout", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration && !illustration.contains(event.relatedTarget)) {
illustration.style.removeProperty("--tilt-x");
illustration.style.removeProperty("--tilt-y");
}
const diffPanel = event.target.closest?.(".diff-panel");
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
atmosphere?.style.removeProperty("--diff-tilt-x");
atmosphere?.style.removeProperty("--diff-tilt-y");
}
});
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();