mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-08 06:30:04 +00:00
feat: App mode saving rework (#9338)
## 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) <img width="689" height="84" alt="image" src="https://github.com/user-attachments/assets/335596ee-dce9-4e3a-a7b5-f0715c294e41" /> <img width="421" height="324" alt="image" src="https://github.com/user-attachments/assets/ad3cd33c-e9f0-4c30-8874-d4507892fc6b" /> ┆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)
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -55,10 +56,13 @@ function makeWorkflowData(
|
||||
}
|
||||
}
|
||||
|
||||
const { mockShowMissingNodes, mockShowMissingModels } = vi.hoisted(() => ({
|
||||
mockShowMissingNodes: vi.fn(),
|
||||
mockShowMissingModels: vi.fn()
|
||||
}))
|
||||
const { mockShowMissingNodes, mockShowMissingModels, mockConfirm } = vi.hoisted(
|
||||
() => ({
|
||||
mockShowMissingNodes: vi.fn(),
|
||||
mockShowMissingModels: vi.fn(),
|
||||
mockConfirm: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useMissingNodesDialog', () => ({
|
||||
useMissingNodesDialog: () => ({ show: mockShowMissingNodes, hide: vi.fn() })
|
||||
@@ -71,7 +75,7 @@ vi.mock('@/composables/useMissingModelsDialog', () => ({
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
prompt: vi.fn(),
|
||||
confirm: vi.fn()
|
||||
confirm: mockConfirm
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -463,17 +467,6 @@ describe('useWorkflowService', () => {
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
})
|
||||
|
||||
it('syncs linearMode to rootGraph.extra for draft persistence', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
await service.afterLoadNewGraph(
|
||||
workflow,
|
||||
makeWorkflowData({ linearMode: true })
|
||||
)
|
||||
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('reads initialMode from file when draft lacks linearMode (restoration)', async () => {
|
||||
const filePath = 'workflows/saved-app.json'
|
||||
const fileInitialState = makeWorkflowData({ linearMode: true })
|
||||
@@ -507,7 +500,6 @@ describe('useWorkflowService', () => {
|
||||
|
||||
// initialMode should come from the file, not the draft
|
||||
expect(persistedWorkflow.initialMode).toBe('app')
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -533,4 +525,194 @@ describe('useWorkflowService', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflowAs', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let service: ReturnType<typeof useWorkflowService>
|
||||
|
||||
beforeEach(() => {
|
||||
workflowStore = useWorkflowStore()
|
||||
service = useWorkflowService()
|
||||
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
|
||||
})
|
||||
|
||||
function createTemporaryWorkflow(
|
||||
directory: string = 'workflows'
|
||||
): LoadedComfyWorkflow {
|
||||
const workflow = new ComfyWorkflowClass({
|
||||
path: directory + '/temp.json',
|
||||
modified: Date.now(),
|
||||
size: 100
|
||||
})
|
||||
workflow.changeTracker = createMockChangeTracker()
|
||||
workflow.content = '{}'
|
||||
workflow.originalContent = '{}'
|
||||
Object.defineProperty(workflow, 'isTemporary', { get: () => true })
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
it('appends .app.json extension when initialMode is app', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflowAs(workflow, { filename: 'my-workflow' })
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.app.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends .json extension when initialMode is graph', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
workflow.initialMode = 'graph'
|
||||
|
||||
await service.saveWorkflowAs(workflow, { filename: 'my-workflow' })
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends .json extension when initialMode is not set', async () => {
|
||||
const workflow = createTemporaryWorkflow()
|
||||
|
||||
await service.saveWorkflowAs(workflow, { filename: 'my-workflow' })
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/my-workflow.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWorkflow', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let toastStore: ReturnType<typeof useToastStore>
|
||||
let service: ReturnType<typeof useWorkflowService>
|
||||
|
||||
beforeEach(() => {
|
||||
workflowStore = useWorkflowStore()
|
||||
toastStore = useToastStore()
|
||||
service = useWorkflowService()
|
||||
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
|
||||
})
|
||||
|
||||
function createSaveableWorkflow(path: string): LoadedComfyWorkflow {
|
||||
const workflow = new ComfyWorkflowClass({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: 100
|
||||
})
|
||||
workflow.changeTracker = createMockChangeTracker()
|
||||
workflow.content = '{}'
|
||||
workflow.originalContent = '{}'
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
it('renames .json to .app.json when initialMode is app', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/test.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('renames .app.json to .json when initialMode is graph', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.app.json')
|
||||
workflow.initialMode = 'graph'
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/test.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('does not rename when extension already matches', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.app.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(workflowStore.renameWorkflow).not.toHaveBeenCalled()
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('shows toast only when rename occurs', async () => {
|
||||
const addSpy = vi.spyOn(toastStore, 'add')
|
||||
|
||||
const workflow = createSaveableWorkflow('workflows/test.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'info' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show toast when no rename occurs', async () => {
|
||||
const addSpy = vi.spyOn(toastStore, 'add')
|
||||
|
||||
const workflow = createSaveableWorkflow('workflows/test.app.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(addSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rename when initialMode is not set', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.json')
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(workflowStore.renameWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for overwrite when target path already exists', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
const existing = createSaveableWorkflow('workflows/test.app.json')
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(existing)
|
||||
vi.spyOn(workflowStore, 'deleteWorkflow').mockResolvedValue()
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled()
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/test.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
|
||||
it('saves without renaming when user declines overwrite', async () => {
|
||||
const workflow = createSaveableWorkflow('workflows/test.json')
|
||||
workflow.initialMode = 'app'
|
||||
|
||||
const existing = createSaveableWorkflow('workflows/test.app.json')
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(existing)
|
||||
mockConfirm.mockResolvedValue(false)
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled()
|
||||
expect(workflowStore.renameWorkflow).not.toHaveBeenCalled()
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(workflow)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { syncLinearMode } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
@@ -25,7 +24,7 @@ import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { appendJsonExt, appendWorkflowJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
function linearModeToAppMode(linearMode: unknown): AppMode | null {
|
||||
if (typeof linearMode !== 'boolean') return null
|
||||
@@ -44,6 +43,15 @@ export const useWorkflowService = () => {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
|
||||
function confirmOverwrite(targetPath: string) {
|
||||
return dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
|
||||
type: 'overwrite',
|
||||
message: t('sideToolbar.workflowTab.confirmOverwrite'),
|
||||
itemList: [targetPath]
|
||||
})
|
||||
}
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
let filename = await dialogService.prompt({
|
||||
@@ -103,26 +111,21 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const saveWorkflowAs = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { filename?: string; initialMode?: AppMode } = {}
|
||||
options: { filename?: string } = {}
|
||||
): Promise<boolean> => {
|
||||
const newFilename = options.filename ?? (await workflow.promptSave())
|
||||
if (!newFilename) return false
|
||||
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const newPath =
|
||||
workflow.directory + '/' + appendWorkflowJsonExt(newFilename, isApp)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
|
||||
const isSelfOverwrite =
|
||||
existingWorkflow?.path === workflow.path && !existingWorkflow?.isTemporary
|
||||
|
||||
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
||||
const res = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
|
||||
type: 'overwrite',
|
||||
message: t('sideToolbar.workflowTab.confirmOverwrite'),
|
||||
itemList: [newPath]
|
||||
})
|
||||
|
||||
if (res !== true) return false
|
||||
if ((await confirmOverwrite(newPath)) !== true) return false
|
||||
|
||||
if (!isSelfOverwrite) {
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
@@ -130,9 +133,6 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.initialMode) workflow.initialMode = options.initialMode
|
||||
|
||||
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
@@ -156,8 +156,34 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
workflow.directory +
|
||||
'/' +
|
||||
appendWorkflowJsonExt(workflow.filename, isApp)
|
||||
if (workflow.path !== expectedPath) {
|
||||
const existing = workflowStore.getWorkflowByPath(expectedPath)
|
||||
if (existing && !existing.isTemporary) {
|
||||
if ((await confirmOverwrite(expectedPath)) !== true) {
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
return
|
||||
}
|
||||
await deleteWorkflow(existing, true)
|
||||
}
|
||||
await renameWorkflow(workflow, expectedPath)
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t(
|
||||
isApp
|
||||
? 'workflowService.savedAsApp'
|
||||
: 'workflowService.savedAsWorkflow'
|
||||
),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
@@ -404,7 +430,6 @@ export const useWorkflowService = () => {
|
||||
) ?? freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
return
|
||||
@@ -417,7 +442,6 @@ export const useWorkflowService = () => {
|
||||
)
|
||||
tempWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(tempWorkflow)
|
||||
syncLinearMode(tempWorkflow, [workflowData, app.rootGraph])
|
||||
await workflowStore.openWorkflow(tempWorkflow)
|
||||
return
|
||||
}
|
||||
@@ -427,7 +451,6 @@ export const useWorkflowService = () => {
|
||||
loadedWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
}
|
||||
|
||||
@@ -24,31 +24,6 @@ export interface PendingWarnings {
|
||||
}
|
||||
}
|
||||
|
||||
type LinearModeTarget = { extra?: Record<string, unknown> | null } | null
|
||||
|
||||
export function syncLinearMode(
|
||||
workflow: ComfyWorkflow,
|
||||
targets: LinearModeTarget[],
|
||||
options?: { flushLinearData?: boolean }
|
||||
): void {
|
||||
for (const target of targets) {
|
||||
if (!target) continue
|
||||
if (workflow.initialMode === 'app' || workflow.initialMode === 'graph') {
|
||||
const extra = (target.extra ??= {})
|
||||
extra.linearMode = workflow.initialMode === 'app'
|
||||
} else {
|
||||
delete target.extra?.linearMode
|
||||
}
|
||||
if (options?.flushLinearData && workflow.dirtyLinearData) {
|
||||
const extra = (target.extra ??= {})
|
||||
extra.linearData = workflow.dirtyLinearData
|
||||
}
|
||||
}
|
||||
if (options?.flushLinearData && workflow.dirtyLinearData) {
|
||||
workflow.dirtyLinearData = null
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
@@ -77,12 +52,6 @@ export class ComfyWorkflow extends UserFile {
|
||||
* Takes precedence over initialMode when present.
|
||||
*/
|
||||
activeMode: AppMode | null = null
|
||||
/**
|
||||
* In-progress builder selections not yet persisted via save.
|
||||
* Preserved across tab switches, discarded on exitBuilder.
|
||||
*/
|
||||
dirtyLinearData: LinearData | null = null
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
|
||||
Reference in New Issue
Block a user