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

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
<template>
<BuilderDialog @close="onClose">
<template #header-icon>
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
</template>
<template #title>
{{ $t('builderToolbar.saveSuccess') }}
</template>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
</p>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
</p>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
</p>
<template #footer>
<Button
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
size="lg"
@click="onClose"
>
{{ $t('g.close') }}
</Button>
<Button
v-if="savedAsApp && onViewApp"
variant="primary"
size="lg"
@click="onViewApp"
>
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
workflowName: string
savedAsApp: boolean
onViewApp?: () => void
onClose: () => void
}>()
</script>

View File

@@ -29,15 +29,19 @@
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Save -->
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
<StepBadge
:step="defaultViewStep"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</ConnectOutputPopover>
<button
@@ -45,21 +49,26 @@
:class="
cn(
stepClasses,
activeStep === 'save'
activeStep === 'setDefaultView'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="setSaving(true)"
@click="showDialog()"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
<StepBadge
:step="defaultViewStep"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -68,19 +77,20 @@ import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const { t } = useI18n()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
const activeStep = computed(() =>
settingView.value ? 'setDefaultView' : mode.value
)
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
@@ -99,10 +109,10 @@ const arrangeStep: BuilderToolbarStep<AppMode> = {
icon: 'icon-[lucide--layout-panel-left]'
}
const saveStep: BuilderToolbarStep<'save'> = {
id: 'save',
title: t('builderToolbar.save'),
subtitle: t('builderToolbar.saveDescription'),
icon: 'icon-[lucide--cloud-upload]'
const defaultViewStep: BuilderToolbarStep<'setDefaultView'> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
</script>

View File

@@ -1,32 +1,16 @@
<template>
<BuilderDialog @close="onClose">
<BuilderDialog @close="$emit('close')">
<template #title>
{{ $t('builderToolbar.saveAs') }}
{{ $t('builderToolbar.defaultViewTitle') }}
</template>
<!-- Filename -->
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
/>
</div>
<!-- Save as type -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.saveAsLabel') }}
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in saveTypeOptions"
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
@@ -61,23 +45,18 @@
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="onClose">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="onSave(filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref, useId } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -87,17 +66,18 @@ import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { defaultFilename, onSave, onClose } = defineProps<{
defaultFilename: string
onSave: (filename: string, openAsApp: boolean) => void
onClose: () => void
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
}>()
const inputId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(true)
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const saveTypeOptions = [
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',

View File

@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as { initialMode?: string | null } | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
})
})

View File

@@ -0,0 +1,47 @@
import { computed } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
const DIALOG_KEY = 'builder-default-view'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
closeDialog()
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -1,134 +0,0 @@
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
resetSaving()
}
return
}
showSaveDialog(workflow.filename)
}
function showSaveDialog(defaultFilename: string) {
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename,
onSave: handleSave,
onClose: handleCancelSave
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function handleCancelSave() {
closeSaveDialog()
resetSaving()
}
async function handleSave(filename: string, openAsApp: boolean) {
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function closeSaveDialog() {
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
}
function closeSuccessDialog() {
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
resetSaving()
}
function resetSaving() {
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -161,7 +161,7 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const settingStore = useSettingStore()
@@ -256,10 +256,11 @@ const renderTreeNode = (
? {
handleClick,
async handleRename(newName: string) {
const suffix = getWorkflowSuffix(workflow.suffix)
const newPath =
type === WorkflowTreeType.Browse
? workflow.directory + '/' + appendJsonExt(newName)
: ComfyWorkflow.basePath + appendJsonExt(newName)
? workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
: ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
await workflowService.renameWorkflow(workflow, newPath)
},

View File

@@ -53,6 +53,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes
@@ -207,7 +208,9 @@ export function useCoreCommands(): ComfyCommand[] {
})
if (!newName || newName === workflow.filename) return
const newPath = workflow.directory + '/' + newName + '.json'
const suffix = getWorkflowSuffix(workflow.suffix)
const newPath =
workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
await workflowService.renameWorkflow(workflow, newPath)
}
},

View File

@@ -1075,7 +1075,9 @@
"exportWorkflow": "Export Workflow",
"enterFilename": "Enter the filename",
"enterFilenamePrompt": "Enter the filename:",
"saveWorkflow": "Save workflow"
"saveWorkflow": "Save workflow",
"savedAsApp": "Converted to app workflow",
"savedAsWorkflow": "Converted to node graph only workflow"
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
@@ -3352,24 +3354,18 @@
"selectDescription": "Choose inputs/outputs",
"arrange": "Preview",
"arrangeDescription": "Review app layout",
"save": "Save",
"saveDescription": "Save and finish",
"defaultView": "Set a default view",
"defaultViewDescription": "Choose how this opens",
"connectOutput": "Connect an output",
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
"connectOutputBody2": "Switch to the 'Select' step and click on output nodes to add them here.",
"switchToSelect": "Switch to Select",
"saveAs": "Save as",
"filename": "Filename",
"saveAsLabel": "Save this workflow as a ...",
"defaultViewTitle": "Set the default view for this workflow",
"defaultViewLabel": "By default, this workflow will open as:",
"app": "App",
"appDescription": "Opens as an app by default",
"nodeGraph": "Node graph",
"nodeGraphDescription": "Opens as node graph by default",
"saveSuccess": "Successfully saved",
"saveSuccessAppMessage": "'{name}' has been saved. It will open in App Mode by default from now on.",
"saveSuccessAppPrompt": "Would you like to view it now?",
"saveSuccessGraphMessage": "'{name}' has been saved. It will open as a node graph by default.",
"viewApp": "View app"
"nodeGraphDescription": "Opens as node graph by default"
},
"builderMenu": {
"exitAppBuilder": "Exit app builder"

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.

View File

@@ -1,32 +1,18 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { AppMode } from '@/composables/useAppMode'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import { useAppModeStore } from '@/stores/appModeStore'
const modeRef = ref<AppMode>('graph')
const mockSetMode = vi.fn()
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: computed(() => modeRef.value),
setMode: mockSetMode,
isBuilderMode: computed(
() =>
modeRef.value === 'builder:select' ||
modeRef.value === 'builder:arrange'
),
isAppMode: computed(
() => modeRef.value === 'app' || modeRef.value === 'builder:arrange'
),
isSelectMode: computed(() => modeRef.value === 'builder:select'),
isArrangeMode: computed(() => modeRef.value === 'builder:arrange'),
isGraphMode: computed(
() => modeRef.value === 'graph' || modeRef.value === 'builder:select'
)
})
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {} }
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -35,56 +21,143 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
import { useAppModeStore } from './appModeStore'
function createBuilderWorkflow(
activeMode: string = 'builder:select'
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path: 'workflows/test.json',
modified: Date.now(),
size: 100
})
}))
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
workflow.activeMode = activeMode as LoadedComfyWorkflow['activeMode']
return workflow as LoadedComfyWorkflow
}
describe('appModeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
modeRef.value = 'graph'
mockSetMode.mockClear()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(app.rootGraph).extra = {}
})
describe('enterBuilder', () => {
it('navigates to builder:arrange when in app mode with outputs', () => {
modeRef.value = 'app'
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('app')
const store = useAppModeStore()
store.selectedOutputs.push('1')
store.selectedOutputs.push(1)
store.enterBuilder()
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
})
it('navigates to builder:select when in app mode without outputs', () => {
modeRef.value = 'app'
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('app')
const store = useAppModeStore()
store.enterBuilder()
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
it('navigates to builder:select when in graph mode with outputs', () => {
modeRef.value = 'graph'
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
const store = useAppModeStore()
store.selectedOutputs.push('1')
store.selectedOutputs.push(1)
store.enterBuilder()
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
it('navigates to builder:select when in graph mode without outputs', () => {
modeRef.value = 'graph'
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
const store = useAppModeStore()
store.enterBuilder()
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
})
describe('linearData sync watcher', () => {
it('writes linearData to rootGraph.extra when in builder mode', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.selectedOutputs.push(1)
await nextTick()
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [],
outputs: [1]
})
})
it('does not write linearData when not in builder mode', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
const workflow = createBuilderWorkflow()
workflow.activeMode = 'graph'
workflowStore.activeWorkflow = workflow
await nextTick()
store.selectedOutputs.push(1)
await nextTick()
expect(app.rootGraph.extra.linearData).toBeUndefined()
})
it('does not write when rootGraph is null', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
const originalRootGraph = app.rootGraph
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
store.selectedOutputs.push(1)
await nextTick()
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
})
it('reflects input changes in linearData', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [[42, 'prompt']],
outputs: []
})
})
})
})

View File

@@ -6,6 +6,7 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
@@ -28,31 +29,12 @@ export const useAppModeStore = defineStore('appMode', () => {
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
}
function flushSelections() {
const workflow = workflowStore.activeWorkflow
if (workflow) {
workflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
}
watch(
() => workflowStore.activeWorkflow,
(newWorkflow, oldWorkflow) => {
// Persist in-progress builder selections to the outgoing workflow
if (oldWorkflow && isBuilderMode.value) {
oldWorkflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
// Load from incoming workflow: dirty state first, then persisted
(newWorkflow) => {
if (newWorkflow) {
loadSelections(
newWorkflow.dirtyLinearData ??
newWorkflow.changeTracker?.activeState?.extra?.linearData
newWorkflow.changeTracker?.activeState?.extra?.linearData
)
} else {
loadSelections(undefined)
@@ -61,6 +43,24 @@ export const useAppModeStore = defineStore('appMode', () => {
{ immediate: true }
)
watch(
() =>
isBuilderMode.value
? { inputs: selectedInputs, outputs: selectedOutputs }
: null,
(data) => {
if (!data) return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})
extra.linearData = {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
},
{ deep: true }
)
watch(
() => mode.value === 'builder:select',
(inSelect) => (getCanvas().read_only = inSelect)
@@ -75,8 +75,6 @@ export const useAppModeStore = defineStore('appMode', () => {
}
async function exitBuilder() {
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.dirtyLinearData = null
resetSelectedToWorkflow()
setMode('graph')
}
@@ -85,7 +83,6 @@ export const useAppModeStore = defineStore('appMode', () => {
enterBuilder,
exitBuilder,
hasOutputs,
flushSelections,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs