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