From 68b16e3a3f6a565010afaaaff90f927f7b439284 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:35:36 +0000 Subject: [PATCH] feat: App mode saving rework (#9338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Change app mode changes to be written directly to the workflow on change instead of requiring explicit save via builder. Temporary: Adds `.app.json` file extension to app files for identification since we don't currently have a way to identify them with metadata Removes app builder save dialog and replaces it with default mode selection ## Changes - **What**: - ensure all save locations handle app mode - remove dirtyLinearData and flushing - **Breaking**: - if people are relying on workflow names and are converting to/from app mode in the same workflow, they will gain/lose the `.app` part of the extension ## Screenshots (if applicable) image image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9338-feat-App-mode-saving-rework-3176d73d3650813f9ae1f6c5a234da8c) by [Unito](https://www.unito.io) --- .../src/formatUtil.test.ts | 145 ++++++++++++ .../shared-frontend-utils/src/formatUtil.ts | 59 ++++- .../breadcrumb/SubgraphBreadcrumbItem.vue | 5 +- src/components/builder/BuilderMenu.vue | 21 +- .../BuilderSaveSuccessDialogContent.vue | 51 ----- src/components/builder/BuilderToolbar.vue | 42 ++-- ...ntent.vue => DefaultViewDialogContent.vue} | 54 ++--- .../builder/useAppSetDefaultView.test.ts | 146 ++++++++++++ .../builder/useAppSetDefaultView.ts | 47 ++++ src/components/builder/useBuilderSave.ts | 134 ----------- .../sidebar/tabs/WorkflowsSidebarTab.vue | 7 +- src/composables/useCoreCommands.ts | 5 +- src/locales/en/main.json | 20 +- .../core/services/workflowService.test.ts | 216 ++++++++++++++++-- .../workflow/core/services/workflowService.ts | 61 +++-- .../management/stores/comfyWorkflow.ts | 31 --- src/stores/appModeStore.test.ts | 159 +++++++++---- src/stores/appModeStore.ts | 45 ++-- 18 files changed, 842 insertions(+), 406 deletions(-) delete mode 100644 src/components/builder/BuilderSaveSuccessDialogContent.vue rename src/components/builder/{BuilderSaveDialogContent.vue => DefaultViewDialogContent.vue} (62%) create mode 100644 src/components/builder/useAppSetDefaultView.test.ts create mode 100644 src/components/builder/useAppSetDefaultView.ts delete mode 100644 src/components/builder/useBuilderSave.ts diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index e9748b4ca0..d494126db5 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import { + appendWorkflowJsonExt, + ensureWorkflowSuffix, + getFilenameDetails, getMediaTypeFromFilename, + getPathDetails, highlightQuery, isPreviewableMediaType, truncateFilename @@ -198,6 +202,147 @@ describe('formatUtil', () => { }) }) + describe('getFilenameDetails', () => { + it('splits simple filenames into name and suffix', () => { + expect(getFilenameDetails('file.txt')).toEqual({ + filename: 'file', + suffix: 'txt' + }) + }) + + it('handles filenames with multiple dots', () => { + expect(getFilenameDetails('my.file.name.png')).toEqual({ + filename: 'my.file.name', + suffix: 'png' + }) + }) + + it('handles filenames without extension', () => { + expect(getFilenameDetails('README')).toEqual({ + filename: 'README', + suffix: null + }) + }) + + it('recognises .app.json as a compound extension', () => { + expect(getFilenameDetails('workflow.app.json')).toEqual({ + filename: 'workflow', + suffix: 'app.json' + }) + }) + + it('recognises .app.json case-insensitively', () => { + expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({ + filename: 'Workflow', + suffix: 'app.json' + }) + }) + + it('handles regular .json files normally', () => { + expect(getFilenameDetails('workflow.json')).toEqual({ + filename: 'workflow', + suffix: 'json' + }) + }) + + it('treats bare .app.json as a dotfile without basename', () => { + expect(getFilenameDetails('.app.json')).toEqual({ + filename: '.app', + suffix: 'json' + }) + }) + }) + + describe('getPathDetails', () => { + it('splits a path with .app.json extension', () => { + const result = getPathDetails('workflows/test.app.json') + expect(result).toEqual({ + directory: 'workflows', + fullFilename: 'test.app.json', + filename: 'test', + suffix: 'app.json' + }) + }) + + it('splits a path with .json extension', () => { + const result = getPathDetails('workflows/test.json') + expect(result).toEqual({ + directory: 'workflows', + fullFilename: 'test.json', + filename: 'test', + suffix: 'json' + }) + }) + }) + + describe('appendWorkflowJsonExt', () => { + it('appends .app.json when isApp is true', () => { + expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json') + }) + + it('appends .json when isApp is false', () => { + expect(appendWorkflowJsonExt('test', false)).toBe('test.json') + }) + + it('replaces .json with .app.json when isApp is true', () => { + expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json') + }) + + it('replaces .app.json with .json when isApp is false', () => { + expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json') + }) + + it('leaves .app.json unchanged when isApp is true', () => { + expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json') + }) + + it('leaves .json unchanged when isApp is false', () => { + expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json') + }) + + it('handles case-insensitive extensions', () => { + expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json') + expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json') + }) + }) + + describe('ensureWorkflowSuffix', () => { + it('appends suffix when missing', () => { + expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json') + }) + + it('does not double-append when suffix already present', () => { + expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json') + }) + + it('appends compound suffix when missing', () => { + expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json') + }) + + it('does not double-append compound suffix', () => { + expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe( + 'file.app.json' + ) + }) + + it('replaces .json with .app.json when suffix is app.json', () => { + expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe( + 'file.app.json' + ) + }) + + it('replaces .app.json with .json when suffix is json', () => { + expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json') + }) + + it('handles case-insensitive extension detection', () => { + expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json') + expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe( + 'file.app.json' + ) + }) + }) + describe('isPreviewableMediaType', () => { it('returns true for image/video/audio/3D', () => { expect(isPreviewableMediaType('image')).toBe(true) diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index bb9d2ff18e..74845409fa 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -26,13 +26,44 @@ export function formatCamelCase(str: string): string { return processedWords.join(' ') } +// Metadata cannot be associated with workflows, so extension encodes the mode. +const JSON_SUFFIX = 'json' +const APP_JSON_SUFFIX = `app.${JSON_SUFFIX}` +const JSON_EXT = `.${JSON_SUFFIX}` +const APP_JSON_EXT = `.${APP_JSON_SUFFIX}` + export function appendJsonExt(path: string) { - if (!path.toLowerCase().endsWith('.json')) { - path += '.json' + if (!path.toLowerCase().endsWith(JSON_EXT)) { + path += JSON_EXT } return path } +export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX + +export function getWorkflowSuffix( + suffix: string | null | undefined +): WorkflowSuffix { + return suffix === APP_JSON_SUFFIX ? APP_JSON_SUFFIX : JSON_SUFFIX +} + +export function appendWorkflowJsonExt(path: string, isApp: boolean): string { + return ensureWorkflowSuffix(path, isApp ? APP_JSON_SUFFIX : JSON_SUFFIX) +} + +export function ensureWorkflowSuffix( + name: string, + suffix: WorkflowSuffix +): string { + const lower = name.toLowerCase() + if (lower.endsWith(APP_JSON_EXT)) { + name = name.slice(0, -APP_JSON_EXT.length) + } else if (lower.endsWith(JSON_EXT)) { + name = name.slice(0, -JSON_EXT.length) + } + return name + '.' + suffix +} + export function highlightQuery( text: string, query: string, @@ -96,19 +127,27 @@ export function formatCommitHash(value: string): string { /** * Returns various filename components. + * Recognises compound extensions like `.app.json`. * Example: - * - fullFilename: 'file.txt' - * - filename: 'file' - * - suffix: 'txt' + * - fullFilename: 'file.txt' → { filename: 'file', suffix: 'txt' } + * - fullFilename: 'file.app.json' → { filename: 'file', suffix: 'app.json' } */ export function getFilenameDetails(fullFilename: string) { - if (fullFilename.includes('.')) { + const lower = fullFilename.toLowerCase() + if ( + lower.endsWith(APP_JSON_EXT) && + fullFilename.length > APP_JSON_EXT.length + ) { return { - filename: fullFilename.split('.').slice(0, -1).join('.'), - suffix: fullFilename.split('.').pop() ?? null + filename: fullFilename.slice(0, -APP_JSON_EXT.length), + suffix: APP_JSON_SUFFIX } - } else { - return { filename: fullFilename, suffix: null } + } + const dotIndex = fullFilename.lastIndexOf('.') + if (dotIndex <= 0) return { filename: fullFilename, suffix: null } + return { + filename: fullFilename.slice(0, dotIndex), + suffix: fullFilename.slice(dotIndex + 1) } } diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue index 2e22c1424e..d7b88b4a08 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue @@ -60,6 +60,7 @@ import { computed, nextTick, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu' +import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { ComfyWorkflow, @@ -70,7 +71,6 @@ import { useDialogService } from '@/services/dialogService' import { useCommandStore } from '@/stores/commandStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' -import { appendJsonExt } from '@/utils/formatUtil' import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes' interface Props { @@ -107,9 +107,10 @@ const rename = async ( workflowStore.activeSubgraph.name = newName } else if (workflowStore.activeWorkflow) { try { + const suffix = getWorkflowSuffix(workflowStore.activeWorkflow.suffix) await workflowService.renameWorkflow( workflowStore.activeWorkflow, - ComfyWorkflow.basePath + appendJsonExt(newName) + ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix) ) } catch (error) { console.error(error) diff --git a/src/components/builder/BuilderMenu.vue b/src/components/builder/BuilderMenu.vue index cd9e606f6f..7d33cc7a10 100644 --- a/src/components/builder/BuilderMenu.vue +++ b/src/components/builder/BuilderMenu.vue @@ -51,19 +51,28 @@ import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import Popover from '@/components/ui/Popover.vue' +import { useErrorHandling } from '@/composables/useErrorHandling' +import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useAppModeStore } from '@/stores/appModeStore' import { cn } from '@/utils/tailwindUtil' -import { useBuilderSave } from './useBuilderSave' - const { t } = useI18n() const appModeStore = useAppModeStore() const { hasOutputs } = storeToRefs(appModeStore) -const { setSaving } = useBuilderSave() +const workflowService = useWorkflowService() +const workflowStore = useWorkflowStore() +const { toastErrorHandler } = useErrorHandling() -function onSave(close: () => void) { - setSaving(true) - close() +async function onSave(close: () => void) { + const workflow = workflowStore.activeWorkflow + if (!workflow) return + try { + await workflowService.saveWorkflow(workflow) + close() + } catch (error) { + toastErrorHandler(error) + } } function onExitBuilder(close: () => void) { diff --git a/src/components/builder/BuilderSaveSuccessDialogContent.vue b/src/components/builder/BuilderSaveSuccessDialogContent.vue deleted file mode 100644 index 81975a9ba3..0000000000 --- a/src/components/builder/BuilderSaveSuccessDialogContent.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/src/components/builder/BuilderToolbar.vue b/src/components/builder/BuilderToolbar.vue index 48a6c16e66..b94efeccef 100644 --- a/src/components/builder/BuilderToolbar.vue +++ b/src/components/builder/BuilderToolbar.vue @@ -29,15 +29,19 @@ diff --git a/src/components/builder/BuilderSaveDialogContent.vue b/src/components/builder/DefaultViewDialogContent.vue similarity index 62% rename from src/components/builder/BuilderSaveDialogContent.vue rename to src/components/builder/DefaultViewDialogContent.vue index af7fb2d730..ebbe612658 100644 --- a/src/components/builder/BuilderSaveDialogContent.vue +++ b/src/components/builder/DefaultViewDialogContent.vue @@ -1,32 +1,16 @@