Files
ForgeFlow/src/renderer/app.js
T
NuklearRabbit d47c7b5e41
ForgeFlow quality gate / secret-scan (push) Failing after 32s
ForgeFlow quality gate / quality (push) Failing after 0s
feat: add safe Gitea sync and signed updates
2026-08-27 00:38:58 +02:00

734 lines
26 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[
character
],
);
}
function attr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
function formatDate(value) {
if (!value) return "Unknown";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const diff = Date.now() - date.getTime();
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`;
return date.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year:
date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
});
}
function truncate(value, length = 76) {
const text = String(value || "");
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
}
function shortSha(value) {
return String(value || "").slice(0, 7) || "—";
}
function defaultWorkspaceRoot() {
return ui.boot?.state?.workspaceRoots?.[0] || null;
}
function safeCloneFolderName(repository) {
return (
String(repository?.name || "repository")
.replace(/\.git$/i, "")
.replace(/[^a-zA-Z0-9._-]/g, "-") || "repository"
);
}
function displayCloneTarget(repository) {
const root = defaultWorkspaceRoot();
if (!root) return null;
const separator = ui.boot?.platform === "win32" ? "\\" : "/";
return `${String(root).replace(/[\\/]+$/, "")}${separator}${safeCloneFolderName(repository)}`;
}
function clonePrimaryLabel(repository) {
return defaultWorkspaceRoot()
? `Clone to ${safeCloneFolderName(repository)}`
: "Choose project root & clone";
}
function isTerminalOperation(status) {
return ["success", "failed", "cancelled", "rolled-back"].includes(status);
}
function toneForStatus(status) {
if (["success", "healthy", "complete", "rolled-back"].includes(status))
return "success";
if (["failed", "failure", "unhealthy", "danger"].includes(status))
return "danger";
if (["queued", "running", "requested", "warning", "active"].includes(status))
return "warning";
return "";
}
const ui = {
boot: null,
repositories: [],
currentView: "overview",
selectedRepoId: null,
repositoryTab: "changes",
selectedFile: null,
selectedFiles: new Set(),
selectedProfileId: null,
diff: "",
history: [],
branches: [],
stashes: [],
search: "",
repoSearch: "",
commitMessage: "",
loading: false,
loadingMessage: "",
modal: null,
setupStep: 0,
systemPreflight: null,
deploymentPreflight: null,
serverGitVerifications: {},
deployKeyLifecycle: null,
inventoryReviewPlan: null,
diagnosticsStatus: null,
troubleshooter: null,
deploymentDiscovery: null,
serverDiscovery: [],
lastDiagnosticBundle: null,
setupDraft: {
baseUrl: "https://",
token: "",
user: null,
roots: [],
discovered: [],
},
setupValidation: null,
activeDeployment: null,
operationPollTimer: null,
inputRenderTimer: null,
isMock: false,
refreshError: null,
refreshWarning: null,
autoRefreshPending: false,
repositoryRefreshPromise: null,
repositoryRefreshRequest: null,
deploymentTruthPromise: null,
deploymentTruthRequest: null,
paletteQuery: "",
updateStatus: null,
updateChecking: false,
servers: [],
serverInspection: null,
gitRecovery: null,
workspaceSyncPlan: null,
gitValidation: null,
diffHunks: null,
conflictState: null,
branchProtection: null,
pullRequests: [],
auditEvents: [],
};
function scheduleInputRender(delay = 120) {
if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer);
ui.inputRenderTimer = setTimeout(() => {
ui.inputRenderTimer = null;
render();
}, delay);
}
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 canDirectPushDeploy(repository, profile = selectedProfile(repository)) {
const status = repository?.localStatus;
const mode = deploymentMode(profile);
return Boolean(
repository?.localPath
&& status?.head
&& !status?.counts?.changed
&& !status?.counts?.conflicts
&& profile
&& mode === "push-bundle"
&& profile.branch === status.branch?.head,
);
}
function deploymentMode(profile) {
if (profile?.provider !== "ssh-unraid") return "gitea-actions";
return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
}
function deploymentTargetSha(repository, profile = selectedProfile(repository)) {
return deploymentMode(profile) === "server-git"
? profile?.state?.giteaSha || null
: repository?.localStatus?.head || null;
}
function canServerGitDeploy(repository, profile = selectedProfile(repository)) {
const target = deploymentTargetSha(repository, profile);
return Boolean(
profile
&& deploymentMode(profile) === "server-git"
&& target
&& profile.branch
&& !(profile.state?.liveSha === target && profile.state?.healthy !== false),
);
}
function canDeploy(repository, profile = selectedProfile(repository)) {
const mode = deploymentMode(profile);
if (mode === "server-git") return canServerGitDeploy(repository, profile);
if (mode === "push-bundle") return canDirectPushDeploy(repository, profile);
return profile?.provider === "gitea-actions" && repository?.readyToDeploy;
}
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 isSshCredentialError(error) {
const code = String(error?.code || "");
const message = String(error?.message || "");
return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code)
|| /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message);
}
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(delay = 450) {
if (
ui.loading ||
ui.autoRefreshPending ||
!ui.boot?.state?.preferences?.autoRefresh
)
return;
ui.autoRefreshPending = true;
setTimeout(async () => {
ui.autoRefreshPending = false;
await refreshRepositories(false, true);
}, delay);
}
async function refreshRepositories(withLoader = true, silent = false) {
ui.repositoryRefreshRequest = {
withLoader: ui.repositoryRefreshRequest?.withLoader === true || withLoader,
silent: ui.repositoryRefreshRequest ? ui.repositoryRefreshRequest.silent && silent : silent,
};
if (ui.repositoryRefreshPromise) return ui.repositoryRefreshPromise;
ui.repositoryRefreshPromise = (async () => {
let result;
while (ui.repositoryRefreshRequest) {
const request = ui.repositoryRefreshRequest;
ui.repositoryRefreshRequest = null;
result = await performRepositoryRefresh(request.withLoader, request.silent);
}
return result;
})();
try {
return await ui.repositoryRefreshPromise;
} finally {
ui.repositoryRefreshPromise = null;
}
}
async function performRepositoryRefresh(withLoader = true, silent = false) {
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
try {
const selectedId = ui.selectedRepoId;
ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader });
ui.refreshError = null;
const staleRepository = ui.repositories.find(
(repository) => repository.remoteStale,
);
ui.refreshWarning = staleRepository
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
: 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;
ui.refreshWarning = null;
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) {
ui.deploymentTruthRequest = { showErrors: ui.deploymentTruthRequest?.showErrors === true || showErrors };
if (ui.deploymentTruthPromise) return ui.deploymentTruthPromise;
ui.deploymentTruthPromise = (async () => {
let result;
while (ui.deploymentTruthRequest) {
const request = ui.deploymentTruthRequest;
ui.deploymentTruthRequest = null;
result = await performDeploymentTruthRefresh(request.showErrors);
}
return result;
})();
try {
return await ui.deploymentTruthPromise;
} finally {
ui.deploymentTruthPromise = null;
}
}
async function performDeploymentTruthRefresh(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} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
"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 inventoryRefreshedProfiles = new Set(discovery.flatMap((server) => server.refreshedProfileIds || []));
const pendingTargets = targets.filter(({ profile }) => !inventoryRefreshedProfiles.has(profile.id));
const failures = [];
const queue = [...pendingTargets];
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, reusedInventory: targets.length - pendingTargets.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.workspaceSyncPlan = null;
ui.gitValidation = 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 deployment.`,
};
if (status.counts.changed)
return {
kind: "commit",
title: "Commit local changes",
detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`,
};
if (!repository.deploymentProfiles?.length)
return {
kind: "configure",
title: "Configure deployment",
detail: "Connect an Unraid server or a 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 (canDirectPushDeploy(repository, profile))
return {
kind: "deploy",
title: "Ready for direct redeploy",
detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`,
};
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.`,
};
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.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.",
};
}