mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 05:00:03 +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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]',
|
||||
146
src/components/builder/useAppSetDefaultView.test.ts
Normal file
146
src/components/builder/useAppSetDefaultView.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/components/builder/useAppSetDefaultView.ts
Normal file
47
src/components/builder/useAppSetDefaultView.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user