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:
pythongosssss
2026-03-03 19:35:36 +00:00
committed by GitHub
parent ab2aaa3852
commit 68b16e3a3f
18 changed files with 842 additions and 406 deletions

View File

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

View File

@@ -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()
}

View File

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