Release ForgeFlow 0.5.2

This commit is contained in:
NuklearRabbit
2026-07-25 01:07:54 +02:00
parent 602309b203
commit cf1f67a823
23 changed files with 550 additions and 72 deletions
+38 -7
View File
@@ -453,7 +453,10 @@ function renderGitTools(repository) {
function renderRepositorySettings(repository) {
const automaticTarget = displayCloneTarget(repository);
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><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>${repository.localPath ? `<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"><h2>Repository behavior</h2><div class="notice">${icon('shield')}ForgeFlow automatically creates a repository-named subfolder and never overwrites a non-empty conflicting folder. Existing matching clones are linked instead.</div></section></div>`;
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
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="repair-index-lock">${icon('key')}Repair stale Git lock</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"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
}
function renderRepositoryWorkspace(repository) {
@@ -508,6 +511,7 @@ function renderSettings() {
<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>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>
@@ -584,7 +588,7 @@ function renderModal() {
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"><section class="modal wide-modal"><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><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 ? `
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><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>
@@ -607,21 +611,21 @@ function renderModal() {
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"><section class="modal wide-modal"><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>`;
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"><section class="modal"><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>`;
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"><section class="modal"><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>`;
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"><section class="modal wide-modal"><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>`;
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 === 'command-palette') return renderCommandPalette();
return '';
@@ -950,9 +954,11 @@ app.addEventListener('click', async (event) => {
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, document.querySelector('#server-password')?.value || '', document.querySelector('#server-passphrase')?.value || '');
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);
@@ -973,6 +979,31 @@ app.addEventListener('click', async (event) => {
else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); }
else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); }
else if (action === 'repair-origin') {
if (!repository?.localPath || !repository.sshUrl) return;
if (!confirm(`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`)) return;
setLoading(true, 'Updating Git origin…');
try { await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); await refreshRepositories(false); showToast('Git origin updated', repository.sshUrl, 'success'); }
catch (error) { showToast('Could not update origin', error.message, 'error'); }
setLoading(false);
}
else if (action === 'normalize-origins') {
if (!confirm('Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.')) return;
setLoading(true, 'Normalizing linked Git origins…');
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast('Git origins normalized', `${result.changes.length} repository origin${result.changes.length === 1 ? '' : 's'} updated.`, 'success');
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
setLoading(true, 'Repairing stale Git lock…');
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
setLoading(false);
}
else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') {
const preferences = {
+7 -3
View File
@@ -250,7 +250,7 @@
}
window.forgeflow = Object.freeze({
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.4.0-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.5.1-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async selectDirectory() { await wait(); return 'C:\\Development'; },
async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; },
async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [
@@ -269,9 +269,9 @@
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.4.0', remoteVersion: '0.4.1', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.1', remoteVersion: '0.5.2', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.4.1' }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.5.1' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
@@ -297,6 +297,10 @@
async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; },
async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); },
async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; },
async indexLockInfo() { return { exists: false, ageMs: 0 }; },
async repairIndexLock() { return { removed: true }; },
async setOrigin(localPath, remoteUrl) { const repo = findRepo(localPath); repo.localStatus.remoteUrl = remoteUrl; repo.sshUrl = remoteUrl; emitRepositories(); return clone(repo.localStatus); },
async normalizeOrigins() { const changes = []; repositories.filter((repo) => repo.localPath && repo.sshUrl).forEach((repo) => { if (repo.localStatus.remoteUrl !== repo.sshUrl) { changes.push({ fullName: repo.fullName, previous: repo.localStatus.remoteUrl, next: repo.sshUrl }); repo.localStatus.remoteUrl = repo.sshUrl; } }); emitRepositories(); return { changes, repositories: snapshot() }; },
async cloneRepository(fullName, mode = 'default') {
await wait(620);
const repository = repositories.find((item) => item.fullName === fullName);
+25 -5
View File
@@ -326,12 +326,12 @@ html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
.discovery-row strong { display: block; font-size: 12px; }
.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; align-items: center; justify-items: center; padding: clamp(8px,2vh,20px); overflow: auto; overscroll-behavior: contain; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); max-height: calc(100dvh - clamp(16px,4vh,40px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
.modal-header h2 { margin: 0; font-size: 15px; }
.modal-body { padding: 17px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); }
.modal-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 17px; }
.modal-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); box-shadow: 0 -8px 18px rgba(0,0,0,.08); }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; }
@@ -533,3 +533,23 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
}
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
/* v0.5 viewport-safe dialogs */
.modal-body > .preflight-list:last-child { margin-bottom: 2px; }
.modal-footer .button { flex: 0 0 auto; }
@media (max-height: 720px) {
.modal-backdrop { align-items: start; }
.modal { max-height: calc(100dvh - 16px); }
.modal-header { padding-block: 11px; }
.modal-body { padding-block: 13px; }
.modal-footer { padding-block: 10px; }
}
@media (max-width: 680px) {
.modal-backdrop { padding: 0; align-items: stretch; }
.modal, .wide-modal { width: 100vw; max-height: 100dvh; border-radius: 0; }
.modal-footer .modal-spacer { display: none; }
.modal-footer .button { flex: 1 1 auto; }
.form-grid { grid-template-columns: 1fr; }
.field.full, .check-field.full { grid-column: 1; }
}