diff --git a/locales/en.json b/locales/en.json index fedea89b..99092a1a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -53,7 +53,7 @@ "menuMigrate": "Migrate to Standalone…", "menuRestoreSnapshot": "Restore Snapshot…", "menuRevealInFolder": "Open Folder", - "menuDelete": "Delete…" + "menuDelete": "Uninstall…" }, "titleBar": { @@ -69,15 +69,18 @@ "comfyUISettings": { "title": "Settings", - "tabConfig": "Config", - "tabStatus": "Status", + "tabConfig": "Startup Args", + "tabStatus": "About", "tabUpdate": "Update", "tabSnapshots": "Snapshots", + "tabStorage": "Storage", "relaunch": "Relaunch", "more": "More", "diskUsage": "Disk Usage", "envVarsCount": "{n} defined", - "emptyInstallLess": "Open a ComfyUI install to view its settings." + "emptyInstallLess": "Open a ComfyUI install to view its settings.", + "storageGlobalNote": "Changes here apply to all of your ComfyUI instances.", + "storageRestartNote": "Restart the application (or close and reopen) for these changes to take effect." }, "comfyLifecycle": { @@ -390,7 +393,13 @@ "about": "About", "version": "Version", "platform": "Platform", - "language": "Language" + "language": "Language", + "preferences": "Preferences", + "privacy": "Privacy", + "community": "Community", + "storageTab": "Storage", + "sharedDirectories": "Shared Directories", + "updatesTab": "Updates" }, "zoom": { @@ -421,7 +430,12 @@ "panelAvailableTitle": "Update {version} available", "panelReadyTitle": "Update {version} ready to install", "panelDownloadingTitle": "Downloading update {version}…", - "downloading": "Downloading…" + "downloading": "Downloading…", + "readyBadge": "Ready to restart", + "installedLabel": "Installed {version}", + "lastCheckedLabel": "Last checked {time}", + "latestLabel": "Latest {version}", + "systemManagedNote": "Updates for this install are delivered through your system package manager." }, "modal": { @@ -454,12 +468,19 @@ "telemetryHint": "Telemetry helps us improve the launcher. We never collect workflow contents, prompts, generated media, or personal information.", "acceptTos": "Accept and continue", "consentTosTitle": "Accept the terms", - "consentTosHintPrefix": "By continuing you agree to the", + "consentTosHintPrefix": "I agree to the", "consentTosHintSep": "and", "consentTosHintSuffix": ".", "consentTelemetryTitle": "Help improve Comfy", - "consentTelemetryHint": "Send usage data to help us fix bugs and prioritize features.", + "consentTelemetryHint": "Help improve Comfy by sharing anonymous usage data.", "consentGetStarted": "Get Started", + "startContinue": "Continue", + "startContinueBusy": "Preparing your install…", + "expressInstallLabel": "Express Install", + "expressInstallHint": "Use recommended settings — skip optional setup steps.", + "expressInstallLine": "Express Install with recommended settings", + "expressGpuHintPrefix": "Detected: ", + "expressGpuHintSuffix": " - if that's not your hardware, uncheck Express", "termsModalTitle": "ComfyUI Desktop Terms", "eulaModalTitle": "End-User License Agreement", "tosModalTitle": "Terms of Service", @@ -523,11 +544,11 @@ "copyInstallationConfirm": "Copy", "copyingInstallation": "Copying Install", "copyingFiles": "Copying files…", - "delete": "Delete Install", - "deleteConfirmTitle": "Delete Install", - "deleteConfirmMessage": "This will permanently delete the install and all its files. This cannot be undone.", - "untrack": "Untrack", - "untrackConfirmTitle": "Untrack Install", + "delete": "Uninstall", + "deleteConfirmTitle": "Uninstall", + "deleteConfirmMessage": "This will permanently uninstall ComfyUI and delete all its files. This cannot be undone.", + "untrack": "Forget", + "untrackConfirmTitle": "Forget Install", "untrackConfirmMessage": "This will remove the install from the app. The files will not be deleted." }, @@ -802,11 +823,22 @@ "added": "Added", "removed": "Removed", "changed": "Changed", - "timeJustNow": "just now", + "timeJustNow": "Just now", "timeMinutesAgo": "{count}m ago", "timeHoursAgo": "{count}h ago", "timeDaysAgo": "{count}d ago", "deleteConfirm": "Delete this snapshot?", + "deleteConfirmNamed": "Delete snapshot \"{name}\"?", + "deleteConfirmMessage": "This snapshot file will be permanently removed. Your installation and other snapshots stay as-is. This can't be undone.", + "delete": "Delete", + "saveErrorTitle": "Couldn't save snapshot", + "deleteErrorTitle": "Couldn't delete snapshot", + "restoreConfirmTitle": "Restore this snapshot?", + "restoreConfirmMessage": "Your current install state will be replaced with the contents of this snapshot. Make sure ComfyUI isn't running before you continue.", + "importConfirmTitle": "Import these snapshots?", + "importConfirmMessage": "These snapshots will be added to this installation. The newest one will replace your current state.", + "importConfirmLabel": "Import", + "importErrorTitle": "Couldn't import snapshots", "copiedAs": "Copied as", "copyUpdatedAs": "Copy & Updated as", "releaseUpdatedAs": "Release Updated as", @@ -871,10 +903,10 @@ "waiting": "Waiting for ComfyUI to be ready…", "waitingTime": "Waiting for ComfyUI to be ready… ({secs}s)", "portBusyUsing": "Port {old} is busy — using port {new} instead…", - "instanceRunningTitle": "Instance Already Running", - "instanceRunningMessage": "\"{name}\" is currently running. Running multiple local installs simultaneously may use significant system resources.", - "instanceRunningProceed": "Launch Alongside", - "instanceRunningReplace": "Close Running & Launch", + "instanceRunningTitle": "An instance is already running", + "instanceRunningMessage": "\"{name}\" is running. What would you like to do?", + "instanceRunningProceed": "Launch Anyway", + "instanceRunningReplace": "Close & Launch New", "modelFolderRelaunchTitle": "Restarting ComfyUI", "modelFolderRelaunchDesc": "New model folders were detected from custom nodes. Restarting so ComfyUI can use them right away.", "steps": { @@ -933,6 +965,7 @@ "startupArgs": "Command-line arguments passed to ComfyUI when it starts, such as --port or --lowvram.", "browserPartition": "Controls whether this install shares browser cache (cookies, local storage) with other installs or keeps its own separate cache.", "sharedDirs": "Directories shared across all installations for inputs and outputs.", + "sharedModels": "Folders shared across all installations so models aren't downloaded twice. Newly downloaded models go to the primary folder. The system default folder is always kept and can't be removed, and the primary folder can't be removed while it's in use — pick a different primary first.", "modelsPrimary": "The primary directory is where ComfyUI saves newly downloaded models by default.", "modelsDefault": "The system default directory. This path is created automatically and cannot be removed.", "useSharedPaths": "When enabled, this install can access models, inputs, and outputs from the shared directories. Disable to use only this install's own local paths.", @@ -952,6 +985,7 @@ "name": "Name", "value": "Value", "add": "Add Variable", + "remove": "Remove variable", "namePlaceholder": "VARIABLE_NAME", "valuePlaceholder": "value", "securityWarning": "Environment variable values are stored unencrypted on disk. Do not add API keys, tokens, passwords, or other secrets." diff --git a/locales/zh.json b/locales/zh.json index 97f0d7a3..e86b7583 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -273,7 +273,13 @@ "about": "关于", "version": "版本", "platform": "平台", - "language": "语言" + "language": "语言", + "preferences": "偏好设置", + "privacy": "隐私", + "community": "社区", + "storageTab": "存储", + "sharedDirectories": "共享目录", + "updatesTab": "更新" }, "update": { @@ -291,7 +297,19 @@ "availableTitle": "桌面端更新可用", "availableMessage": "有可用的桌面端更新({version})。是否立即下载?下载完成后将提示您重启。", "download": "下载", - "errorTitle": "桌面端更新错误" + "later": "稍后", + "errorTitle": "桌面端更新错误", + "sectionTitle": "桌面端更新", + "panelIdleTitle": "ComfyUI Desktop 已是最新版本", + "panelAvailableTitle": "更新 {version} 可用", + "panelReadyTitle": "更新 {version} 可安装", + "panelDownloadingTitle": "正在下载更新 {version}…", + "downloading": "下载中…", + "readyBadge": "准备重启", + "installedLabel": "已安装 {version}", + "lastCheckedLabel": "最后检查于 {time}", + "latestLabel": "最新 {version}", + "systemManagedNote": "此安装的更新通过您的系统包管理器提供。" }, "modal": { @@ -324,12 +342,19 @@ "telemetryHint": "遥测数据帮助我们改进启动器。我们绝不收集工作流内容、提示词、生成的媒体或个人信息。", "acceptTos": "接受并继续", "consentTosTitle": "接受条款", - "consentTosHintPrefix": "继续即表示您同意", + "consentTosHintPrefix": "我同意", "consentTosHintSep": "和", "consentTosHintSuffix": "。", "consentTelemetryTitle": "帮助改进 Comfy", - "consentTelemetryHint": "发送使用数据,帮助我们修复缺陷并确定功能优先级。", + "consentTelemetryHint": "分享匿名使用数据,帮助改进 Comfy。", "consentGetStarted": "开始使用", + "startContinue": "继续", + "startContinueBusy": "正在准备安装…", + "expressInstallLabel": "快捷安装", + "expressInstallHint": "使用推荐设置——跳过可选的安装步骤。", + "expressInstallLine": "快捷安装——使用推荐设置", + "expressGpuHintPrefix": "已检测到:", + "expressGpuHintSuffix": "- 如果不是你的硬件,请取消勾选快捷安装", "termsModalTitle": "ComfyUI Desktop 条款", "eulaModalTitle": "最终用户许可协议", "tosModalTitle": "服务条款", diff --git a/src/main/host/detach.ts b/src/main/host/detach.ts index f7fef88c..7dd79543 100644 --- a/src/main/host/detach.ts +++ b/src/main/host/detach.ts @@ -186,7 +186,7 @@ export async function confirmAndCloseAllHostWindows( parentWindow: BrowserWindow | null, ): Promise { const entries = Array.from(comfyWindows.values()).filter((e) => !e.window.isDestroyed()) - if (entries.length <= 1) { + if (entries.length < 1) { closeAllHostWindows() return } @@ -219,13 +219,16 @@ export async function confirmAndCloseAllHostWindows( closeAllHostWindows() return } + const isSingle = entries.length === 1 const confirmed = await openSystemModalAsync({ parent: overlayParentEntry.window, spec: { - title: 'Close All Windows', - message: `Close ${entries.length} open windows?`, + title: 'Exit All Windows', + message: isSingle + ? 'Exit the open window?' + : `Exit ${entries.length} open windows?`, details, - confirmLabel: 'Close All', + confirmLabel: isSingle ? 'Exit' : 'Exit All', cancelLabel: 'Cancel', confirmStyle: 'danger', theme: overlayParentEntry.lastTheme, @@ -242,6 +245,54 @@ export async function confirmAndCloseAllHostWindows( } } +/** + * Confirm + close a single host window. Mirrors + * `confirmAndCloseAllHostWindows` for the install-host menu's + * `Exit Window` entry — same `openSystemModalAsync` primitive, same + * pre-cleared close path so the per-window close handler doesn't + * double-prompt after the user already confirmed. + */ +export async function confirmAndCloseHostWindow(parentWindow: BrowserWindow): Promise { + if (parentWindow.isDestroyed()) return + const entry = Array.from(comfyWindows.values()).find((e) => e.window === parentWindow) + if (!entry) { + parentWindow.close() + return + } + const details: SystemModalDetailGroup[] = [] + if (ipc.hasActiveOperations()) { + try { + const items = await ipc.getActiveDetails() + const sessions = items.filter((i) => i.type === 'session').map((i) => i.name) + const operations = items.filter((i) => i.type === 'operation').map((i) => i.name) + const downloads = items.filter((i) => i.type === 'download').map((i) => i.name) + if (sessions.length > 0) details.push({ label: 'Running ComfyUI', items: sessions }) + if (operations.length > 0) details.push({ label: 'In-progress operations', items: operations }) + if (downloads.length > 0) details.push({ label: 'Active downloads', items: downloads }) + } catch { + // Active-detail collection failure shouldn't block the prompt. + } + } + const confirmed = await openSystemModalAsync({ + parent: entry.window, + spec: { + title: 'Exit Window', + message: 'Exit this window?', + details: details.length > 0 ? details : undefined, + confirmLabel: 'Exit', + cancelLabel: 'Cancel', + confirmStyle: 'danger', + theme: entry.lastTheme, + }, + }) + if (confirmed) { + // Skip the panel-renderer consult on the close handler — the user + // already confirmed via this prompt. + preClearedClose.add(entry.window) + entry.window.close() + } +} + /** * Flip an install-backed host window in place to install-less * (chooser) mode. The symmetric undo to `attachInstall()`. Bound diff --git a/src/main/index.ts b/src/main/index.ts index 9c5a9f2d..78f4920b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -75,6 +75,7 @@ import { applyAttachHostPreview, clearAttachHostPreview } from './host/attachHos import { _detachInstallImpl, confirmAndCloseAllHostWindows, + confirmAndCloseHostWindow, consultPanelRendererClose, detachOrphanedInstallHosts, preClearedClose, @@ -1103,6 +1104,7 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) { openChooserHostWindow, returnToDashboard, confirmAndCloseAllHostWindows, + confirmAndCloseHostWindow, setActivePanel, triggerOpenFeedback, sendToPanelDeferred, @@ -1164,6 +1166,23 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) { } try { await updateInstallation(installationId, { [fieldId]: value }) + // Channel switch must kick a check-update; otherwise the new + // channel's available-update tag never appears until the user + // manually clicks "Check for updates". + if (fieldId === 'updateChannel') { + const next = await getInstallation(installationId) + if (next) { + const abort = new AbortController() + source.handleAction('check-update', next, undefined, { + update: (data) => updateInstallation(installationId, data).then(() => { }), + sendProgress: () => { }, + sendOutput: () => { }, + signal: abort.signal, + }).catch((err) => { + console.error(`Picker: check-update after channel switch failed for ${installationId}:`, err) + }) + } + } return { ok: true } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : 'Update failed.' } @@ -1197,45 +1216,6 @@ if (app.isPackaged && !app.requestSingleInstanceLock()) { return { ok: false, message: err instanceof Error ? err.message : 'Action failed.' } } }, - getChannelPickerFieldForInstall: async (installationId) => { - if (!installationId) return null - const inst = await getInstallation(installationId) - if (!inst) return null - const source = sourceMap[inst.sourceId] - if (!source) return null - const sections = source.getDetailSections(inst) as Array<{ tab?: string; fields?: Array> }> - for (const sec of sections) { - if (sec.tab && sec.tab !== 'update') continue - const f = sec.fields?.find((ff) => ff.editType === 'channel-cards') - if (f) return f - } - return null - }, - runChannelPickerAction: async (installationId, actionId, actionData) => { - // Same dispatch as `pickerRunAction`. The IPC handler enforces - // the action allowlist (copy-update / release-update / - // switch-channel / update); this binding is a generic - // source-action runner. - try { - const inst = await getInstallation(installationId) - if (!inst) return { ok: false, message: 'Installation not found.' } - const source = sourceMap[inst.sourceId] - if (!source) return { ok: false, message: 'Unknown source.' } - const abort = new AbortController() - const result = await source.handleAction(actionId, inst, actionData, { - update: (data) => updateInstallation(installationId, data).then(() => { }), - sendProgress: () => { }, - sendOutput: () => { }, - signal: abort.signal, - }) - return { - ok: result.ok !== false, - message: typeof result.message === 'string' ? result.message : undefined, - } - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : 'Action failed.' } - } - }, pickInstallFromPicker, restartInstallFromPicker: async (installationId, parentEntryId) => { // Restart: same install, same window. The session is stopped diff --git a/src/main/lib/channel-cards.ts b/src/main/lib/channel-cards.ts index 4eeefd1b..be4c837c 100644 --- a/src/main/lib/channel-cards.ts +++ b/src/main/lib/channel-cards.ts @@ -15,10 +15,7 @@ export interface ChannelCardData { latestVersion: string /** Localized human string for display (e.g. "11/24/2025, 4:32 PM"). */ lastChecked: string - /** Raw ms-since-epoch timestamp the release cache was populated. Used - * renderer-side to gate auto-refresh of stale channel data when the - * Update tab opens. `undefined` ⇒ no cache entry yet → treat as stale. */ - checkedAt?: number + lastCheckedAt?: number updateAvailable: boolean actions?: Record[] } @@ -53,7 +50,7 @@ export function buildChannelCards( installedVersion: cv ? formatComfyVersion(cv, 'detail') : (info.installedTag || 'unknown'), latestVersion: latestCv ? formatComfyVersion(latestCv, 'detail') : (info.releaseName || info.latestTag || '—'), lastChecked: info.checkedAt ? new Date(info.checkedAt).toLocaleString() : '—', - checkedAt: info.checkedAt, + lastCheckedAt: info.checkedAt ?? undefined, updateAvailable: releaseCache.isUpdateAvailable(installation, def.value, info), } : undefined, } diff --git a/src/main/lib/ipc/registerInstallationHandlers.ts b/src/main/lib/ipc/registerInstallationHandlers.ts index 78f1dc91..06728699 100644 --- a/src/main/lib/ipc/registerInstallationHandlers.ts +++ b/src/main/lib/ipc/registerInstallationHandlers.ts @@ -66,8 +66,8 @@ export function enrichInstallationsForRenderer( const statusTag = inst.status === 'partial-delete' ? { label: i18n.t('errors.deleteInterrupted'), style: 'danger' } : inst.status === 'failed' - ? { label: i18n.t('errors.installFailed'), style: 'danger' } - : (source.getStatusTag ? source.getStatusTag(inst) : undefined) + ? { label: i18n.t('errors.installFailed'), style: 'danger' } + : (source.getStatusTag ? source.getStatusTag(inst) : undefined) const cv = inst.comfyVersion as ComfyVersion | undefined const rawVersion = cv ? formatComfyVersion(cv, 'short') : (inst.version as string | undefined) const version = rawVersion === inst.sourceId ? undefined : rawVersion @@ -91,7 +91,7 @@ export function registerInstallationHandlers(): void { const { visible, enriched } = enrichInstallationsForRenderer(allInstalls) // Resolve versions from git state in the background. - _resolveAndBroadcastVersions(visible).catch(() => {}) + _resolveAndBroadcastVersions(visible).catch(() => { }) // Pre-warm the shared ComfyUI release cache so the dashboard / // title-bar update pills reflect upstream state without requiring @@ -221,7 +221,7 @@ export function registerInstallationHandlers(): void { await source.install(inst, { sendProgress, download, cache, extract, signal: abort.signal }) if (source.postInstall) { const update = (data: Record): Promise => - installations.update(installationId, data).then(() => {}) + installations.update(installationId, data).then(() => { }) await source.postInstall(inst, { sendProgress, update, signal: abort.signal }) } @@ -230,10 +230,10 @@ export function registerInstallationHandlers(): void { const pendingFile = freshInst?.pendingSnapshotRestore as string | undefined if (freshInst && pendingFile && fs.existsSync(pendingFile)) { const sendOutput = (text: string): void => { - try { if (!sender.isDestroyed()) sender.send('comfy-output', { installationId, text }) } catch {} + try { if (!sender.isDestroyed()) sender.send('comfy-output', { installationId, text }) } catch { } } const update = (data: Record): Promise => - installations.update(installationId, data).then(() => {}) + installations.update(installationId, data).then(() => { }) await restoreSnapshotIntoInstallation( freshInst, pendingFile, true, { sendProgress, sendOutput, signal: abort.signal }, @@ -250,14 +250,14 @@ export function registerInstallationHandlers(): void { try { fs.rmSync(inst.installPath, { recursive: true, force: true }) cleaned = true - } catch {} + } catch { } } if (cleaned) { await installations.remove(installationId) return { ok: true, navigate: 'list' } } const markerPath = path.join(inst.installPath, MARKER_FILE) - try { fs.writeFileSync(markerPath, installationId) } catch {} + try { fs.writeFileSync(markerPath, installationId) } catch { } await installations.update(installationId, { status: 'partial-delete' }) const deleteAbort = new AbortController() _operationAborts.set(installationId, deleteAbort) @@ -272,10 +272,10 @@ export function registerInstallationHandlers(): void { _operationAborts.delete(installationId) if (deleteAbort.signal.aborted) { if (isEffectivelyEmptyInstallDir(inst.installPath)) { - try { fs.rmSync(inst.installPath, { recursive: true, force: true }) } catch {} + try { fs.rmSync(inst.installPath, { recursive: true, force: true }) } catch { } await installations.remove(installationId) } else { - try { fs.writeFileSync(markerPath, installationId) } catch {} + try { fs.writeFileSync(markerPath, installationId) } catch { } await installations.update(installationId, { status: 'partial-delete' }) } } diff --git a/src/main/lib/ipc/sessionActions/delete.ts b/src/main/lib/ipc/sessionActions/delete.ts index d17ffd3e..e1eaa903 100644 --- a/src/main/lib/ipc/sessionActions/delete.ts +++ b/src/main/lib/ipc/sessionActions/delete.ts @@ -21,10 +21,10 @@ export async function handleDelete({ event, installationId, inst }: ActionContex let markerContent: string | null try { markerContent = fs.readFileSync(markerPath, 'utf-8').trim() } catch { markerContent = null } if (!markerContent) { - return { ok: false, message: 'Safety check failed: this directory was not created by ComfyUI Desktop 2.0. Use Untrack to remove it from the list, then delete the files manually.' } + return { ok: false, message: 'Safety check failed: this directory was not created by ComfyUI Desktop 2.0. Use Forget to remove it from the list, then delete the files manually.' } } if (markerContent !== inst.id && markerContent !== 'tracked') { - return { ok: false, message: 'Safety check failed: the marker file does not match this installation. Use Untrack instead.' } + return { ok: false, message: 'Safety check failed: the marker file does not match this installation. Use Forget instead.' } } const sender = event.sender const sendProgress = makeSendProgress(sender, installationId) diff --git a/src/main/popups/titlePopup.test.ts b/src/main/popups/titlePopup.test.ts index 97228169..67c57e2a 100644 --- a/src/main/popups/titlePopup.test.ts +++ b/src/main/popups/titlePopup.test.ts @@ -23,6 +23,7 @@ vi.mock('electron', () => ({ // without subscribing to anything. import { buildInstancePickerSnapshot, + resolvePickerSelectedInstallId, buildTitlePopupMenuItems, computePopupHeight, GLOBAL_SETTINGS_ALLOWED_ACTIONS, @@ -123,33 +124,57 @@ describe('buildTitlePopupMenuItems', () => { expect(ids).not.toContain('new-install') expect(ids).not.toContain('track') expect(ids).not.toContain('load-snapshot') - expect(ids).toContain('return-to-dashboard') }) - it('always includes New Window, Settings, Send Feedback, and Close All Windows', () => { - for (const installationId of [null, 'inst-1']) { - const ids = buildTitlePopupMenuItems(makeEntry({ installationId })) - .map((i) => i.id ?? null) - expect(ids).toContain('new-window') - expect(ids).toContain('settings') - expect(ids).toContain('feedback') - expect(ids).toContain('close-all-windows') - } + it('chooser host includes New Window, Settings, Send Feedback, and Close All Windows', () => { + const ids = buildTitlePopupMenuItems(makeEntry({ installationId: null })) + .map((i) => i.id ?? null) + expect(ids).toContain('new-window') + expect(ids).toContain('settings') + expect(ids).toContain('feedback') + expect(ids).toContain('close-all-windows') }) -it('exposes Reset Zoom only when comfy zoom is non-zero, with the percent in the label', () => { - const noZoom = buildTitlePopupMenuItems(makeEntry({ zoomLevel: 0 })) + // Install-host menu was deliberately trimmed (spec item 1): no + // Desktop Settings, no Return to Dashboard (replaced by the Home + // icon in the picker, spec item 10), no Reset Zoom (Ctrl/Cmd+0 + // shortcut still works). The remaining four are New Window, Send + // Beta Feedback, Exit Window, Exit All Windows. + it('install-host menu is trimmed to four essentials in the canonical order', () => { + const items = buildTitlePopupMenuItems(makeEntry({ installationId: 'inst-1' })) + const ids = items.map((i) => i.id ?? null).filter((id) => id !== null) + expect(ids).toEqual(['new-window', 'feedback', 'exit-window', 'close-all-windows']) + const closeAll = items.find((i) => i.id === 'close-all-windows') + expect(closeAll?.label).toBe('Exit All Windows') + const exitWindow = items.find((i) => i.id === 'exit-window') + expect(exitWindow?.label).toBe('Exit Window') + }) + + it('install-host menu has neither Reset Zoom nor Return to Dashboard', () => { + const items = buildTitlePopupMenuItems( + makeEntry({ installationId: 'inst-1', zoomLevel: 2 }), + ) + const ids = items.map((i) => i.id ?? null) + expect(ids).not.toContain('reset-zoom') + expect(ids).not.toContain('return-to-dashboard') + expect(ids).not.toContain('settings') + }) + + it('exposes Reset Zoom on chooser host only when comfy zoom is non-zero, with the percent in the label', () => { + const noZoom = buildTitlePopupMenuItems(makeEntry({ installationId: null, zoomLevel: 0 })) expect(noZoom.find((i) => i.id === 'reset-zoom')).toBeUndefined() // 1.2^2 ≈ 1.44 → 144 % - const zoomed = buildTitlePopupMenuItems(makeEntry({ zoomLevel: 2 })) + const zoomed = buildTitlePopupMenuItems(makeEntry({ installationId: null, zoomLevel: 2 })) const resetZoom = zoomed.find((i) => i.id === 'reset-zoom') expect(resetZoom).toBeDefined() expect(resetZoom?.label).toBe('Reset Zoom (144%)') }) - it('omits Reset Zoom when the comfy webContents has been destroyed', () => { - const items = buildTitlePopupMenuItems(makeEntry({ comfyDestroyed: true, zoomLevel: 2 })) + it('omits Reset Zoom from the chooser host menu when the comfy webContents has been destroyed', () => { + const items = buildTitlePopupMenuItems( + makeEntry({ installationId: null, comfyDestroyed: true, zoomLevel: 2 }), + ) expect(items.find((i) => i.id === 'reset-zoom')).toBeUndefined() }) @@ -160,15 +185,6 @@ it('exposes Reset Zoom only when comfy zoom is non-zero, with the percent in the expect(ids[ids.length - 1]).toBe('close-all-windows') }) - it('places Return to Dashboard before Close All Windows on an install-backed host', () => { - const items = buildTitlePopupMenuItems(makeEntry({ installationId: 'inst-1' })) - const ids = items.map((i) => i.id ?? null) - const returnIdx = ids.indexOf('return-to-dashboard') - const closeAllIdx = ids.indexOf('close-all-windows') - expect(returnIdx).toBeGreaterThanOrEqual(0) - expect(closeAllIdx).toBeGreaterThan(returnIdx) - }) - it('separators bracket the optional install-creation block on chooser', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const newWindowIdx = items.findIndex((i) => i.id === 'new-window') @@ -178,6 +194,37 @@ it('exposes Reset Zoom only when comfy zoom is non-zero, with the percent in the }) }) +describe('resolvePickerSelectedInstallId', () => { + function makeInstall(overrides: Partial): InstancePickerInstall { + return { + id: 'x', + name: 'X', + sourceLabel: 'Standalone', + sourceCategory: 'local', + ...overrides, + } as InstancePickerInstall + } + + it('prefers an explicit selection over the host install', () => { + const installs = [makeInstall({ id: 'a' }), makeInstall({ id: 'b' })] + expect(resolvePickerSelectedInstallId('b', 'a', installs)).toBe('b') + }) + + it('falls back to the host install when no explicit selection', () => { + const installs = [makeInstall({ id: 'a' }), makeInstall({ id: 'b' })] + expect(resolvePickerSelectedInstallId(null, 'b', installs)).toBe('b') + }) + + it('defaults to the first install on an install-less host', () => { + const installs = [makeInstall({ id: 'a' }), makeInstall({ id: 'b' })] + expect(resolvePickerSelectedInstallId(null, null, installs)).toBe('a') + }) + + it('returns null when there are no installs to select', () => { + expect(resolvePickerSelectedInstallId(null, null, [])).toBeNull() + }) +}) + describe('buildInstancePickerSnapshot', () => { function makeInstall(overrides: Partial): InstancePickerInstall { return { @@ -189,6 +236,12 @@ describe('buildInstancePickerSnapshot', () => { } as InstancePickerInstall } + const EMPTY_STORAGE = { + sharedDirectoriesFields: [], + modelsDirs: [], + modelsSystemDefault: '', + } + it('forwards the install array verbatim under `installs`', () => { const installs = [ makeInstall({ id: 'a', name: 'A' }), @@ -198,6 +251,7 @@ describe('buildInstancePickerSnapshot', () => { installs, hostInstallationId: null, runningInstallationIds: [], + storage: EMPTY_STORAGE, }) expect(snap.installs).toEqual(installs) }) @@ -207,6 +261,7 @@ describe('buildInstancePickerSnapshot', () => { installs: [makeInstall({ id: 'a' })], hostInstallationId: 'a', runningInstallationIds: [], + storage: EMPTY_STORAGE, }) expect(snap.activeInstallationId).toBe('a') }) @@ -216,6 +271,7 @@ describe('buildInstancePickerSnapshot', () => { installs: [], hostInstallationId: null, runningInstallationIds: [], + storage: EMPTY_STORAGE, }) expect(snap.activeInstallationId).toBeNull() }) @@ -225,6 +281,7 @@ describe('buildInstancePickerSnapshot', () => { installs: [], hostInstallationId: null, runningInstallationIds: ['b', 'a', 'c'], + storage: EMPTY_STORAGE, }) expect(snap.runningInstallationIds).toEqual(['b', 'a', 'c']) }) @@ -234,6 +291,7 @@ describe('buildInstancePickerSnapshot', () => { installs: [makeInstall({ id: 'a' })], hostInstallationId: null, runningInstallationIds: [], + storage: EMPTY_STORAGE, }) expect(snap.runningInstallationIds).toEqual([]) }) diff --git a/src/main/popups/titlePopup.ts b/src/main/popups/titlePopup.ts index 7db4f3d6..82fa7247 100644 --- a/src/main/popups/titlePopup.ts +++ b/src/main/popups/titlePopup.ts @@ -27,7 +27,6 @@ import { comfyWindows, findEntryByTitleBarSender, isChooserHost, - isInstallHost, } from '../host/registry' import type { ComfyPanelKey, ComfyWindowEntry } from '../host/registry' import { @@ -95,6 +94,18 @@ export interface InstancePickerInstall { * picker's right pane (changes whenever the user clicks a different * row — picker tells main via `set-picker-selected-install` IPC and * main re-broadcasts a fresh snapshot). */ +/** Storage-tab slice piggy-backed on the picker snapshot. Same shape + * as the storage fields in `GlobalSettingsSnapshot` — main builds it + * off the same `buildMediaSections` / `buildModelsPayload` plumbing. + * Used by `StoragePane.vue` to render shared-model dirs and the + * Shared Directories fields without subscribing to the + * global-settings broadcast (which doesn't target picker popups). */ +export interface PickerStorageSlice { + sharedDirectoriesFields: Record[] + modelsDirs: GlobalSettingsModelsDir[] + modelsSystemDefault: string +} + export interface InstancePickerSnapshot { installs: InstancePickerInstall[] activeInstallationId: string | null @@ -102,19 +113,15 @@ export interface InstancePickerSnapshot { selectedInstallationId: string | null selectedSettings: Record[] | null selectedSnapshots: Record | null - /** Compact = default identity-card right pane; expanded = full - * per-install settings UI (`ComfyUISettingsContent`) in the right - * pane. Driven by `comfy-titlepopup:set-picker-mode` IPC; main - * animates the popup bounds when this flips. */ - mode: 'compact' | 'expanded' - /** When `mode === 'expanded'`, the tab the settings UI opens on - * ('config' | 'status' | 'update' | 'snapshots'). Ignored in - * compact mode. */ + /** Tab the settings UI opens on ('config' | 'status' | 'update' | + * 'snapshots' | 'storage'). Null = let the picker view choose its + * default. */ initialTab: string | null - /** When `mode === 'expanded'`, an action id to fire automatically - * after the settings UI mounts (e.g. `'update-comfyui'` for the - * kebab Update entry). Cleared after consumption. */ + /** Action id to fire automatically after the settings UI mounts + * (e.g. `'update-comfyui'` for the kebab Update entry). Cleared + * after consumption. */ autoAction: string | null + storage: PickerStorageSlice } /** Single Models-directory row pushed to the global-settings popup. @@ -134,7 +141,9 @@ export interface GlobalSettingsModelsDir { * `Record` to keep the preload boundary type-safe * without dragging renderer types into main. */ export interface GlobalSettingsSnapshot { - overviewFields: Record[] + generalFields: Record[] + telemetryFields: Record[] + desktopUpdateFields: Record[] cacheFields: Record[] advancedFields: Record[] sharedDirectoriesFields: Record[] @@ -149,15 +158,12 @@ export interface GlobalSettingsSnapshot { platform: NodeJS.Platform lastCheckedAt: number | null } - channelPickerField: Record | null - activeInstallationId: string | null - hasActiveInstall: boolean githubUrl: string githubStars: number | null i18n: { overview: string updates: string - cache: string + storage: string models: string advanced: string sharedDirectories: string @@ -171,9 +177,24 @@ interface BuildInstancePickerSnapshotArgs { selectedInstallationId?: string | null selectedSettings?: Record[] | null selectedSnapshots?: Record | null - mode?: 'compact' | 'expanded' initialTab?: string | null autoAction?: string | null + storage: PickerStorageSlice +} + +/** + * Resolves which install the picker should show in its detail pane. + * Install-less hosts (dashboard) have no active install; default to the + * first row so the pane is not empty on open. + */ +export function resolvePickerSelectedInstallId( + explicitSelection: string | null | undefined, + hostInstallationId: string | null | undefined, + installs: InstancePickerInstall[], +): string | null { + const resolved = explicitSelection ?? hostInstallationId ?? null + if (resolved) return resolved + return installs[0]?.id ?? null } /** @@ -191,9 +212,32 @@ export function buildInstancePickerSnapshot( selectedInstallationId: args.selectedInstallationId ?? null, selectedSettings: args.selectedSettings ?? null, selectedSnapshots: args.selectedSnapshots ?? null, - mode: args.mode ?? 'compact', initialTab: args.initialTab ?? null, autoAction: args.autoAction ?? null, + storage: args.storage, + } +} + +/** Build the storage-tab slice piggy-backed on the picker snapshot. + * Same `buildMediaSections` / `buildModelsPayload` plumbing the + * global-settings snapshot uses — kept in one place so both + * surfaces stay in lockstep. */ +function buildPickerStorageSlice(): PickerStorageSlice { + const mediaSections = buildMediaSections() + const modelsPayload = buildModelsPayload() + const sharedDirectoriesFields = + (mediaSections[0]?.fields ?? []).map(toDetailField) as unknown as Record[] + const modelsDirsRaw = + (modelsPayload.sections[0]?.fields[0]?.value as string[] | undefined) ?? [] + const modelsDefault = modelsPayload.systemDefault + return { + sharedDirectoriesFields, + modelsDirs: modelsDirsRaw.map((p, i) => ({ + path: p, + isPrimary: i === 0, + isDefault: p === modelsDefault, + })), + modelsSystemDefault: modelsDefault, } } @@ -270,21 +314,12 @@ interface TitlePopupEntry { * scope `selectedSettings` + `selectedSnapshots` in subsequent * snapshot pushes. Defaults to the host's active install on open. */ pickerSelectedInstallationId: string | null - /** Picker's right-pane mode. `'compact'` = identity card + Open/Manage - * CTAs (the original popup size). `'expanded'` = the full per-install - * settings UI mounts in the right pane and main animates the popup - * bounds to ~95dvw × 95dvh. Driven by `comfy-titlepopup:set-picker-mode` - * IPC; main rebroadcasts a snapshot with the new value so the picker - * view re-renders the right pane. */ - pickerMode: 'compact' | 'expanded' - /** When `pickerMode === 'expanded'`, the tab id the settings UI opens - * on. Forwarded into the snapshot for the picker view to consume on - * first render. */ + /** Tab id the settings UI opens on. Forwarded into the snapshot for + * the picker view to consume on first render. */ pickerInitialTab: string | null - /** When `pickerMode === 'expanded'`, an action id the settings UI - * fires automatically after mounting (kebab Update / Migrate / - * Restore-Snapshot / Delete entry points). Cleared after the picker - * view consumes it. */ + /** Action id the settings UI fires automatically after mounting + * (kebab Update / Migrate / Restore-Snapshot / Delete entry + * points). Cleared after the picker view consumes it. */ pickerAutoAction: string | null /** JSON of the most recent `installs-changed` snapshot sent to this * popup. Used by `broadcastInstancePickerSnapshotToTitlePopups` to @@ -297,14 +332,6 @@ interface TitlePopupEntry { /** JSON of the most recent `global-settings-changed` snapshot pushed * to this popup. Same dedup role as `lastPickerBroadcastJson`. */ lastGlobalSettingsBroadcastJson: string | null - /** Most recent natural-content height the picker renderer reported - * via `comfy-titlepopup:request-size`. The compact-mode bounds - * computation reads this so the popup tracks the renderer's - * measured height even after a host-window resize — without it the - * resize listener would have to ask the renderer again, racing the - * observer. `null` until the first `request-size` lands; only - * populated for the instance-picker kind. */ - lastReportedNaturalHeight: number | null /** Wall-clock time of the most recent `showTitlePopupNow()` for this * entry. The backdrop's `mousedown` listener can fire during the * same tick as the trigger click (the backdrop covers the body, and @@ -558,44 +585,52 @@ export function buildTitlePopupMenuItems(entry: ComfyWindowEntry): TitlePopupMen }, { id: 'load-snapshot', label: 'Load Snapshot', labelKey: 'fileMenu.loadSnapshot' }, { kind: 'separator' }, + { + id: 'settings', + label: 'Desktop Settings', + labelKey: 'fileMenu.globalSettings', + }, + // Send Feedback (#493). The renderer-side handler resolves the + // support URL and emits the `desktop2.feedback.opened` + // telemetry action with `source: 'menu'`. + { id: 'feedback', label: 'Send Beta Feedback', labelKey: 'fileMenu.sendFeedback' }, + ) + // Reset Zoom — discoverable recovery path for users who zoom the + // comfyView too far to read. Only on the chooser host (the dummy + // comfyView there can still be zoomed via Ctrl/Cmd+scroll); the + // install host trims it out per the simplified menu spec. + if (!entry.comfyView.webContents.isDestroyed()) { + const level = entry.comfyView.webContents.getZoomLevel() + if (level !== 0) { + const percent = Math.round(Math.pow(1.2, level) * 100) + items.push({ id: 'reset-zoom', label: `Reset Zoom (${percent}%)` }) + } + } + items.push( + { kind: 'separator' }, + { + id: 'close-all-windows', + label: 'Exit All Windows', + labelKey: 'fileMenu.exitAllWindows', + }, ) + return items } + // Install-host menu: trimmed to the four essentials. Desktop Settings, + // Return to Dashboard, and Reset Zoom are intentionally absent — + // Settings lives in the picker's Startup Args tab, the dashboard + // escape is the Home icon in the picker chips row, and Reset Zoom + // remains reachable via Ctrl/Cmd + 0. items.push( - { - id: 'settings', - label: 'Desktop Settings', - labelKey: 'fileMenu.globalSettings', - }, - // Send Feedback (#493). The renderer-side handler resolves the - // support URL and emits the `desktop2.feedback.opened` - // telemetry action with `source: 'menu'`. { id: 'feedback', label: 'Send Beta Feedback', labelKey: 'fileMenu.sendFeedback' }, { kind: 'separator' }, + { id: 'exit-window', label: 'Exit Window', labelKey: 'fileMenu.exitWindow' }, + { + id: 'close-all-windows', + label: 'Exit All Windows', + labelKey: 'fileMenu.exitAllWindows', + }, ) - if (isInstallHost(entry)) { - items.push({ - id: 'return-to-dashboard', - label: 'Return to Dashboard', - labelKey: 'fileMenu.returnToDashboard', - }) - } - // Reset Zoom — discoverable recovery path for users who zoom the Comfy - // view too far to read. Only surfaced when zoom is actually non-default, - // and the label includes the current percent so the menu also doubles - // as a status indicator. The Ctrl/Cmd + 0 shortcut wired in `onLaunch` - // does the same thing for users who know it. - if (!entry.comfyView.webContents.isDestroyed()) { - const level = entry.comfyView.webContents.getZoomLevel() - if (level !== 0) { - const percent = Math.round(Math.pow(1.2, level) * 100) - items.push({ id: 'reset-zoom', label: `Reset Zoom (${percent}%)` }) - } - } - items.push({ - id: 'close-all-windows', - label: 'Close All Windows', - labelKey: 'fileMenu.closeAllWindows', - }) return items } @@ -636,7 +671,14 @@ async function broadcastInstancePickerSnapshotToTitlePopups( if (!entry.view.isOpen && entry.view.pendingShowTimer === null) continue if (entry.view.popup.webContents.isDestroyed()) continue const parentEntry = comfyWindows.get(entry.parentEntryId) - const selectedId = entry.pickerSelectedInstallationId ?? parentEntry?.installationId ?? null + const selectedId = resolvePickerSelectedInstallId( + entry.pickerSelectedInstallationId, + parentEntry?.installationId, + installs, + ) + if (!entry.pickerSelectedInstallationId && selectedId) { + entry.pickerSelectedInstallationId = selectedId + } const details = selectedId ? await bindings.getPickerDetailsForInstall(selectedId).catch(() => ({ settings: null, @@ -650,9 +692,9 @@ async function broadcastInstancePickerSnapshotToTitlePopups( selectedInstallationId: selectedId, selectedSettings: details.settings, selectedSnapshots: details.snapshots, - mode: entry.pickerMode, initialTab: entry.pickerInitialTab, autoAction: entry.pickerAutoAction, + storage: buildPickerStorageSlice(), }) // Dedupe: every snapshot broadcast triggers a `pickerSnapshot` // prop change in the renderer, which schedules a measure-and- @@ -775,13 +817,11 @@ function ensureTitlePopup(parent: BrowserWindow): TitlePopupEntry { lastConfigJson: null, lastSyncedConfigJson: null, pickerSelectedInstallationId: null, - pickerMode: 'compact', pickerInitialTab: null, pickerAutoAction: null, openedAt: 0, lastPickerBroadcastJson: null, lastGlobalSettingsBroadcastJson: null, - lastReportedNaturalHeight: null, } titlePopupsByParent.set(view.parentWindowId, entry) titlePopupsByWebContents.set(view.popupWebContentsId, entry) @@ -899,63 +939,31 @@ const DOWNLOADS_POPUP_WIDTH = 405 const DOWNLOADS_POPUP_MAX_HEIGHT_PX = 396 const DOWNLOADS_POPUP_MAX_HEIGHT_RATIO = 0.6 -/** Instance-picker popup geometry. The picker has two modes (compact - * list of rows + expanded settings UI) and historically had a stack of - * per-mode pixel constants — wide, min height, max height, ratio — - * layered to keep the popup inside the host window. That worked but - * meant a different math path for every site that needed bounds - * (open, mode-flip morph, parent-resize refit, request-size handler). - * The four sites drifted from each other (the morph forgot the title- - * bar inset; the resize handler used a different ratio than the open - * path) and the popup would visibly slip past the title chrome. - * - * Replaced with a single `computePickerBounds()` function and three - * inset constants. The function is the only place that decides where - * the picker sits inside the host window — every call site (open, - * morph target, parent-resize refit, request-size clamp) reads from it - * so they can't drift. +/** Instance-picker popup geometry. Single `computePickerBounds()` + * function is the only place that decides where the picker sits inside + * the host window — every call site (open, parent-resize refit, + * request-size clamp) reads from it so they can't drift. * * - `PICKER_SIDE_GUTTER` / `PICKER_BOTTOM_GUTTER`: breathing room * between the popup card and the host window's right + bottom - * edges. Top edge is handled by `TITLEBAR_HEIGHT`. - * - `PICKER_COMPACT_MAX_WIDTH`: the natural cap on the compact - * single-column list. On wide monitors the row list would otherwise - * stretch to absurd line lengths; capping at 720px keeps it - * legible. Expanded mode ignores this and fills the available - * inner width. */ + * edges. Top edge is handled by `TITLEBAR_HEIGHT`. */ const PICKER_SIDE_GUTTER = 24 const PICKER_BOTTOM_GUTTER = 24 /** Extra breathing room between the title bar and the popup card. * `TITLEBAR_HEIGHT` is the title-bar's measured height; without this * gutter the popup kisses the chrome and reads as glued-on. */ const PICKER_TOP_GUTTER = 8 -const PICKER_COMPACT_MAX_WIDTH = 720 -/** Expanded mode width ceiling. The settings UI inside the right pane - * is form-shaped — inputs at full width just look stretched, not - * more useful. Cap at 960px so on big screens the box sits centred - * with side gutters and the form fields read at a comfortable line - * length. */ +/** Width ceiling. The settings UI inside the right pane is form-shaped + * — inputs at full width just look stretched, not more useful. Cap at + * 960px so on big screens the box sits centred with side gutters and + * the form fields read at a comfortable line length. */ const PICKER_EXPANDED_MAX_WIDTH = 960 -/** Hard ceiling for the compact tray on tall windows. Previously - * paired with a 60% × window ratio cap, but on a *short* host - * window the ratio choked the tray even when `naturalHeight` fit - * comfortably in the available `innerHeight` (e.g. a 700px-tall - * window: 60% = 420px, clipping a 500px natural-height tray for no - * good reason). The hard 560px cap alone holds the anti-takeover - * intent on tall windows, and `innerHeight` already caps growth on - * short ones. - * much vertical real-estate for two installs. Cap so the tray reads - * as a tray. */ -const PICKER_COMPACT_MAX_HEIGHT = 560 -/** Expanded mode height ceiling as a fraction of the host window's - * content height. Mirrors the compact-mode ratio reasoning — the - * per-install settings UI is form-shaped, so filling a 4K window - * top-to-bottom just leaves the form fields adrift in negative - * space. */ +/** Height ceiling as a fraction of host window content height. The + * per-install settings UI is form-shaped, so filling a 4K window top- + * to-bottom just leaves the form fields adrift in negative space. */ const PICKER_EXPANDED_MAX_HEIGHT_RATIO = 0.85 -/** Hard ceiling for expanded mode on tall windows. 720px fits the - * settings UI's longest tab (Snapshots) without internal scroll on - * typical install counts. */ +/** Hard ceiling on tall windows. 720px fits the settings UI's longest + * tab (Snapshots) without internal scroll on typical install counts. */ const PICKER_EXPANDED_MAX_HEIGHT = 720 interface PickerBounds { @@ -968,70 +976,37 @@ interface PickerBounds { /** * Single source of truth for picker popup bounds. * - * Every site that needs to place the picker (open, mode-flip morph, - * parent-resize refit, renderer-driven natural-height request) calls + * Every site that places the picker (open, parent-resize refit) calls * this — so the popup can't end up at one set of bounds at open and a * different set after a resize. * - * Geometry: - * - Top edge always sits at `TITLEBAR_HEIGHT` so the popup never - * paints over the host's title chrome. - * - Side gutters keep the card from kissing the host window's edges. - * - Compact mode caps at `PICKER_COMPACT_MAX_WIDTH` so the row list - * doesn't stretch into illegible line lengths on wide monitors; the - * renderer's natural-height request decides compact height (passed - * in as `naturalHeight`). - * - Expanded mode fills the full inner area (max width minus gutters, - * height from title bar to bottom gutter). `naturalHeight` is - * ignored — the per-install settings UI fills whatever it gets. + * Geometry: top edge at `TITLEBAR_HEIGHT` so the popup never paints + * over the host's title chrome; side gutters keep the card from kissing + * the host window's edges; width clamped to `PICKER_EXPANDED_MAX_WIDTH` + * so form fields read at a comfortable line length on wide monitors; + * height clamped to a host-content ratio + hard pixel ceiling so the + * settings UI doesn't drift in negative space on a 4K window. * - * The popup is horizontally centred on the host window's content. In - * compact mode (where height < innerHeight) the popup is vertically - * pinned to the top inset so it reads as a "tray under the title bar"; - * expanded mode fills top-to-bottom so vertical centring doesn't - * apply. + * Horizontally centred on the host window's content. On tall windows + * the card drops by a third of the available slack so it sits slightly + * above centre — title bar still anchors it visually, but it doesn't + * kiss the bottom gutter on a 1440px-tall window. */ -function computePickerBounds( - parent: BrowserWindow, - mode: 'compact' | 'expanded', - naturalHeight?: number | null, -): PickerBounds { +function computePickerBounds(parent: BrowserWindow): PickerBounds { const content = parent.getContentBounds() const innerTop = TITLEBAR_HEIGHT + PICKER_TOP_GUTTER const innerHeight = Math.max(0, content.height - innerTop - PICKER_BOTTOM_GUTTER) const innerWidth = Math.max(0, content.width - 2 * PICKER_SIDE_GUTTER) - let width: number - let height: number - if (mode === 'expanded') { - width = Math.min(PICKER_EXPANDED_MAX_WIDTH, innerWidth) - height = Math.min( - innerHeight, - Math.round(content.height * PICKER_EXPANDED_MAX_HEIGHT_RATIO), - PICKER_EXPANDED_MAX_HEIGHT, - ) - } else { - width = Math.min(PICKER_COMPACT_MAX_WIDTH, innerWidth) - // `innerHeight` caps growth against the host window (anti-overflow); - // the hard 560px cap holds the anti-takeover intent on tall windows. - // No window-ratio cap — that previously choked the tray on *short* - // windows where the natural height would otherwise have fit fine. - const compactCeiling = Math.min(innerHeight, PICKER_COMPACT_MAX_HEIGHT) - const requested = typeof naturalHeight === 'number' && naturalHeight > 0 - ? Math.ceil(naturalHeight) - : compactCeiling - height = Math.max(1, Math.min(requested, compactCeiling)) - } + const width = Math.min(PICKER_EXPANDED_MAX_WIDTH, innerWidth) + const height = Math.min( + innerHeight, + Math.round(content.height * PICKER_EXPANDED_MAX_HEIGHT_RATIO), + PICKER_EXPANDED_MAX_HEIGHT, + ) const x = Math.max(PICKER_SIDE_GUTTER, Math.round((content.width - width) / 2)) - // Compact: pin to top inset so the tray reads as anchored to the - // title bar. Expanded: drop down by a third of the slack so on a - // tall window the card sits slightly above centre — title bar still - // anchors it visually, but it doesn't kiss the bottom gutter on a - // 1440px-tall window. const slack = Math.max(0, innerHeight - height) - const y = mode === 'expanded' && slack > 0 - ? innerTop + Math.round(slack / 3) - : innerTop + const y = slack > 0 ? innerTop + Math.round(slack / 3) : innerTop return { x, y, width, height } } @@ -1105,16 +1080,12 @@ function refitPopupForParent(entry: TitlePopupEntry): void { const cur = entry.view.popup.getBounds() const parent = entry.view.parentWindow - // Instance picker has its own geometry function (compact + expanded - // modes both clamped to the inner area below the title bar). Route - // through it so a parent resize lands on the same bounds the open / - // mode-flip paths would compute — single source of truth. + // Instance picker has its own geometry function (clamped to the inner + // area below the title bar). Route through it so a parent resize lands + // on the same bounds the open path would compute — single source of + // truth. if (entry.kind === 'instance-picker') { - const target = computePickerBounds( - parent, - entry.pickerMode, - entry.lastReportedNaturalHeight, - ) + const target = computePickerBounds(parent) if ( target.x === cur.x && target.y === cur.y @@ -1234,15 +1205,10 @@ function openTitlePopup(opts: OpenTitlePopupOpts): void { x = clampPopupX(rawX, width, opts.parent) } else if (opts.kind === 'instance-picker') { // instance-picker geometry is delegated to `computePickerBounds` - // — single source of truth shared with the mode-flip morph, the - // parent-resize refit, and the renderer's natural-height request - // handler so all four paths produce consistent bounds. The initial - // open passes `null` for naturalHeight; the renderer measures once - // it mounts and pushes the real height via - // `comfy-titlepopup:request-size`, which stores it on the entry - // and re-applies bounds. Geometry function also owns the title-bar - // inset, so the popup never paints over the title chrome. - const bounds = computePickerBounds(opts.parent, entry.pickerMode, null) + // — single source of truth shared with the parent-resize refit so + // both paths produce consistent bounds. Geometry function owns the + // title-bar inset, so the popup never paints over the title chrome. + const bounds = computePickerBounds(opts.parent) width = bounds.width height = bounds.height x = bounds.x @@ -1254,7 +1220,7 @@ function openTitlePopup(opts: OpenTitlePopupOpts): void { // below the title bar. Anchor coords are title-bar-local so `y=0` // sits at the title-bar top; the centred-y formula recentres // inside the `contentHeight - TITLEBAR_HEIGHT` band beneath it. - ;({ width, height } = computeGlobalSettingsBounds(opts.parent)) + ; ({ width, height } = computeGlobalSettingsBounds(opts.parent)) const { width: contentWidth, height: contentHeight } = opts.parent.getContentBounds() x = Math.max(0, Math.round((contentWidth - width) / 2)) y = Math.max( @@ -1340,6 +1306,11 @@ export interface TitlePopupHostBindings { /** Confirm + close all host windows. The parent window is the popup's * host so the confirm dialog can be parented to it. */ confirmAndCloseAllHostWindows: (parentWindow: BrowserWindow | null) => Promise | void + /** Confirm + close a single host window. Same primitive the + * bulk-close uses, scoped to one window. Powers the install-host + * menu's `Exit Window` entry so the user gets a prompt instead of + * the silent-close the native OS button gives. */ + confirmAndCloseHostWindow: (parentWindow: BrowserWindow) => Promise | void /** Switch the host's body to the named panel (settings, new-install, ...). */ setActivePanel: (windowKey: number, panel: ComfyPanelKey) => void /** Forward a Send Feedback request to the host's panel renderer. */ @@ -1410,30 +1381,13 @@ export interface TitlePopupHostBindings { actionId: string, actionData?: Record, ) => Promise<{ ok: boolean; message?: string }> - /** Resolve the `channel-cards` field from the active install's - * Update tab. Returns `null` for chooser hosts (no install) or for - * sources that emit no channel-picker payload (e.g. cloud installs). - * Drives the Update Channel select inside the global-settings popup. */ - getChannelPickerFieldForInstall: ( - installationId: string | null, - ) => Promise | null> - /** Dispatch an arbitrary install action — used by the global-settings - * popup's Update accordion for `'copy-update' | 'release-update' | - * 'switch-channel' | 'update'`. The IPC handler enforces the - * allowlist before calling this. Mirrors `pickerRunAction` but - * doesn't constrain the action surface itself. */ - runChannelPickerAction: ( - installationId: string, - actionId: string, - actionData?: Record, - ) => Promise<{ ok: boolean; message?: string }> } /** Open the Global Settings popup for a specific host window. Shared * by the hamburger menu's `id === 'settings'` handler and the panel * renderer's `comfy-titlepopup:open-global-settings` IPC — both end up - * doing the same thing: build a snapshot for the host's active - * install (null on chooser hosts) and open the centred popup. + * doing the same thing: build a desktop-only snapshot and open the + * centred popup. * * `parentEntry` is the host window's `ComfyWindowEntry`. Bail if the * window is destroyed. @@ -1445,15 +1399,12 @@ export interface TitlePopupHostBindings { function openGlobalSettingsForHost( parentEntry: ComfyWindowEntry, parentEntryId: number, - bindings: TitlePopupHostBindings, + _bindings: TitlePopupHostBindings, titleBarSender: Electron.WebContents, ): void { if (parentEntry.window.isDestroyed()) return void (async () => { - const snapshot = await buildGlobalSettingsSnapshot( - bindings, - parentEntry.installationId, - ) + const snapshot = await buildGlobalSettingsSnapshot() if (parentEntry.window.isDestroyed()) return openTitlePopup({ parent: parentEntry.window, @@ -1494,22 +1445,20 @@ function openInstancePickerForHost( titleBarSender: Electron.WebContents, anchor: { x: number; y: number }, selectedInstallationId?: string | null, - initialMode?: 'compact' | 'expanded', initialTab?: string | null, autoAction?: string | null, ): void { if (parentEntry.window.isDestroyed()) return const installs: InstancePickerInstall[] = cachedInstallsForPicker.slice() const runningInstallationIds = bindings.getRunningInstallationIds() - const initialSelectedId = selectedInstallationId ?? parentEntry.installationId + const initialSelectedId = resolvePickerSelectedInstallId( + selectedInstallationId, + parentEntry.installationId, + installs, + ) const popupEntry = titlePopupsByParent.get(parentEntry.window.id) if (popupEntry) { popupEntry.pickerSelectedInstallationId = initialSelectedId - // Seed the mode + auto-action on the entry so the next snapshot - // broadcast picks them up. The chooser-card kebab path passes - // `'expanded'` + a specific tab; the title-bar pill path leaves - // these unset and the popup opens compact. - popupEntry.pickerMode = initialMode ?? 'compact' popupEntry.pickerInitialTab = initialTab ?? null popupEntry.pickerAutoAction = autoAction ?? null } @@ -1520,9 +1469,9 @@ function openInstancePickerForHost( selectedInstallationId: initialSelectedId, selectedSettings: null, selectedSnapshots: null, - mode: initialMode ?? 'compact', initialTab: initialTab ?? null, autoAction: autoAction ?? null, + storage: buildPickerStorageSlice(), }) openTitlePopup({ parent: parentEntry.window, @@ -1577,6 +1526,14 @@ function activateTitlePopupMenuItem( // parented to it so it stays valid through the in-place body // swap (no popup teardown). void bindings.returnToDashboard(entry.parentEntryId) + } else if (id === 'exit-window') { + // Single-window close with a confirm — mirrors the bulk-close + // primitive so the user sees what they're about to abandon + // (running ComfyUI, in-progress installs, downloads) instead of + // the silent close the OS button gives. + if (parentEntry && !parentEntry.window.isDestroyed()) { + void bindings.confirmAndCloseHostWindow(parentEntry.window) + } } else if (id === 'close-all-windows') { // For two or more open windows we confirm via a native dialog // that lists the open windows + any active operations that @@ -1744,28 +1701,27 @@ function findSettingsFields( return src.map(toDetailField) } -async function buildGlobalSettingsSnapshot( - bindings: TitlePopupHostBindings, - hostInstallationId: string | null, -): Promise { +async function buildGlobalSettingsSnapshot(): Promise { const settingsSections = buildSettingsSections() const mediaSections = buildMediaSections() const modelsPayload = buildModelsPayload() - const general = findSettingsFields(settingsSections, 'settings.general', 0) - const telemetry = findSettingsFields(settingsSections, 'settings.telemetry', 1) + const generalRaw = findSettingsFields(settingsSections, 'settings.general', 0) + const desktopUpdateFields = generalRaw.filter((f) => f.id === 'autoInstallUpdates') + const generalFields = generalRaw.filter((f) => f.id !== 'autoInstallUpdates') + const telemetryFields = findSettingsFields(settingsSections, 'settings.telemetry', 1) const cache = findSettingsFields(settingsSections, 'settings.cache', 2) const advanced = findSettingsFields(settingsSections, 'settings.advanced', 3) const shared = (mediaSections[0]?.fields ?? []).map(toDetailField) const modelsDirsRaw = (modelsPayload.sections[0]?.fields[0]?.value as string[] | undefined) ?? [] const modelsDefault = modelsPayload.systemDefault - const channelPickerField = - hostInstallationId ? await bindings.getChannelPickerFieldForInstall(hostInstallationId).catch(() => null) : null const appUpdateState = updater.getCurrentUpdateState() as unknown as Record const isDownloading = (appUpdateState['kind'] === 'downloading') if (!isDownloading) lastAppUpdateProgress = null const githubStars = await getGithubStarCount('comfy-org/ComfyUI').catch(() => null) return { - overviewFields: [...general, ...telemetry], + generalFields, + telemetryFields, + desktopUpdateFields, cacheFields: cache, advancedFields: advanced, sharedDirectoriesFields: shared, @@ -1787,32 +1743,21 @@ async function buildGlobalSettingsSnapshot( platform: process.platform, lastCheckedAt: globalSettingsLastCheckedAt, }, - channelPickerField, - activeInstallationId: hostInstallationId, - hasActiveInstall: !!hostInstallationId, githubUrl: GLOBAL_SETTINGS_GITHUB_URL, githubStars, - // Section titles. Section count is intentionally trimmed from six - // (Overview / Updates / Cache / Models / Advanced / Shared Dirs) - // to four — Cache + Models + Shared Dirs collapse into one - // "Storage" bucket per UX feedback, and Overview becomes - // "General" so its tone matches its contents (preferences, not - // a catch-all). Labels render English directly to avoid the - // raw-key flash that `i18n.t()` returns when no catalog entry - // exists. i18n: { - overview: 'General', - updates: 'Updates', - cache: 'Storage', - models: 'Models', - advanced: 'Advanced', - sharedDirectories: 'Shared Directories', + overview: i18n.t('settings.general'), + updates: i18n.t('settings.updatesTab'), + storage: i18n.t('settings.storageTab'), + models: i18n.t('settings.models'), + advanced: i18n.t('settings.advanced'), + sharedDirectories: i18n.t('settings.sharedDirectories'), }, } } async function broadcastGlobalSettingsSnapshotToTitlePopups( - bindings: TitlePopupHostBindings, + _bindings: TitlePopupHostBindings, ): Promise { const hasOpen = Array.from(titlePopupsByParent.values()).some( (e) => e.kind === 'global-settings' @@ -1823,9 +1768,7 @@ async function broadcastGlobalSettingsSnapshotToTitlePopups( if (entry.kind !== 'global-settings') continue if (!entry.view.isOpen && entry.view.pendingShowTimer === null) continue if (entry.view.popup.webContents.isDestroyed()) continue - const parentEntry = comfyWindows.get(entry.parentEntryId) - const hostInstallationId = parentEntry?.installationId ?? null - const snapshot = await buildGlobalSettingsSnapshot(bindings, hostInstallationId) + const snapshot = await buildGlobalSettingsSnapshot() const snapshotJson = JSON.stringify(snapshot) if (entry.lastGlobalSettingsBroadcastJson === snapshotJson) continue entry.lastGlobalSettingsBroadcastJson = snapshotJson @@ -1928,40 +1871,16 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { (event, payload: { height?: unknown }) => { const entry = titlePopupsByWebContents.get(event.sender.id) if (!entry) return - // Menu popups are sized deterministically by `computePopupHeight` - // — ignore renderer requests to avoid fighting the source of truth. - if (entry.kind !== 'downloads' && entry.kind !== 'instance-picker') return + // Menu / instance-picker / global-settings popups are sized + // deterministically from host bounds — only the downloads tray + // adapts to renderer-reported natural height. Ignore everything + // else to avoid fighting the source of truth. + if (entry.kind !== 'downloads') return const requested = payload?.height if (typeof requested !== 'number' || !Number.isFinite(requested)) return const parent = comfyWindows.get(entry.parentEntryId)?.window if (!parent || parent.isDestroyed()) return - // Instance picker routes through `computePickerBounds` — same - // function used at open, on parent resize, and on mode flip. - // We also stash the reported height on the entry so a subsequent - // parent resize can re-derive bounds without needing the renderer - // to re-measure. Skipped in expanded mode (the settings UI fills - // available height; we don't want a measured natural height to - // override the full-fit layout). - if (entry.kind === 'instance-picker') { - entry.lastReportedNaturalHeight = Math.ceil(requested) - if (entry.pickerMode === 'expanded') return - const target = computePickerBounds(parent, 'compact', entry.lastReportedNaturalHeight) - const cur = entry.view.popup.getBounds() - if ( - target.x === cur.x - && target.y === cur.y - && target.width === cur.width - && target.height === cur.height - ) return - entry.view.popup.setBounds(target) - return - } - - // Only `'downloads'` reaches this point — `'instance-picker'` - // returns above and `'global-settings'` is filtered out at the top - // of the handler (it sizes itself from host bounds, no renderer - // measurement). const contentHeight = parent.getContentBounds().height const ceiling = Math.min( DOWNLOADS_POPUP_MAX_HEIGHT_PX, @@ -2212,8 +2131,6 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { const requestedId = payload?.installationId const selectedInstallationId = typeof requestedId === 'string' && requestedId.length > 0 ? requestedId : null - const mode: 'compact' | 'expanded' = - payload?.mode === 'expanded' ? 'expanded' : 'compact' const initialTab = typeof payload?.initialTab === 'string' ? payload.initialTab : null const autoAction = @@ -2225,7 +2142,6 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { parentEntry.titleBarView.webContents, { x: 0, y: TITLEBAR_HEIGHT }, selectedInstallationId, - mode, initialTab, autoAction, ) @@ -2250,119 +2166,6 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { }, ) - // Picker → flip between compact and expanded modes. Main animates the - // popup bounds (compact card → 95dvw × 95dvh centred), then - // rebroadcasts a snapshot so the picker view re-renders the right - // pane (identity card → full per-install settings UI). - // - // Animation: 240ms cubic-bezier(0.32, 0.72, 0, 1) tween via repeated - // `popup.setBounds()` at ~60fps. Same curve the global-settings - // popup uses for its resize. Reduced-motion users get a snap (the - // renderer-side `prefers-reduced-motion` query co-handles the inner - // chrome cross-fade). - ipcMain.on( - 'comfy-titlepopup:set-picker-mode', - async ( - event, - payload: { mode?: unknown; initialTab?: unknown; autoAction?: unknown }, - ) => { - const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'instance-picker') return - if (popupEntry.view.popup.webContents.isDestroyed()) return - const mode: 'compact' | 'expanded' = - payload?.mode === 'expanded' ? 'expanded' : 'compact' - const initialTab = - typeof payload?.initialTab === 'string' ? payload.initialTab : null - const autoAction = - typeof payload?.autoAction === 'string' ? payload.autoAction : null - if (popupEntry.pickerMode === mode) { - // No-op flip — still refresh the seeded tab/auto-action so a - // second Manage click against the same install can target a - // different tab without closing first. - popupEntry.pickerInitialTab = initialTab - popupEntry.pickerAutoAction = autoAction - return - } - popupEntry.pickerMode = mode - popupEntry.pickerInitialTab = initialTab - popupEntry.pickerAutoAction = autoAction - - // Target bounds come from the same `computePickerBounds` - // function the open / parent-resize / natural-height paths use. - // Single source of truth — the morph end-state always matches - // what a freshly-opened or freshly-resized picker would land on. - const parent = popupEntry.view.parentWindow - if (parent.isDestroyed()) return - const fromBounds = popupEntry.view.popup.getBounds() - const toBounds = computePickerBounds( - parent, - mode, - popupEntry.lastReportedNaturalHeight, - ) - const toX = toBounds.x - const toY = toBounds.y - const toWidth = toBounds.width - const toHeight = toBounds.height - - const fromX = fromBounds.x - const fromY = fromBounds.y - const fromW = fromBounds.width - const fromH = fromBounds.height - - // 200ms reads as "decisive" — closer to Linear / Raycast snap feel. - // 240ms drifted into "soft" territory; the renderer-side 160ms - // cross-fade finishes just before the shell does so contents land - // settled, not mid-tween. - const DURATION_MS = 200 - const FRAME_MS = 16 - // cubic-bezier(0.32, 0.72, 0, 1) — approximated via the standard - // easeOutCubic curve. The full bezier eval is ~10 lines of code - // for a barely-visible refinement; easeOutCubic is the canonical - // shorthand and is what existing global-settings resize uses. - const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3) - - const start = Date.now() - const lerp = (a: number, b: number, t: number): number => a + (b - a) * t - - // Per-frame dedupe: every 16ms `setBounds` call hits an IPC - // boundary + a native paint, even when the eased value rounds to - // the same integer on all four axes (common on short tweens or - // small deltas). Skip those no-op frames. - let prevX = fromX - let prevY = fromY - let prevW = fromW - let prevH = fromH - - const tick = (): void => { - if (popupEntry.view.popup.webContents.isDestroyed()) return - if (popupEntry.view.parentWindow.isDestroyed()) return - const elapsed = Date.now() - start - const t = Math.min(1, elapsed / DURATION_MS) - const e = easeOutCubic(t) - const nextX = Math.round(lerp(fromX, toX, e)) - const nextY = Math.round(lerp(fromY, toY, e)) - const nextW = Math.round(lerp(fromW, toWidth, e)) - const nextH = Math.round(lerp(fromH, toHeight, e)) - if (nextX !== prevX || nextY !== prevY || nextW !== prevW || nextH !== prevH) { - popupEntry.view.popup.setBounds({ - x: nextX, - y: nextY, - width: nextW, - height: nextH, - }) - prevX = nextX - prevY = nextY - prevW = nextW - prevH = nextH - } - if (t < 1) setTimeout(tick, FRAME_MS) - } - tick() - - await broadcastInstancePickerSnapshotToTitlePopups(bindings) - }, - ) - // Picker → field update. Routes to the existing handler the drawer // uses; main rebroadcasts a fresh snapshot on success so the popup // sees the latest value without polling. Errors surface via the @@ -2585,7 +2388,10 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { 'comfy-titlepopup:global-settings-update-field', (event, payload: { fieldId?: unknown; value?: unknown }) => { const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'global-settings') { + if ( + !popupEntry + || (popupEntry.kind !== 'global-settings' && popupEntry.kind !== 'instance-picker') + ) { return { ok: false, message: 'Global Settings popup not active.' } } const fieldId = payload?.fieldId @@ -2606,7 +2412,10 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { 'comfy-titlepopup:global-settings-set-models-dirs', (event, payload: { dirs?: unknown }) => { const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'global-settings') { + if ( + !popupEntry + || (popupEntry.kind !== 'global-settings' && popupEntry.kind !== 'instance-picker') + ) { return { ok: false } } const dirs = payload?.dirs @@ -2625,7 +2434,12 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { 'comfy-titlepopup:global-settings-browse-folder', async (event, payload: { defaultPath?: unknown } | undefined) => { const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'global-settings') return null + if ( + !popupEntry + || (popupEntry.kind !== 'global-settings' && popupEntry.kind !== 'instance-picker') + ) { + return null + } const parentEntry = comfyWindows.get(popupEntry.parentEntryId) if (!parentEntry || parentEntry.window.isDestroyed()) return null const defaultPath = typeof payload?.defaultPath === 'string' && payload.defaultPath.length > 0 @@ -2645,7 +2459,12 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { 'comfy-titlepopup:global-settings-open-path', (event, payload: { path?: unknown }) => { const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'global-settings') return + if ( + !popupEntry + || (popupEntry.kind !== 'global-settings' && popupEntry.kind !== 'instance-picker') + ) { + return + } const targetPath = payload?.path if (typeof targetPath !== 'string' || targetPath.length === 0) return void openPathHelper(targetPath) @@ -2704,34 +2523,6 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { }, ) - // Install-scoped action (Update Channel cards emit these). Allowlist - // enforced here so the popup can't fire arbitrary install actions. - ipcMain.handle( - 'comfy-titlepopup:global-settings-run-install-action', - async (event, payload: { installationId?: unknown; actionId?: unknown; actionData?: unknown }) => { - const popupEntry = titlePopupsByWebContents.get(event.sender.id) - if (!popupEntry || popupEntry.kind !== 'global-settings') { - return { ok: false, message: 'Global Settings popup not active.' } - } - const installationId = payload?.installationId - const actionId = payload?.actionId - if (typeof installationId !== 'string' || typeof actionId !== 'string') { - return { ok: false, message: 'Invalid payload.' } - } - if (!GLOBAL_SETTINGS_ALLOWED_ACTIONS.has(actionId)) { - return { ok: false, message: `Action '${actionId}' is not available.` } - } - const actionData = (payload.actionData && typeof payload.actionData === 'object') - ? payload.actionData as Record - : undefined - const result = await bindings.runChannelPickerAction(installationId, actionId, actionData) - if (result.ok) { - await broadcastGlobalSettingsSnapshotToTitlePopups(bindings) - } - return result - }, - ) - // Newly-opened windows pick up live transitions automatically; initial // state for a fresh popup is pushed in `openTitlePopup`. downloadEvents.on('tray-state-changed', broadcastDownloadsToTitlePopups) @@ -2746,8 +2537,11 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { }) // Settings writes (applySettingSet) emit 'changed' — rebroadcast so // the popup sees Language / Theme / Cache / Models / etc. flip live. + // The picker piggy-backs `modelsDirs` + `sharedDirectoriesFields` on + // its own snapshot, so it gets re-broadcast on the same event. globalSettingsEvents.on('changed', () => { void broadcastGlobalSettingsSnapshotToTitlePopups(bindings) + void broadcastInstancePickerSnapshotToTitlePopups(bindings) }) // Updater state transitions repaint the Updates accordion. updater.onUpdateStateChanged(() => { diff --git a/src/main/sources/common/launchSettingsFields.ts b/src/main/sources/common/launchSettingsFields.ts index 6d2998b8..150fe02f 100644 --- a/src/main/sources/common/launchSettingsFields.ts +++ b/src/main/sources/common/launchSettingsFields.ts @@ -6,10 +6,22 @@ export interface LaunchSettingsOptions { defaultLaunchMode?: string defaultBrowserPartition?: string defaultPortConflict?: string - includeUseSharedPaths?: boolean extraFields?: Record[] } +/** Per-install `useSharedPaths` toggle. Rendered in the picker's + * Storage tab next to the global model-directory UI. Emitted by + * sources that participate in shared-model storage (desktop / + * portable); git-source installs omit this section entirely. */ +export function buildSharedPathsField(installation: InstallationRecord): Record { + return { + id: 'useSharedPaths', label: t('common.useSharedPaths'), + value: (installation.useSharedPaths as boolean | undefined) !== false, + editable: true, editType: 'boolean', tooltip: t('tooltips.useSharedPaths'), + requiresRestart: true, + } +} + export function buildLaunchSettingsFields( installation: InstallationRecord, options: LaunchSettingsOptions @@ -19,46 +31,39 @@ export function buildLaunchSettingsFields( defaultLaunchMode = 'window', defaultBrowserPartition = 'shared', defaultPortConflict = 'ask', - includeUseSharedPaths = true, extraFields = [], } = options const fields: Record[] = [] - if (includeUseSharedPaths) { - fields.push({ - id: 'useSharedPaths', label: t('common.useSharedPaths'), - value: (installation.useSharedPaths as boolean | undefined) !== false, - editable: true, editType: 'boolean', tooltip: t('tooltips.useSharedPaths'), - }) - } - fields.push( ...extraFields, { id: 'launchArgs', label: t('common.startupArgs'), value: (installation.launchArgs as string | undefined) ?? defaultLaunchArgs, - editable: true, editType: 'args-builder', tooltip: t('tooltips.startupArgs') }, + editable: true, editType: 'args-builder', tooltip: t('tooltips.startupArgs'), + requiresRestart: true }, { id: 'launchMode', label: t('common.launchMode'), value: (installation.launchMode as string | undefined) || defaultLaunchMode, editable: true, editType: 'select', options: [ { value: 'window', label: t('common.launchModeWindow') }, { value: 'console', label: t('common.launchModeConsole') }, - ] }, + ], requiresRestart: true }, { id: 'browserPartition', label: t('common.browserPartition'), value: (installation.browserPartition as string | undefined) || defaultBrowserPartition, editable: true, editType: 'select', options: [ { value: 'shared', label: t('common.partitionShared') }, { value: 'unique', label: t('common.partitionUnique') }, - ], tooltip: t('tooltips.browserPartition') }, + ], tooltip: t('tooltips.browserPartition'), requiresRestart: true }, { id: 'portConflict', label: t('common.portConflict'), value: (installation.portConflict as string | undefined) || defaultPortConflict, editable: true, editType: 'select', options: [ { value: 'ask', label: t('common.portConflictAsk') }, { value: 'auto', label: t('common.portConflictAuto') }, - ] }, + ], requiresRestart: true }, { id: 'envVars', label: t('common.envVars'), value: (installation.envVars as Record | undefined) ?? {}, - editable: true, editType: 'env-vars', tooltip: t('tooltips.envVars') }, + editable: true, editType: 'env-vars', tooltip: t('tooltips.envVars'), + requiresRestart: true }, ) return fields diff --git a/src/main/sources/git.ts b/src/main/sources/git.ts index ec392403..91280a2d 100644 --- a/src/main/sources/git.ts +++ b/src/main/sources/git.ts @@ -187,7 +187,6 @@ export const gitSource: SourcePlugin = { title: t('common.launchSettings'), fields: buildLaunchSettingsFields(installation, { defaultLaunchArgs: DEFAULT_LAUNCH_ARGS, - includeUseSharedPaths: false, extraFields: [ { id: 'venvPath', label: t('git.venv'), value: venvPath || '', editable: true, editType: 'path' }, ], diff --git a/src/main/sources/portable.ts b/src/main/sources/portable.ts index 11799b33..166f1922 100644 --- a/src/main/sources/portable.ts +++ b/src/main/sources/portable.ts @@ -11,7 +11,7 @@ import { t } from '../lib/i18n' import { fetchLatestRelease, truncateNotes } from '../lib/comfyui-releases' import { buildChannelCards, buildChannelLabelMap } from '../lib/channel-cards' import type { ChannelDef } from '../lib/channel-cards' -import { buildLaunchSettingsFields } from './common/launchSettingsFields' +import { buildLaunchSettingsFields, buildSharedPathsField } from './common/launchSettingsFields' import type { InstallationRecord } from '../installations' import type { SourcePlugin, @@ -207,6 +207,10 @@ export const portable: SourcePlugin = { title: t('common.launchSettings'), fields: buildLaunchSettingsFields(installation, { defaultLaunchArgs: DEFAULT_LAUNCH_ARGS, defaultBrowserPartition: 'unique' }), }, + { + tab: 'storage', + fields: [buildSharedPathsField(installation)], + }, { title: 'Actions', pinBottom: true, @@ -214,8 +218,8 @@ export const portable: SourcePlugin = { launchAction(installed, !installed ? t('errors.installNotReady') : undefined), openFolderAction(installation.installPath), migrateToStandaloneAction(installed), - deleteAction(installation), untrackAction(), + deleteAction(installation), ], }, ) diff --git a/src/main/sources/standalone/updateSections.ts b/src/main/sources/standalone/updateSections.ts index d1796acb..0ee7fffa 100644 --- a/src/main/sources/standalone/updateSections.ts +++ b/src/main/sources/standalone/updateSections.ts @@ -8,7 +8,7 @@ import type { ComfyVersion } from '../../lib/version' import { truncateNotes } from '../../lib/comfyui-releases' import { deleteAction, untrackAction, launchAction, openFolderAction } from '../../lib/actions' import { t } from '../../lib/i18n' -import { buildLaunchSettingsFields } from '../common/launchSettingsFields' +import { buildLaunchSettingsFields, buildSharedPathsField } from '../common/launchSettingsFields' import { getVariantLabel, DEFAULT_LAUNCH_ARGS } from './envPaths' import type { InstallationRecord } from '../../installations' import type { StatusTag } from '../../types/sources' @@ -182,6 +182,10 @@ export function getDetailSections(installation: InstallationRecord): Record ipcRenderer.send('comfy-window:open-instance-picker-for-install', { installationId: opts?.installationId ?? null, - mode: opts?.mode ?? 'compact', initialTab: opts?.initialTab ?? null, autoAction: opts?.autoAction ?? null, }), diff --git a/src/preload/comfyTitlePopupPreload.ts b/src/preload/comfyTitlePopupPreload.ts index f91c0806..97ed97f0 100644 --- a/src/preload/comfyTitlePopupPreload.ts +++ b/src/preload/comfyTitlePopupPreload.ts @@ -69,17 +69,11 @@ export interface PopupInstancePickerSnapshot { * `get-snapshots` returns. `null` when no selection or no install * path. */ selectedSnapshots: Record | null - /** Compact = default identity-card right pane. Expanded = full - * per-install settings UI in the right pane + 95dvw×95dvh popup - * bounds. Flipped by `setPickerMode` IPC. */ - mode: 'compact' | 'expanded' - /** When `mode === 'expanded'`, the tab the settings UI opens on - * ('config' | 'status' | 'update' | 'snapshots'). `null` in - * compact mode. */ + /** Tab the settings UI opens on ('config' | 'status' | 'update' | + * 'snapshots'). `null` lets the picker view choose its default. */ initialTab: string | null - /** When `mode === 'expanded'`, an action id to fire automatically - * after the settings UI mounts (kebab Update / Migrate / etc.). - * `null` once consumed. */ + /** Action id to fire automatically after the settings UI mounts + * (kebab Update / Migrate / etc.). `null` once consumed. */ autoAction: string | null } @@ -91,12 +85,13 @@ export interface PopupGlobalSettingsModelsDir { isDefault: boolean } -/** Snapshot pushed to the global-settings popup. Field arrays + the - * `channelPickerField` are loose-typed (renderer casts to `DetailField` - * on receipt) because the preload tsconfig slice can't see the - * renderer's view types. */ +/** Snapshot pushed to the global-settings popup. Field arrays are + * loose-typed (renderer casts to `DetailField` on receipt) because + * the preload tsconfig slice can't see the renderer's view types. */ export interface PopupGlobalSettingsSnapshot { - overviewFields: Record[] + generalFields: Record[] + telemetryFields: Record[] + desktopUpdateFields: Record[] cacheFields: Record[] advancedFields: Record[] sharedDirectoriesFields: Record[] @@ -111,14 +106,12 @@ export interface PopupGlobalSettingsSnapshot { platform: NodeJS.Platform lastCheckedAt: number | null } - channelPickerField: Record | null - activeInstallationId: string | null - hasActiveInstall: boolean githubUrl: string + githubStars: number | null i18n: { overview: string updates: string - cache: string + storage: string models: string advanced: string sharedDirectories: string @@ -324,14 +317,6 @@ export interface ComfyTitlePopupBridge { /** Renderer mirrors `localStorage.lastCheckedAt` back to main so the * next snapshot rebroadcast shows the freshest timestamp. */ globalSettingsSetLastCheckedAt(value: number): void - /** ChannelPicker `@action` route. The IPC handler enforces the - * allowlist (`copy-update | release-update | switch-channel | - * update`); the popup just forwards whatever the picker emits. */ - globalSettingsRunInstallAction( - installationId: string, - actionId: string, - actionData?: Record, - ): Promise<{ ok: boolean; message?: string }> // ----- Per-install (ComfyUI) settings bridge ----- // @@ -425,20 +410,10 @@ export interface ComfyTitlePopupBridge { * `diskSpace.*`, etc.). The popup merges this payload on top of its * static catalog once the expanded mode opens. */ pickerSettingsGetLocaleMessages(): Promise> - /** Flip the picker between its compact and expanded states. Main - * animates the popup bounds (compact ~720×natural → expanded - * ~95dvw×95dvh) and rebroadcasts a snapshot with `mode` set so the - * picker view re-renders the right pane (compact identity card vs. - * expanded `ComfyUISettingsContent`). */ - setPickerMode( - mode: 'compact' | 'expanded', - opts?: { initialTab?: string; autoAction?: string | null }, - ): void /** Forward a `show-progress` request from the picker's settings UI to * the parent host's panel renderer. The panel rebuilds the apiCall * closure from `actionId`/`actionData` and routes through its existing - * ProgressModal pipeline. Picker collapses to compact so the modal is - * not occluded. */ + * ProgressModal pipeline. */ pickerForwardShowProgress(payload: { installationId: string actionId: string @@ -477,7 +452,9 @@ function isPopupConfig(value: unknown): value is TitlePopupConfig { function isGlobalSettingsSnapshot(value: unknown): value is PopupGlobalSettingsSnapshot { if (!value || typeof value !== 'object') return false const v = value as Record - if (!Array.isArray(v['overviewFields'])) return false + if (!Array.isArray(v['generalFields'])) return false + if (!Array.isArray(v['telemetryFields'])) return false + if (!Array.isArray(v['desktopUpdateFields'])) return false if (!Array.isArray(v['cacheFields'])) return false if (!Array.isArray(v['advancedFields'])) return false if (!Array.isArray(v['sharedDirectoriesFields'])) return false @@ -526,10 +503,6 @@ function isInstancePickerSnapshot(value: unknown): value is PopupInstancePickerS && v.selectedSnapshots !== null && typeof v.selectedSnapshots !== 'object' ) return false - // Mode fields are also optional on the wire — old main builds that - // don't yet emit them still validate. Default to compact in the - // consumer when missing. - if (v.mode !== undefined && v.mode !== 'compact' && v.mode !== 'expanded') return false if ( v.initialTab !== undefined && v.initialTab !== null @@ -665,12 +638,6 @@ const bridge: ComfyTitlePopupBridge = { globalSettingsSetLastCheckedAt: (value) => { ipcRenderer.send('comfy-titlepopup:global-settings-set-last-checked', { value }) }, - globalSettingsRunInstallAction: (installationId, actionId, actionData) => - ipcRenderer.invoke('comfy-titlepopup:global-settings-run-install-action', { - installationId, - actionId, - actionData, - }), // Per-install settings (picker expanded Manage). Each handler is a 1:1 // pass-through to the main-side IPC. Channels namespaced `comfy-titlepopup:*` // so they don't collide with the panel's `window.api` IPCs. @@ -718,13 +685,6 @@ const bridge: ComfyTitlePopupBridge = { ipcRenderer.send(CH.relaunchApp) }, pickerSettingsGetLocaleMessages: () => ipcRenderer.invoke(CH.getLocaleMessages), - setPickerMode: (mode, opts) => { - ipcRenderer.send('comfy-titlepopup:set-picker-mode', { - mode, - initialTab: opts?.initialTab, - autoAction: opts?.autoAction ?? null, - }) - }, pickerForwardShowProgress: (payload) => { ipcRenderer.send('comfy-titlepopup:forward-show-progress', payload) }, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 491f4ff5..a49a21d1 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -46,7 +46,8 @@ --color-surface: #262729; --color-border: #494a50; --color-border-hover: #3c3d42; - --color-danger: #b33a3a; + --color-danger: #e05858; + --color-danger-hover: #ef6b6b; --color-terminal-bg: #111112; /* Comfy brand neutral ramp — single canonical scale. Yellow at 50, @@ -85,7 +86,8 @@ --titlebar-icon: var(--color-text-muted); --border: #494a50; --border-hover: #3c3d42; - --danger: #b33a3a; + --danger: #e05858; + --danger-hover: #ef6b6b; --warning: #fd9903; --success: #00cd72; --info: #58a6ff; @@ -150,6 +152,12 @@ 0 20px 24px -4px rgba(10, 13, 18, 0.4), 0 8px 8px -4px rgba(10, 13, 18, 0.25), 0 3px 3px -1.5px rgba(10, 13, 18, 0.2); + /* Tooltip primitive */ + --tooltip-bg: #211927; + --tooltip-border: #38303d; + --tooltip-fg: #ffffff; + --z-tooltip: 10001; + /* Takeover fluid type + spacing — Utopia clamp, anchored 1024px → 1920px. */ --takeover-fs-caption: clamp(0.75rem, 0.6071rem + 0.2232vw, 0.875rem); /* 12→14px */ --takeover-fs-body: clamp(0.875rem, 0.7321rem + 0.2232vw, 1rem); /* 14→16px */ @@ -179,6 +187,7 @@ --border: #b4b4b4; --border-hover: #d9d9d9; --danger: #f75951; + --danger-hover: #ff7a73; --warning: #fcbf64; --success: #00cd72; --info: #2563eb; @@ -229,6 +238,12 @@ --modal-surface-bg: #27202d; --modal-surface-border: var(--chooser-surface-border); + /* Tooltip primitive — mirror of dark-theme block until light parity. */ + --tooltip-bg: #211927; + --tooltip-border: #38303d; + --tooltip-fg: #ffffff; + --z-tooltip: 10001; + /* Takeover fluid type + spacing — mirror of dark-theme block. */ --takeover-fs-caption: clamp(0.75rem, 0.6071rem + 0.2232vw, 0.875rem); --takeover-fs-body: clamp(0.875rem, 0.7321rem + 0.2232vw, 1rem); @@ -727,8 +742,8 @@ button.brand-ghost:hover:not([disabled]) { .brand-checkbox input[type='checkbox'] { appearance: none; flex: 0 0 auto; - width: 18px; - height: 18px; + width: 14px; + height: 14px; margin: 4px 0 0 0; border: 1px solid var(--brand-surface-border-hover); border-radius: 4px; diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 5fefaa4f..658399f6 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -8,7 +8,7 @@ import { Loader2, Menu as MenuIcon, MessageSquarePlus, - RefreshCw, + RefreshCw } from 'lucide-vue-next' import { useTitleBarTooltip } from './useTitleBarTooltip' import { useTitleBarMenus } from './useTitleBarMenus' @@ -23,12 +23,7 @@ const { t } = useI18n() // file isn't visible to tsconfig.web (only its .d.ts would be). Kept in // sync with the literal union in src/preload/comfyTitleBarPreload.ts and // the ComfyPanelKey export in src/main/index.ts. -type ComfyPanelKey = - | 'comfy' - | 'new-install' - | 'track' - | 'load-snapshot' - | 'quick-install' +type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' /** Position passed to main so the native menu pops below the anchor button. * Coordinates are in title-bar-local pixels — main translates to window @@ -436,10 +431,7 @@ onUnmounted(() => {