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 @@
-
-
- {{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
-
- {{ $t('builderToolbar.saveSuccessAppPrompt') }}
-
- {{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
-