Add support for dragging in multiple workflow files at once (#8757)

## Summary

Allows users to drag in multiple files that are/have embedded workflows
and loads each of them as tabs.
Previously it would only load the first one.

## Changes

- **What**: 
- process all files from drop event
- add defered errors so you don't get errors for non-visible workflows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8757-Add-support-for-dragging-in-multiple-workflow-files-at-once-3026d73d365081c096e9dfb18ba01253)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-02-17 07:45:22 +00:00
committed by GitHub
parent efe78b799f
commit f5f5a77435
9 changed files with 429 additions and 107 deletions

View File

@@ -0,0 +1,251 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
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'
import { app } from '@/scripts/app'
const { mockShowLoadWorkflowWarning, mockShowMissingModelsWarning } =
vi.hoisted(() => ({
mockShowLoadWorkflowWarning: vi.fn(),
mockShowMissingModelsWarning: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showLoadWorkflowWarning: mockShowLoadWorkflowWarning,
showMissingModelsWarning: mockShowMissingModelsWarning,
prompt: vi.fn(),
confirm: vi.fn()
})
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { ds: { offset: [0, 0], scale: 1 } },
rootGraph: { serialize: vi.fn(() => ({})) },
loadGraphData: vi.fn()
}
}))
vi.mock('@/scripts/defaultGraph', () => ({
defaultGraph: {},
blankGraph: {}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ linearMode: false })
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
storeThumbnail: vi.fn(),
getThumbnail: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('@/platform/workflow/persistence/stores/workflowDraftStore', () => ({
useWorkflowDraftStore: () => ({
saveDraft: vi.fn(),
getDraft: vi.fn(),
removeDraft: vi.fn(),
markDraftUsed: vi.fn()
})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
clear: vi.fn()
})
}))
const MISSING_MODELS: PendingWarnings['missingModels'] = {
missingModels: [
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
],
paths: { checkpoints: ['/models/checkpoints'] }
}
function createWorkflow(
warnings: PendingWarnings | null = null,
options: { loadable?: boolean; path?: string } = {}
): ComfyWorkflow {
return {
pendingWarnings: warnings,
...(options.loadable && {
path: options.path ?? 'workflows/test.json',
isLoaded: true,
activeState: { nodes: [], links: [] },
changeTracker: { reset: vi.fn(), restore: vi.fn() }
})
} as unknown as ComfyWorkflow
}
function enableWarningSettings() {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
if (key === 'Comfy.Workflow.ShowMissingNodesWarning') return true
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
return false
}
)
}
describe('useWorkflowService', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('showPendingWarnings', () => {
beforeEach(() => {
enableWarningSettings()
})
it('should do nothing when workflow has no pending warnings', () => {
const workflow = createWorkflow(null)
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
})
it('should show missing nodes dialog and clear warnings', () => {
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
const workflow = createWorkflow({ missingNodeTypes })
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes
})
expect(workflow.pendingWarnings).toBeNull()
})
it('should show missing models dialog and clear warnings', () => {
const workflow = createWorkflow({ missingModels: MISSING_MODELS })
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowMissingModelsWarning).toHaveBeenCalledWith(MISSING_MODELS)
expect(workflow.pendingWarnings).toBeNull()
})
it('should not show dialogs when settings are disabled', () => {
vi.spyOn(useSettingStore(), 'get').mockReturnValue(false)
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1'],
missingModels: MISSING_MODELS
})
useWorkflowService().showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
expect(mockShowMissingModelsWarning).not.toHaveBeenCalled()
expect(workflow.pendingWarnings).toBeNull()
})
it('should only show warnings once across multiple calls', () => {
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1']
})
const service = useWorkflowService()
service.showPendingWarnings(workflow)
service.showPendingWarnings(workflow)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
})
})
describe('openWorkflow deferred warnings', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
enableWarningSettings()
workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockImplementation(
async (_data, _clean, _restore, wf) => {
;(
workflowStore as unknown as Record<string, unknown>
).activeWorkflow = wf
}
)
})
it('should defer warnings during load and show on focus', async () => {
const workflow = createWorkflow(
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
)
expect(mockShowLoadWorkflowWarning).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
expect(app.loadGraphData).toHaveBeenCalledWith(
expect.anything(),
true,
true,
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes: ['CustomNode1']
})
expect(workflow.pendingWarnings).toBeNull()
})
it('should show each workflow warnings only when that tab is focused', async () => {
const workflow1 = createWorkflow(
{ missingNodeTypes: ['MissingNodeA'] },
{ loadable: true, path: 'workflows/first.json' }
)
const workflow2 = createWorkflow(
{ missingNodeTypes: ['MissingNodeB'] },
{ loadable: true, path: 'workflows/second.json' }
)
const service = useWorkflowService()
await service.openWorkflow(workflow1)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledWith({
missingNodeTypes: ['MissingNodeA']
})
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(2)
expect(mockShowLoadWorkflowWarning).toHaveBeenLastCalledWith({
missingNodeTypes: ['MissingNodeB']
})
expect(workflow2.pendingWarnings).toBeNull()
})
it('should not show warnings when refocusing a cleared tab', async () => {
const workflow = createWorkflow(
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
)
const service = useWorkflowService()
await service.openWorkflow(workflow, { force: true })
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(mockShowLoadWorkflowWarning).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -183,9 +183,11 @@ export const useWorkflowService = () => {
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: false
checkForRerouteMigration: false,
deferWarnings: true
}
)
showPendingWarnings()
}
/**
@@ -437,6 +439,32 @@ export const useWorkflowService = () => {
await app.loadGraphData(state, true, true, filename)
}
/**
* Show and clear any pending warnings (missing nodes/models) stored on the
* active workflow. Called after a workflow becomes visible so dialogs don't
* overlap with subsequent loads.
*/
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
const wf = workflow ?? workflowStore.activeWorkflow
if (!wf?.pendingWarnings) return
const { missingNodeTypes, missingModels } = wf.pendingWarnings
wf.pendingWarnings = null
if (
missingNodeTypes?.length &&
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) {
void dialogService.showLoadWorkflowWarning({ missingNodeTypes })
}
if (
missingModels &&
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
) {
void dialogService.showMissingModelsWarning(missingModels)
}
}
return {
exportWorkflow,
saveWorkflowAs,
@@ -452,6 +480,7 @@ export const useWorkflowService = () => {
loadNextOpenedWorkflow,
loadPreviousOpenedWorkflow,
duplicateWorkflow,
showPendingWarnings,
afterLoadNewGraph,
beforeLoadNewGraph
}

View File

@@ -3,7 +3,19 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
ModelFile
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingNodeType } from '@/types/comfy'
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
missingModels?: {
missingModels: ModelFile[]
paths: Record<string, string[]>
}
}
export class ComfyWorkflow extends UserFile {
static readonly basePath: string = 'workflows/'
@@ -17,6 +29,10 @@ export class ComfyWorkflow extends UserFile {
* Whether the workflow has been modified comparing to the initial state.
*/
_isModified: boolean = false
/**
* Warnings deferred from load time, shown when the workflow is first focused.
*/
pendingWarnings: PendingWarnings | null = null
/**
* @param options The path, modified, and size of the workflow.