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 @@