Files
ForgeFlow/src/renderer/views.js
T
NuklearRabbit f7d6bc374f
ForgeFlow quality gate / quality (push) Canceled after 0s
fix: reconcile server deployments across repository views
2026-08-01 18:57:55 +02:00

747 lines
99 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.
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" aria-label="Open command 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 profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
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 (profiles.length)
badges.push(
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</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)}" data-deployment-count="${profiles.length}">
<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 linkedWorkloadsForRepository(repository) {
const fullName = String(repository?.fullName || "").toLowerCase();
if (!fullName) return [];
return (ui.serverDiscovery || []).flatMap((server) =>
(server.workloads || [])
.filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName)
.map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })),
);
}
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 (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
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 isSsh = profile.provider === "ssh-unraid";
const mode = deploymentMode(profile);
const verification = ui.serverGitVerifications[profile.id];
const targetSha = deploymentTargetSha(repository, profile);
const ready = canDeploy(repository, profile);
const modeLabel = {
"push-bundle": "Direct copy",
"server-git": "Server pull from Gitea",
"monitor-only": "Monitor only",
}[mode] || mode;
const providerDetail = isSsh
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const managesDockerMan = isSsh && profile.manageDockerMan === true;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
const identity = deploymentIdentity(profile, repository);
const syncLabel = isSsh
? state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.liveSha && state.giteaSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: state.liveSha ? `<span class="sync-proof success">${icon("check")}Live · ${shortSha(state.liveSha)}</span>` : ""
: 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>`
: "";
const dockerManLabel = managesDockerMan
? dockerManReady
? templateReady
? "Managed labels/template active"
: "Managed labels active"
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
: "Existing DockerMan template preserved";
const sourceLabel = isSsh
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
const serverAccessAction = isSsh && mode === "server-git"
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
: "";
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>Deploy source</span><strong>${escapeHtml(sourceLabel)}</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>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</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>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<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>` : ""}${managesDockerMan ? `<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 integration" : "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(shortSha(targetSha))}</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 workloads = linkedWorkloadsForRepository(repository);
const profileIds = new Set(profiles.map((profile) => profile.id));
const workloadRows = workloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean);
const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId));
return `<div class="tool-row repository-workload-row"><div><strong>${escapeHtml(workload.displayName || containers[0] || "Server workload")}</strong><span>${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}</span></div><div class="stack horizontal compact"><span class="status-pill ${profileResolved ? "success" : "danger"}">${profileResolved ? "Repository linked" : "Link needs reconciliation"}</span>${profileResolved ? `<button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(workload.link.profileId)}">Open profile</button>` : `<button class="button" data-action="navigate" data-view="deployments">Review inventory</button>`}</div></div>`;
}).join("");
const repoOps = repositoryOperations(repository).slice(0, 10);
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${workloads.length ? `<section class="panel repository-workloads"><div class="panel-header"><div><h3>Detected on server</h3><span class="meta">Live Docker / Compose identities resolved back to this repository</span></div></div><div class="panel-body"><div class="tool-list">${workloadRows}</div></div></section>` : ""}${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 renderGitValidator(repository) {
const report = ui.gitValidation;
if (!report)
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
const tone =
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
const groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
const trend = report.trend || {};
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p><p class="meta">${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><label class="sr-only" for="validator-policy">Assurance policy</label><select id="validator-policy" class="select" data-action="git-validator-policy">${["minimal", "standard", "strict", "production"].map((policy) => `<option value="${policy}" ${report.policy?.id === policy ? "selected" : ""}>${policy[0].toUpperCase() + policy.slice(1)}</option>`).join("")}</select><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button><button class="button" data-action="git-validator-export" data-format="markdown">Export report</button></div></section><div class="validator-groups">${Object.entries(
groups,
)
.map(
([category, checks]) =>
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
.map((check) => {
const checkIndex = report.checks.indexOf(check);
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p>${check.suppressed ? `<small>Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}</small>` : check.expiredSuppression ? `<small>Exception expired; finding is active again.</small>` : ""}</div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}Preview fix</button>` : check.status !== "pass" && !check.suppressed ? `<button class="button" data-action="git-validator-suppress" data-check-index="${checkIndex}">Document exception</button>` : `<span class="status-pill ${check.suppressed ? "warning" : check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository);
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
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"
: profileWorkload?.runtime?.running
? "success"
: "";
const content = (
{
changes: renderChanges,
history: renderHistory,
deployments: renderRepositoryDeployments,
gittools: renderGitTools,
validator: renderGitValidator,
settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges
)(repository);
const deploymentLinks = profiles.length
? `<div class="repository-deployment-summary"><span class="repository-deployment-summary-label">${icon("server")}Linked deployments</span><div class="repository-deployment-chips">${profiles.map((item) => {
const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id);
const itemState = item.state || {};
const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning";
const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment;
return `<button class="repository-deployment-chip" data-action="select-deployment-profile" data-profile-id="${attr(item.id)}"><span class="state-dot ${tone}"></span><strong>${escapeHtml(identity)}</strong><span>${escapeHtml(item.environment)}${workload?.serverName ? ` · ${escapeHtml(workload.serverName)}` : ""}</span></button>`;
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
: "";
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) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
${deploymentLinks}
<nav class="tabs">${[
["changes", "Changes"],
["history", "History"],
["deployments", "Deployments"],
["gittools", "Git tools"],
["validator", "Git Validator"],
["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 renderServerInventory() {
const servers = ui.serverDiscovery || [];
const configuredServers = ui.boot?.state?.servers || [];
const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]);
const visibleForServer = (server) => (server.workloads || []).filter((workload) =>
workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))),
);
const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).length, 0);
const serverCards = servers.map((server) => {
const capabilities = server.capabilities || {};
const capabilityText = [
capabilities.docker ? "Docker" : "Docker missing",
capabilities.compose ? "Compose" : "Compose missing",
capabilities.git ? "Git available" : "Git optional",
capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete",
].join(" · ");
const errorBlock = server.error
? `<div class="notice danger">${icon("error")}<div><strong>Server scan failed</strong><p>${escapeHtml(server.error)}</p><div class="stack horizontal compact" style="margin-top:8px"><button class="button primary" data-action="use-server-password" data-server-id="${attr(server.serverId)}" data-retry="scan">Use server password instead</button><button class="button" data-action="test-server" data-server-id="${attr(server.serverId)}">Test connection</button></div></div></div>`
: "";
const warnings = (server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("");
const visibleWorkloads = visibleForServer(server);
const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length);
const workloads = visibleWorkloads.length
? visibleWorkloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
const topCandidate = workload.candidates?.[0];
const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId);
const claimsLink = workload.status === "linked" || Boolean(workload.link);
const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link";
const inconsistentLink = claimsLink && !linked;
const classification = workload.classification?.type || workload.status || "review";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: workload.dockerMan?.templatePath
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: inconsistentLink
? `Stored link cannot be resolved to a loaded repository profile`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}`
: "No repository candidate; select one manually";
const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate);
const linkButton = canQuickLink
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale || inconsistentLink ? "text-warning" : "meta"}">${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}</span>${linked ? `<button class="button ghost" data-action="open-deployment-link" data-repository-id="${attr(linkedRepository.id)}" data-profile-id="${attr(linkedProfile.id)}">Open in repository</button>` : inconsistentLink ? `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">Reconcile</button>` : linkButton}</div></div>`;
}).join("")
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
const resolvedLinks = visibleWorkloads.filter((workload) => {
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
const unresolvedLinks = visibleWorkloads.filter((workload) => {
if (!(workload.status === "linked" || workload.link)) return false;
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
}).length;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).join("");
const empty = configuredServers.length
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
: `<div class="empty-state panel"><h3>No Unraid server configured</h3><p>Add the server with password authentication and ForgeFlow can copy and deploy projects directly.</p><button class="button primary" data-action="open-add-server">Add server</button></div>`;
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Live Docker, Compose and DockerMan discovery, linked to Gitea</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${serverCards}</div>` : empty}<div class="notice" style="margin-top:12px">${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.</div></section>`;
}
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.manageDockerMan === true &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
);
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<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} managed 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>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Stable Compose identity, live container health and exact Gitea commit parity</span></div></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>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><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)}" aria-label="Project root ${index + 1}"/><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><div class="field"><label for="appearance-select">Color theme</label><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></div></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>` : ""}${item.repairAction ? `<button class="button primary compact-button" data-action="${attr(item.repairAction)}" data-profile-id="${attr(ui.selectedProfileId || "")}">${icon("wrench")}${escapeHtml(item.repairLabel || "Repair")}</button>` : ""}</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>`;
}