+
@@ -153,15 +151,20 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
+type ContentPadding = 'default' | 'compact' | 'none'
const {
contentTitle,
rightPanelTitle,
- size = 'lg'
+ size = 'lg',
+ leftPanelWidth = '14rem',
+ contentPadding = 'default'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
+ leftPanelWidth?: string
+ contentPadding?: ContentPadding
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -197,10 +200,18 @@ const showLeftPanel = computed(() => {
return shouldShow
})
+const contentContainerClass = computed(() =>
+ cn(
+ 'flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-custom',
+ contentPadding === 'default' && 'px-6 pt-0 pb-10',
+ contentPadding === 'compact' && 'px-6 pt-0 pb-2'
+ )
+)
+
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
- ? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
- : `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
+ ? `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
+ : `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {
diff --git a/src/composables/README.md b/src/composables/README.md
index cccc7d3df8..e1be581685 100644
--- a/src/composables/README.md
+++ b/src/composables/README.md
@@ -247,7 +247,7 @@ General-purpose composables:
| `useTreeExpansion` | Handles tree node expansion state |
| `useWorkflowAutoSave` | Handles automatic workflow saving |
-| `useWorkflowPersistence` | Manages workflow persistence |
+| `useWorkflowPersistenceV2` | Manages workflow persistence |
| `useWorkflowValidation` | Validates workflow integrity |
## Usage Guidelines
diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts
index 88e48a9b26..601cf5cc63 100644
--- a/src/composables/useFeatureFlags.ts
+++ b/src/composables/useFeatureFlags.ts
@@ -23,7 +23,10 @@ export enum ServerFeatureFlag {
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
- NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled'
+ NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
+ WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
+ COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
+ COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
}
/**
@@ -130,6 +133,29 @@ export function useFeatureFlags() {
false
)
)
+ },
+ get workflowSharingEnabled() {
+ // UI is also gated on `isCloud` in TopMenuSection; default false
+ // to match other flags' opt-in convention.
+ return resolveFlag(
+ ServerFeatureFlag.WORKFLOW_SHARING_ENABLED,
+ remoteConfig.value.workflow_sharing_enabled,
+ false
+ )
+ },
+ get comfyHubUploadEnabled() {
+ return resolveFlag(
+ ServerFeatureFlag.COMFYHUB_UPLOAD_ENABLED,
+ remoteConfig.value.comfyhub_upload_enabled,
+ false
+ )
+ },
+ get comfyHubProfileGateEnabled() {
+ return resolveFlag(
+ ServerFeatureFlag.COMFYHUB_PROFILE_GATE_ENABLED,
+ remoteConfig.value.comfyhub_profile_gate_enabled,
+ false
+ )
}
})
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index c7b3ffd326..72d5315eb7 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -2998,7 +2998,131 @@
"actionbar": {
"dockToTop": "Dock to top",
"feedback": "Feedback",
- "feedbackTooltip": "Feedback"
+ "feedbackTooltip": "Feedback",
+ "share": "Share",
+ "shareTooltip": "Share workflow"
+ },
+ "shareWorkflow": {
+ "shareLinkTab": "Share",
+ "publishToHubTab": "Publish",
+ "loadingTitle": "Share workflow",
+ "unsavedTitle": "Save workflow first",
+ "unsavedDescription": "You must save your workflow before sharing. Save it now to continue.",
+ "saveButton": "Save workflow",
+ "saving": "Saving...",
+ "workflowNameLabel": "Workflow name",
+ "createLinkTitle": "Share workflow",
+ "createLinkDescription": "When you create a link for your workflow, you will share these media items along with your workflow",
+ "privateAssetsDescription": "Your workflow contains private models and/or media files",
+ "createLinkButton": "Create a link",
+ "creatingLink": "Creating a link...",
+ "successTitle": "Workflow successfully published!",
+ "successDescription": "Anyone with this link can view and use this workflow. If you make changes to this workflow, you can republish to update the shared version.",
+ "hasChangesTitle": "Share workflow",
+ "hasChangesDescription": "You have made changes since this workflow was last published.",
+ "updateLinkButton": "Update link",
+ "updatingLink": "Updating link...",
+ "publishedOn": "Published on {date}",
+ "copyLink": "Copy",
+ "linkCopied": "Copied!",
+ "shareUrlLabel": "Share URL",
+ "loadFailed": "Failed to load shared workflow",
+ "saveFailedTitle": "Save failed",
+ "saveFailedDescription": "Failed to save workflow. Please try again.",
+ "mediaLabel": "{count} Media File | {count} Media Files",
+ "modelsLabel": "{count} Model | {count} Models",
+ "checkingAssets": "Checking media visibility…",
+ "acknowledgeCheckbox": "I understand these media items will be published and made public",
+ "inLibrary": "In library",
+ "comfyHubTitle": "Upload to ComfyHub",
+ "comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
+ "comfyHubButton": "Upload to ComfyHub"
+ },
+ "openSharedWorkflow": {
+ "dialogTitle": "Open shared workflow",
+ "author": "Author:",
+ "copyDescription": "Opening the workflow will create a new copy in your workspace",
+ "nonPublicAssetsWarningLine1": "This workflow comes with non-public assets.",
+ "nonPublicAssetsWarningLine2": "These will be imported to your library when you open the workflow",
+ "copyAssetsAndOpen": "Import assets & open workflow",
+ "openWorkflow": "Open workflow",
+ "openWithoutImporting": "Open without importing",
+ "importFailed": "Failed to import workflow assets",
+ "loadError": "Could not load this shared workflow. Please try again later."
+ },
+ "comfyHubPublish": {
+ "title": "Publish to ComfyHub",
+ "stepDescribe": "Describe your workflow",
+ "stepExamples": "Add output examples",
+ "stepFinish": "Finish publishing",
+ "workflowName": "Workflow name",
+ "workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
+ "workflowDescription": "Workflow description",
+ "workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
+ "workflowType": "Workflow type",
+ "workflowTypePlaceholder": "Select the type",
+ "workflowTypeImageGeneration": "Image generation",
+ "workflowTypeVideoGeneration": "Video generation",
+ "workflowTypeUpscaling": "Upscaling",
+ "workflowTypeEditing": "Editing",
+ "tags": "Tags",
+ "tagsDescription": "Select tags so people can find your workflow faster",
+ "tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
+ "selectAThumbnail": "Select a thumbnail",
+ "showMoreTags": "Show more...",
+ "showLessTags": "Show less...",
+ "suggestedTags": "Suggested tags",
+ "thumbnailImage": "Image",
+ "thumbnailVideo": "Video",
+ "thumbnailImageComparison": "Image comparison",
+ "uploadThumbnail": "Upload an image",
+ "uploadVideo": "Upload a video",
+ "uploadComparison": "Upload before and after",
+ "thumbnailPreview": "Thumbnail preview",
+ "uploadPromptClickToBrowse": "Click to browse or",
+ "uploadPromptDropImage": "drop an image here",
+ "uploadPromptDropVideo": "drop a video here",
+ "uploadComparisonBeforePrompt": "Before",
+ "uploadComparisonAfterPrompt": "After",
+ "uploadThumbnailHint": "1:1 preferred, 1080p max",
+ "back": "Back",
+ "next": "Next",
+ "publishButton": "Publish to ComfyHub",
+ "examplesDescription": "Add up to {total} additional sample images",
+ "uploadAnImage": "Click to browse or drag an image",
+ "uploadExampleImage": "Upload example image",
+ "exampleImage": "Example image {index}",
+ "videoPreview": "Video thumbnail preview",
+ "maxExamples": "You can select up to {max} examples",
+ "createProfileToPublish": "Create a profile to publish to ComfyHub",
+ "createProfileCta": "Create a profile"
+ },
+ "comfyHubProfile": {
+ "checkingAccess": "Checking your publishing access...",
+ "profileCreationNav": "Profile creation",
+ "introTitle": "Publish to the ComfyHub",
+ "introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
+ "introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
+ "createProfileButton": "Create my profile",
+ "startPublishingButton": "Start publishing",
+ "modalTitle": "Create your profile on ComfyHub",
+ "createProfileTitle": "Create your Comfy Hub profile",
+ "uploadCover": "+ Upload a cover",
+ "uploadProfilePicture": "+ Upload a profile picture",
+ "chooseProfilePicture": "Choose a profile picture",
+ "nameLabel": "Your name",
+ "namePlaceholder": "Enter your name here",
+ "usernameLabel": "Your username (required)",
+ "usernamePlaceholder": "@",
+ "descriptionLabel": "Your description",
+ "descriptionPlaceholder": "Tell the community about yourself...",
+ "createProfile": "Create profile",
+ "creatingProfile": "Creating profile...",
+ "successTitle": "Looking good, {'@'}{username}!",
+ "successProfileUrl": "Your profile page is live at",
+ "successProfileLink": "comfy.com/p/{username}",
+ "successDescription": "You can now upload your workflow to your creator page",
+ "uploadWorkflowButton": "Upload my workflow"
},
"desktopDialogs": {
"": {
diff --git a/src/platform/navigation/preservedQueryNamespaces.ts b/src/platform/navigation/preservedQueryNamespaces.ts
index f8fb0a50e9..944e578e05 100644
--- a/src/platform/navigation/preservedQueryNamespaces.ts
+++ b/src/platform/navigation/preservedQueryNamespaces.ts
@@ -1,4 +1,5 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
- INVITE: 'invite'
+ INVITE: 'invite',
+ SHARE: 'share'
} as const
diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts
index 63846e4523..f931259a98 100644
--- a/src/platform/remoteConfig/types.ts
+++ b/src/platform/remoteConfig/types.ts
@@ -48,4 +48,7 @@ export type RemoteConfig = {
node_library_essentials_enabled?: boolean
free_tier_credits?: number
new_free_tier_subscriptions?: boolean
+ workflow_sharing_enabled?: boolean
+ comfyhub_upload_enabled?: boolean
+ comfyhub_profile_gate_enabled?: boolean
}
diff --git a/src/platform/workflow/management/stores/workflowStore.test.ts b/src/platform/workflow/management/stores/workflowStore.test.ts
index 22a153e1ca..fcbe6ca187 100644
--- a/src/platform/workflow/management/stores/workflowStore.test.ts
+++ b/src/platform/workflow/management/stores/workflowStore.test.ts
@@ -183,6 +183,31 @@ describe('useWorkflowStore', () => {
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
+
+ it('should assign a workflow id to newly created temporary workflows', () => {
+ const workflow = store.createTemporary('id-test.json')
+ const state = JSON.parse(workflow.content!)
+
+ expect(typeof state.id).toBe('string')
+ expect(state.id.length).toBeGreaterThan(0)
+ })
+
+ it('should assign an id when temporary workflow data is missing one', () => {
+ const workflowDataWithoutId = {
+ ...defaultGraph,
+ id: undefined
+ }
+
+ const workflow = store.createTemporary(
+ 'missing-id.json',
+ workflowDataWithoutId
+ )
+ const state = JSON.parse(workflow.content!)
+
+ expect(typeof state.id).toBe('string')
+ expect(state.id.length).toBeGreaterThan(0)
+ expect(workflowDataWithoutId.id).toBeUndefined()
+ })
})
describe('openWorkflow', () => {
diff --git a/src/platform/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts
index e493ff882b..d97193a7a5 100644
--- a/src/platform/workflow/management/stores/workflowStore.ts
+++ b/src/platform/workflow/management/stores/workflowStore.ts
@@ -255,6 +255,20 @@ export const useWorkflowStore = defineStore('workflow', () => {
return workflow
}
+ const ensureWorkflowId = (
+ workflowData?: ComfyWorkflowJSON
+ ): ComfyWorkflowJSON => {
+ const base = workflowData
+ ? (JSON.parse(JSON.stringify(workflowData)) as ComfyWorkflowJSON)
+ : (JSON.parse(defaultGraphJSON) as ComfyWorkflowJSON)
+
+ if (!base.id) {
+ base.id = generateUUID()
+ }
+
+ return base
+ }
+
/**
* Helper to create a new temporary workflow
*/
@@ -268,9 +282,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
size: -1
})
- workflow.originalContent = workflow.content = workflowData
- ? JSON.stringify(workflowData)
- : defaultGraphJSON
+ const initialWorkflowData = ensureWorkflowId(workflowData)
+ workflow.originalContent = workflow.content =
+ JSON.stringify(initialWorkflowData)
workflowLookup.value[workflow.path] = workflow
return workflow
@@ -284,9 +298,13 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
)
+ const normalizedWorkflowData = workflowData
+ ? ensureWorkflowId(workflowData)
+ : undefined
+
// Try to reuse an existing loaded workflow with the same filename
// that is not stored in the workflows directory
- if (path && workflowData) {
+ if (path && normalizedWorkflowData) {
const existingWorkflow = workflows.value.find(
(w) => w.fullFilename === path
)
@@ -296,12 +314,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath.slice(0, -1)
)
) {
- existingWorkflow.changeTracker.reset(workflowData)
+ existingWorkflow.changeTracker.reset(normalizedWorkflowData)
return existingWorkflow
}
}
- return createNewWorkflow(fullPath, workflowData)
+ return createNewWorkflow(fullPath, normalizedWorkflowData)
}
/**
diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts
deleted file mode 100644
index e40a3e2440..0000000000
--- a/src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-import { createTestingPinia } from '@pinia/testing'
-import { setActivePinia } from 'pinia'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { nextTick } from 'vue'
-import type * as I18n from 'vue-i18n'
-
-import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
-import type { WorkflowDraftSnapshot } from '@/platform/workflow/persistence/base/draftCache'
-import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
-import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
-import { defaultGraphJSON } from '@/scripts/defaultGraph'
-import { setStorageValue } from '@/scripts/utils'
-
-const settingMocks = vi.hoisted(() => ({
- persistRef: null as { value: boolean } | null
-}))
-
-vi.mock('@/platform/settings/settingStore', async () => {
- const { ref } = await import('vue')
- settingMocks.persistRef = ref(true)
- return {
- useSettingStore: vi.fn(() => ({
- get: vi.fn((key: string) => {
- if (key === 'Comfy.Workflow.Persist')
- return settingMocks.persistRef!.value
- return undefined
- }),
- set: vi.fn()
- }))
- }
-})
-
-const mockToastAdd = vi.fn()
-vi.mock('primevue', () => ({
- useToast: () => ({
- add: mockToastAdd
- })
-}))
-
-vi.mock('vue-i18n', async (importOriginal) => {
- const actual = await importOriginal
()
- return {
- ...actual,
- useI18n: () => ({
- t: (key: string) => key
- })
- }
-})
-
-const loadBlankWorkflow = vi.fn()
-vi.mock('@/platform/workflow/core/services/workflowService', () => ({
- useWorkflowService: () => ({
- loadBlankWorkflow
- })
-}))
-
-vi.mock(
- '@/platform/workflow/templates/composables/useTemplateUrlLoader',
- () => ({
- useTemplateUrlLoader: () => ({
- loadTemplateFromUrlParams: vi.fn()
- })
- })
-)
-
-const executeCommand = vi.fn()
-vi.mock('@/stores/commandStore', () => ({
- useCommandStore: () => ({
- execute: executeCommand
- })
-}))
-
-type GraphChangedHandler = (() => void) | null
-
-const mocks = vi.hoisted(() => {
- const state = {
- graphChangedHandler: null as GraphChangedHandler,
- currentGraph: {} as Record
- }
- const serializeMock = vi.fn(() => state.currentGraph)
- const loadGraphDataMock = vi.fn()
- const apiMock = {
- clientId: 'test-client',
- initialClientId: 'test-client',
- addEventListener: vi.fn((event: string, handler: () => void) => {
- if (event === 'graphChanged') {
- state.graphChangedHandler = handler
- }
- }),
- removeEventListener: vi.fn(),
- getUserData: vi.fn(),
- storeUserData: vi.fn(),
- listUserDataFullInfo: vi.fn(),
- storeSetting: vi.fn(),
- getSettings: vi.fn(),
- deleteUserData: vi.fn(),
- moveUserData: vi.fn(),
- apiURL: vi.fn((path: string) => path)
- }
- return { state, serializeMock, loadGraphDataMock, apiMock }
-})
-
-vi.mock('@/scripts/app', () => ({
- app: {
- graph: {
- serialize: () => mocks.serializeMock()
- },
- rootGraph: {
- serialize: () => mocks.serializeMock()
- },
- loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
- canvas: {}
- }
-}))
-
-vi.mock('@/scripts/api', () => ({
- api: mocks.apiMock
-}))
-
-describe('useWorkflowPersistence', () => {
- beforeEach(() => {
- vi.useFakeTimers()
- vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
- setActivePinia(createTestingPinia({ stubActions: false }))
- localStorage.clear()
- sessionStorage.clear()
- vi.clearAllMocks()
- settingMocks.persistRef!.value = true
- mockToastAdd.mockClear()
- useWorkflowDraftStore().reset()
- mocks.state.graphChangedHandler = null
- mocks.state.currentGraph = { initial: true }
- mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
- mocks.loadGraphDataMock.mockReset()
- mocks.apiMock.clientId = 'test-client'
- mocks.apiMock.initialClientId = 'test-client'
- mocks.apiMock.addEventListener.mockImplementation(
- (event: string, handler: () => void) => {
- if (event === 'graphChanged') {
- mocks.state.graphChangedHandler = handler
- }
- }
- )
- mocks.apiMock.removeEventListener.mockImplementation(() => {})
- mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
- mocks.apiMock.getUserData.mockResolvedValue({
- status: 200,
- text: () => Promise.resolve(defaultGraphJSON)
- } as Response)
- mocks.apiMock.apiURL.mockImplementation((path: string) => path)
- })
-
- afterEach(() => {
- vi.useRealTimers()
- })
-
- it('persists snapshots for multiple workflows', async () => {
- const workflowStore = useWorkflowStore()
- const workflowA = workflowStore.createTemporary('DraftA.json')
- await workflowStore.openWorkflow(workflowA)
-
- const persistence = useWorkflowPersistence()
- expect(persistence).toBeDefined()
- expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
-
- const graphA = { title: 'A' }
- mocks.state.currentGraph = graphA
- mocks.state.graphChangedHandler!()
- await vi.advanceTimersByTimeAsync(800)
-
- const workflowB = workflowStore.createTemporary('DraftB.json')
- await workflowStore.openWorkflow(workflowB)
- const graphB = { title: 'B' }
- mocks.state.currentGraph = graphB
- mocks.state.graphChangedHandler!()
- await vi.advanceTimersByTimeAsync(800)
-
- const drafts = JSON.parse(
- localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
- ) as Record
-
- expect(Object.keys(drafts)).toEqual(
- expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
- )
- expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
- expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
- expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
- expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
- })
-
- it('evicts least recently used drafts beyond the limit', async () => {
- const workflowStore = useWorkflowStore()
- useWorkflowPersistence()
- expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
-
- for (let i = 0; i < 33; i++) {
- const workflow = workflowStore.createTemporary(`Draft${i}.json`)
- await workflowStore.openWorkflow(workflow)
- mocks.state.currentGraph = { index: i }
- mocks.state.graphChangedHandler!()
- await vi.advanceTimersByTimeAsync(800)
- vi.setSystemTime(new Date(Date.now() + 60000))
- }
-
- const drafts = JSON.parse(
- localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
- ) as Record
-
- expect(Object.keys(drafts).length).toBe(32)
- expect(drafts['workflows/Draft0.json']).toBeUndefined()
- expect(drafts['workflows/Draft32.json']).toBeDefined()
- })
-
- it('restores temporary tabs from cached drafts', async () => {
- const workflowStore = useWorkflowStore()
- const draftStore = useWorkflowDraftStore()
- const draftData = JSON.parse(defaultGraphJSON)
- draftStore.saveDraft('workflows/Unsaved Workflow.json', {
- data: JSON.stringify(draftData),
- updatedAt: Date.now(),
- name: 'Unsaved Workflow.json',
- isTemporary: true
- })
- setStorageValue(
- 'Comfy.OpenWorkflowsPaths',
- JSON.stringify(['workflows/Unsaved Workflow.json'])
- )
- setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
-
- const { restoreWorkflowTabsState } = useWorkflowPersistence()
- restoreWorkflowTabsState()
-
- const restored = workflowStore.getWorkflowByPath(
- 'workflows/Unsaved Workflow.json'
- )
- expect(restored).toBeTruthy()
- expect(restored?.isTemporary).toBe(true)
- expect(
- workflowStore.openWorkflows.map((workflow) => workflow?.path)
- ).toContain('workflows/Unsaved Workflow.json')
- })
-
- it('shows error toast when draft save fails', async () => {
- const workflowStore = useWorkflowStore()
- const draftStore = useWorkflowDraftStore()
-
- const workflow = workflowStore.createTemporary('FailingDraft.json')
- await workflowStore.openWorkflow(workflow)
-
- useWorkflowPersistence()
- expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
-
- vi.spyOn(draftStore, 'saveDraft').mockImplementation(() => {
- throw new Error('Storage quota exceeded')
- })
-
- mocks.state.currentGraph = { title: 'Test' }
- mocks.state.graphChangedHandler!()
- await vi.advanceTimersByTimeAsync(800)
-
- expect(mockToastAdd).toHaveBeenCalledWith(
- expect.objectContaining({
- severity: 'error',
- detail: expect.any(String)
- })
- )
- })
-
- it('clears all drafts when Persist is switched from true to false', async () => {
- const workflowStore = useWorkflowStore()
- const draftStore = useWorkflowDraftStore()
- const workflow = workflowStore.createTemporary('ClearDraft.json')
- await workflowStore.openWorkflow(workflow)
-
- useWorkflowPersistence()
- expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
-
- mocks.state.currentGraph = { title: 'Draft to clear' }
- mocks.state.graphChangedHandler!()
- await vi.advanceTimersByTimeAsync(800)
-
- expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeDefined()
-
- settingMocks.persistRef!.value = false
- await nextTick()
-
- expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeUndefined()
- })
-})
diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts
deleted file mode 100644
index 37eedd5150..0000000000
--- a/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-import { useToast } from 'primevue'
-import { tryOnScopeDispose } from '@vueuse/core'
-import { computed, ref, watch } from 'vue'
-import { useI18n } from 'vue-i18n'
-import { useRoute, useRouter } from 'vue-router'
-
-import {
- hydratePreservedQuery,
- mergePreservedQueryIntoQuery
-} from '@/platform/navigation/preservedQueryManager'
-import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
-import { useSettingStore } from '@/platform/settings/settingStore'
-import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
-import {
- ComfyWorkflow,
- useWorkflowStore
-} from '@/platform/workflow/management/stores/workflowStore'
-import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
-import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
-import { api } from '@/scripts/api'
-import { app as comfyApp } from '@/scripts/app'
-import { getStorageValue, setStorageValue } from '@/scripts/utils'
-import { useCommandStore } from '@/stores/commandStore'
-
-export function useWorkflowPersistence() {
- const { t } = useI18n()
- const workflowStore = useWorkflowStore()
- const settingStore = useSettingStore()
- const route = useRoute()
- const router = useRouter()
- const templateUrlLoader = useTemplateUrlLoader()
- const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
- const workflowDraftStore = useWorkflowDraftStore()
- const toast = useToast()
-
- const ensureTemplateQueryFromIntent = async () => {
- hydratePreservedQuery(TEMPLATE_NAMESPACE)
- const mergedQuery = mergePreservedQueryIntoQuery(
- TEMPLATE_NAMESPACE,
- route.query
- )
-
- if (mergedQuery) {
- await router.replace({ query: mergedQuery })
- }
-
- return mergedQuery ?? route.query
- }
-
- const workflowPersistenceEnabled = computed(() =>
- settingStore.get('Comfy.Workflow.Persist')
- )
-
- const lastSavedJsonByPath = ref>({})
-
- watch(workflowPersistenceEnabled, (enabled) => {
- if (!enabled) {
- workflowDraftStore.reset()
- lastSavedJsonByPath.value = {}
- }
- })
-
- const persistCurrentWorkflow = () => {
- if (!workflowPersistenceEnabled.value) return
- const activeWorkflow = workflowStore.activeWorkflow
- if (!activeWorkflow) return
- const graphData = comfyApp.rootGraph.serialize()
- const workflowJson = JSON.stringify(graphData)
- const workflowPath = activeWorkflow.path
-
- if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
-
- try {
- workflowDraftStore.saveDraft(activeWorkflow.path, {
- data: workflowJson,
- updatedAt: Date.now(),
- name: activeWorkflow.key,
- isTemporary: activeWorkflow.isTemporary
- })
- } catch (error) {
- console.error('Failed to save draft', error)
- toast.add({
- severity: 'error',
- summary: t('g.error'),
- detail: t('toastMessages.failedToSaveDraft'),
- life: 3000
- })
- return
- }
-
- try {
- localStorage.setItem('workflow', workflowJson)
- if (api.clientId) {
- sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
- }
- } catch (error) {
- // Only log our own keys and aggregate stats
- const ourKeys = Object.keys(sessionStorage).filter(
- (key) => key.startsWith('workflow:') || key === 'workflow'
- )
- console.error('QuotaExceededError details:', {
- workflowSizeKB: Math.round(workflowJson.length / 1024),
- totalStorageItems: Object.keys(sessionStorage).length,
- ourWorkflowKeys: ourKeys.length,
- ourWorkflowSizes: ourKeys.map((key) => ({
- key,
- sizeKB: Math.round(sessionStorage[key].length / 1024)
- })),
- error: error instanceof Error ? error.message : String(error)
- })
- throw error
- }
-
- lastSavedJsonByPath.value[workflowPath] = workflowJson
-
- if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
- workflowDraftStore.removeDraft(activeWorkflow.path)
- return
- }
- }
-
- const loadPreviousWorkflowFromStorage = async () => {
- const workflowName = getStorageValue('Comfy.PreviousWorkflow')
- const preferredPath = workflowName
- ? `${ComfyWorkflow.basePath}${workflowName}`
- : null
- return await workflowDraftStore.loadPersistedWorkflow({
- workflowName,
- preferredPath,
- fallbackToLatestDraft: !workflowName
- })
- }
-
- const loadDefaultWorkflow = async () => {
- if (!settingStore.get('Comfy.TutorialCompleted')) {
- await settingStore.set('Comfy.TutorialCompleted', true)
- await useWorkflowService().loadBlankWorkflow()
- await useCommandStore().execute('Comfy.BrowseTemplates')
- } else {
- await comfyApp.loadGraphData()
- }
- }
-
- const initializeWorkflow = async () => {
- if (!workflowPersistenceEnabled.value) return
-
- try {
- const restored = await loadPreviousWorkflowFromStorage()
- if (!restored) {
- await loadDefaultWorkflow()
- }
- } catch (err) {
- console.error('Error loading previous workflow', err)
- await loadDefaultWorkflow()
- }
- }
-
- const loadTemplateFromUrlIfPresent = async () => {
- const query = await ensureTemplateQueryFromIntent()
- const hasTemplateUrl = query.template && typeof query.template === 'string'
-
- if (hasTemplateUrl) {
- await templateUrlLoader.loadTemplateFromUrl()
- }
- }
-
- // Setup watchers
- watch(
- () => workflowStore.activeWorkflow?.key,
- (activeWorkflowKey) => {
- if (!activeWorkflowKey) return
- setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
- // When the activeWorkflow changes, the graph has already been loaded.
- // Saving the current state of the graph to the localStorage.
- persistCurrentWorkflow()
- }
- )
-
- api.addEventListener('graphChanged', persistCurrentWorkflow)
-
- // Clean up event listener when component unmounts
- tryOnScopeDispose(() => {
- api.removeEventListener('graphChanged', persistCurrentWorkflow)
- })
-
- // Restore workflow tabs states
- const openWorkflows = computed(() => workflowStore.openWorkflows)
- const activeWorkflow = computed(() => workflowStore.activeWorkflow)
- const restoreState = computed<{ paths: string[]; activeIndex: number }>(
- () => {
- if (!openWorkflows.value || !activeWorkflow.value) {
- return { paths: [], activeIndex: -1 }
- }
-
- const paths = openWorkflows.value
- .map((workflow) => workflow?.path)
- .filter(
- (path): path is string =>
- typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
- )
- const activeIndex = paths.indexOf(activeWorkflow.value.path)
-
- return { paths, activeIndex }
- }
- )
-
- // Get storage values before setting watchers
- const parsedWorkflows = JSON.parse(
- getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
- )
- const storedWorkflows = Array.isArray(parsedWorkflows)
- ? parsedWorkflows.filter(
- (entry): entry is string => typeof entry === 'string'
- )
- : []
- const parsedIndex = JSON.parse(
- getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
- )
- const storedActiveIndex =
- typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
- ? parsedIndex
- : -1
- watch(restoreState, ({ paths, activeIndex }) => {
- if (workflowPersistenceEnabled.value) {
- setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
- setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
- }
- })
-
- const restoreWorkflowTabsState = () => {
- if (!workflowPersistenceEnabled.value) return
- const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
- if (!isRestorable) return
-
- storedWorkflows.forEach((path: string) => {
- if (workflowStore.getWorkflowByPath(path)) return
- const draft = workflowDraftStore.getDraft(path)
- if (!draft?.isTemporary) return
- try {
- const workflowData = JSON.parse(draft.data)
- workflowStore.createTemporary(draft.name, workflowData)
- } catch (err) {
- console.warn(
- 'Failed to parse workflow draft, creating with default',
- err
- )
- workflowDraftStore.removeDraft(path)
- workflowStore.createTemporary(draft.name)
- }
- })
-
- workflowStore.openWorkflowsInBackground({
- left: storedWorkflows.slice(0, storedActiveIndex),
- right: storedWorkflows.slice(storedActiveIndex)
- })
- }
-
- return {
- initializeWorkflow,
- loadTemplateFromUrlIfPresent,
- restoreWorkflowTabsState
- }
-}
diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
index ceec7d6429..93ff5bd168 100644
--- a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
+++ b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
@@ -33,6 +33,7 @@ import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
+import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -44,6 +45,7 @@ export function useWorkflowPersistenceV2() {
const settingStore = useSettingStore()
const route = useRoute()
const router = useRouter()
+ const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const draftStore = useWorkflowDraftStoreV2()
@@ -183,6 +185,10 @@ export function useWorkflowPersistenceV2() {
}
}
+ const loadSharedWorkflowFromUrlIfPresent = async () => {
+ return await sharedWorkflowUrlLoader.loadSharedWorkflowFromUrl()
+ }
+
// Setup watchers
watch(
() => workflowStore.activeWorkflow?.key,
@@ -279,6 +285,7 @@ export function useWorkflowPersistenceV2() {
return {
initializeWorkflow,
+ loadSharedWorkflowFromUrlIfPresent,
loadTemplateFromUrlIfPresent,
restoreWorkflowTabsState
}
diff --git a/src/platform/workflow/sharing/components/AssetSectionList.vue b/src/platform/workflow/sharing/components/AssetSectionList.vue
new file mode 100644
index 0000000000..88626f99dd
--- /dev/null
+++ b/src/platform/workflow/sharing/components/AssetSectionList.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+ -
+
+
+ {{ item.name }}
+
+
+ {{ $t('shareWorkflow.inLibrary') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts
new file mode 100644
index 0000000000..3765faf409
--- /dev/null
+++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.test.ts
@@ -0,0 +1,329 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { createI18n } from 'vue-i18n'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
+import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
+
+const mockGetSharedWorkflow = vi.fn()
+
+vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
+ SharedWorkflowLoadError: class extends Error {},
+ useWorkflowShareService: () => ({
+ getSharedWorkflow: mockGetSharedWorkflow
+ })
+}))
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: { close: 'Close', cancel: 'Cancel' },
+ openSharedWorkflow: {
+ dialogTitle: 'Open shared workflow',
+ copyDescription:
+ 'Opening the workflow will create a new copy in your workspace',
+ nonPublicAssetsWarningLine1:
+ 'This workflow comes with non-public assets.',
+ nonPublicAssetsWarningLine2:
+ 'These will be added to your library when you open the workflow',
+ copyAssetsAndOpen: 'Copy assets & open workflow',
+ openWorkflow: 'Open workflow',
+ openWithoutImporting: 'Open without importing',
+ loadError:
+ 'Could not load this shared workflow. Please try again later.'
+ },
+ shareWorkflow: {
+ mediaLabel: '{count} Media File | {count} Media Files',
+ modelsLabel: '{count} Model | {count} Models'
+ }
+ }
+ }
+})
+
+function makePayload(
+ overrides: Partial = {}
+): SharedWorkflowPayload {
+ return {
+ shareId: 'share-id-1',
+ workflowId: 'workflow-id-1',
+ name: 'Test Workflow',
+ listed: true,
+ publishedAt: new Date('2026-02-20T00:00:00Z'),
+ workflowJson: {
+ nodes: []
+ } as unknown as SharedWorkflowPayload['workflowJson'],
+ assets: [],
+ ...overrides
+ }
+}
+
+function mountComponent(props: Record = {}) {
+ return mount(OpenSharedWorkflowDialogContent, {
+ global: {
+ plugins: [i18n],
+ stubs: {
+ AssetSectionList: { template: '' },
+ 'asset-section-list': { template: '' }
+ }
+ },
+ props: {
+ shareId: 'test-share-id',
+ onConfirm: vi.fn(),
+ onOpenWithoutImporting: vi.fn(),
+ onCancel: vi.fn(),
+ ...props
+ }
+ })
+}
+
+describe('OpenSharedWorkflowDialogContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('loading state', () => {
+ it('shows skeleton placeholders while loading', () => {
+ mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
+ const wrapper = mountComponent()
+
+ expect(
+ wrapper.findAllComponents({ name: 'Skeleton' }).length
+ ).toBeGreaterThan(0)
+ })
+
+ it('shows dialog title in header while loading', () => {
+ mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
+ const wrapper = mountComponent()
+ const header = wrapper.find('header h2')
+ expect(header.text()).toBe('Open shared workflow')
+ })
+ })
+
+ describe('error state', () => {
+ it('shows error message when fetch fails', async () => {
+ mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain(
+ 'Could not load this shared workflow. Please try again later.'
+ )
+ })
+
+ it('shows close button in error state', async () => {
+ mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const footerButtons = wrapper.findAll('footer button')
+ expect(footerButtons).toHaveLength(1)
+ expect(footerButtons[0].text()).toBe('Close')
+ })
+
+ it('calls onCancel when close is clicked in error state', async () => {
+ mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
+ const onCancel = vi.fn()
+ const wrapper = mountComponent({ onCancel })
+ await flushPromises()
+
+ const closeButton = wrapper
+ .findAll('footer button')
+ .find((b) => b.text() === 'Close')
+ await closeButton!.trigger('click')
+ expect(onCancel).toHaveBeenCalled()
+ })
+ })
+
+ describe('loaded state - no assets', () => {
+ it('shows workflow name in body', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(
+ makePayload({ name: 'My Workflow' })
+ )
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.find('main h2').text()).toBe('My Workflow')
+ })
+
+ it('shows "Open workflow" as primary CTA', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(makePayload())
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const buttons = wrapper.findAll('footer button')
+ const primaryButton = buttons[buttons.length - 1]
+ expect(primaryButton.text()).toBe('Open workflow')
+ })
+
+ it('does not show "Open without importing" button', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(makePayload())
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('Open without importing')
+ })
+
+ it('does not show warning or asset sections', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(makePayload())
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('non-public assets')
+ })
+
+ it('calls onConfirm with payload when primary button is clicked', async () => {
+ const payload = makePayload()
+ mockGetSharedWorkflow.mockResolvedValue(payload)
+ const onConfirm = vi.fn()
+ const wrapper = mountComponent({ onConfirm })
+ await flushPromises()
+
+ const buttons = wrapper.findAll('footer button')
+ await buttons[buttons.length - 1].trigger('click')
+ expect(onConfirm).toHaveBeenCalledWith(payload)
+ })
+
+ it('calls onCancel when cancel button is clicked', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(makePayload())
+ const onCancel = vi.fn()
+ const wrapper = mountComponent({ onCancel })
+ await flushPromises()
+
+ const cancelButton = wrapper
+ .findAll('footer button')
+ .find((b) => b.text() === 'Cancel')
+ await cancelButton!.trigger('click')
+ expect(onCancel).toHaveBeenCalled()
+ })
+ })
+
+ describe('loaded state - with assets', () => {
+ const assetsPayload = makePayload({
+ assets: [
+ {
+ id: 'a1',
+ name: 'photo.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'a2',
+ name: 'image.jpg',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'm1',
+ name: 'model.safetensors',
+ preview_url: '',
+ storage_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ it('shows "Copy assets & open workflow" as primary CTA', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const buttons = wrapper.findAll('footer button')
+ const primaryButton = buttons[buttons.length - 1]
+ expect(primaryButton.text()).toBe('Copy assets & open workflow')
+ })
+
+ it('shows non-public assets warning', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('non-public assets')
+ })
+
+ it('shows "Open without importing" button', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ const openWithoutImporting = wrapper
+ .findAll('button')
+ .find((b) => b.text() === 'Open without importing')
+ expect(openWithoutImporting).toBeDefined()
+ })
+
+ it('calls onOpenWithoutImporting with payload', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
+ const onOpenWithoutImporting = vi.fn()
+ const wrapper = mountComponent({ onOpenWithoutImporting })
+ await flushPromises()
+
+ const button = wrapper
+ .findAll('button')
+ .find((b) => b.text() === 'Open without importing')
+ await button!.trigger('click')
+ expect(onOpenWithoutImporting).toHaveBeenCalledWith(assetsPayload)
+ })
+
+ it('calls onConfirm with payload when primary button is clicked', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
+ const onConfirm = vi.fn()
+ const wrapper = mountComponent({ onConfirm })
+ await flushPromises()
+
+ const buttons = wrapper.findAll('footer button')
+ await buttons[buttons.length - 1].trigger('click')
+ expect(onConfirm).toHaveBeenCalledWith(assetsPayload)
+ })
+
+ it('filters out assets already in library', async () => {
+ const mixedPayload = makePayload({
+ assets: [
+ {
+ id: 'a1',
+ name: 'needed.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'a2',
+ name: 'already-have.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: true
+ }
+ ]
+ })
+ mockGetSharedWorkflow.mockResolvedValue(mixedPayload)
+ const wrapper = mountComponent()
+ await flushPromises()
+
+ // Should still show assets panel (has 1 non-owned)
+ expect(wrapper.text()).toContain('non-public assets')
+ })
+ })
+
+ describe('fetches with correct shareId', () => {
+ it('passes shareId to getSharedWorkflow', async () => {
+ mockGetSharedWorkflow.mockResolvedValue(makePayload())
+ mountComponent({ shareId: 'my-share-123' })
+ await flushPromises()
+
+ expect(mockGetSharedWorkflow).toHaveBeenCalledWith('my-share-123')
+ })
+ })
+})
diff --git a/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue
new file mode 100644
index 0000000000..c7439f9961
--- /dev/null
+++ b/src/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue
@@ -0,0 +1,175 @@
+
+
+
+
+ {{ $t('openSharedWorkflow.dialogTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('openSharedWorkflow.loadError') }}
+
+
+
+
+
+
+
+
+
+ {{ workflowName }}
+
+
+ {{ $t('openSharedWorkflow.copyDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/ShareAssetThumbnail.vue b/src/platform/workflow/sharing/components/ShareAssetThumbnail.vue
new file mode 100644
index 0000000000..5048e7a89a
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareAssetThumbnail.vue
@@ -0,0 +1,67 @@
+
+
+
+
![]()
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/ShareAssetWarningBox.test.ts b/src/platform/workflow/sharing/components/ShareAssetWarningBox.test.ts
new file mode 100644
index 0000000000..6efd99d2fa
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareAssetWarningBox.test.ts
@@ -0,0 +1,268 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import { nextTick } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import type { ComponentProps } from 'vue-component-type-helpers'
+
+import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ shareWorkflow: {
+ privateAssetsDescription:
+ 'Your workflow contains private models and/or media files',
+ mediaLabel: '{count} Media File | {count} Media Files',
+ modelsLabel: '{count} Model | {count} Models',
+ acknowledgeCheckbox: 'I understand these assets...'
+ }
+ }
+ }
+})
+
+describe(ShareAssetWarningBox, () => {
+ function createWrapper(
+ props: Partial> = {}
+ ) {
+ return mount(ShareAssetWarningBox, {
+ props: {
+ items: [
+ {
+ id: 'asset-image',
+ name: 'image.png',
+ storage_url: '',
+ preview_url: 'https://example.com/a.jpg',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-default',
+ name: 'model.safetensors',
+ storage_url: '',
+ preview_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ],
+ acknowledged: false,
+ ...props
+ },
+ global: {
+ plugins: [i18n]
+ }
+ })
+ }
+
+ it('renders warning text', () => {
+ const wrapper = createWrapper()
+ expect(wrapper.text()).toContain(
+ 'Your workflow contains private models and/or media files'
+ )
+ })
+
+ it('renders media and model collapsible sections', () => {
+ const wrapper = createWrapper()
+
+ expect(wrapper.text()).toContain('1 Media File')
+ expect(wrapper.text()).toContain('1 Model')
+ })
+
+ it('keeps at most one accordion section open at a time', async () => {
+ const wrapper = createWrapper()
+
+ const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
+ const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
+ const mediaChevron = mediaHeader.get('i')
+ const modelsChevron = modelsHeader.get('i')
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('true')
+ expect(modelsHeader.attributes('aria-expanded')).toBe('false')
+ expect(mediaHeader.attributes('aria-controls')).toBe(
+ 'section-content-media'
+ )
+ expect(modelsHeader.attributes('aria-controls')).toBe(
+ 'section-content-models'
+ )
+ expect(mediaChevron.classes()).toContain('rotate-90')
+ expect(modelsChevron.classes()).not.toContain('rotate-90')
+
+ await modelsHeader.trigger('click')
+ await nextTick()
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('false')
+ expect(modelsHeader.attributes('aria-expanded')).toBe('true')
+ expect(mediaChevron.classes()).not.toContain('rotate-90')
+ expect(modelsChevron.classes()).toContain('rotate-90')
+
+ await mediaHeader.trigger('click')
+ await nextTick()
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('true')
+ expect(modelsHeader.attributes('aria-expanded')).toBe('false')
+ expect(mediaChevron.classes()).toContain('rotate-90')
+ expect(modelsChevron.classes()).not.toContain('rotate-90')
+
+ await mediaHeader.trigger('click')
+ await nextTick()
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('false')
+ expect(modelsHeader.attributes('aria-expanded')).toBe('false')
+ })
+
+ it('defaults to media section when both sections are available', () => {
+ const wrapper = createWrapper()
+
+ const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
+ const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('true')
+ expect(modelsHeader.attributes('aria-expanded')).toBe('false')
+ })
+
+ it('defaults to models section when media is unavailable', () => {
+ const wrapper = createWrapper({
+ items: [
+ {
+ id: 'model-default',
+ name: 'model.safetensors',
+ storage_url: '',
+ preview_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ expect(wrapper.text()).toContain('1 Model')
+ const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
+
+ expect(modelsHeader.attributes('aria-expanded')).toBe('true')
+ })
+
+ it('allows collapsing the only expanded section when models are unavailable', async () => {
+ const wrapper = createWrapper({
+ items: [
+ {
+ id: 'asset-image',
+ name: 'image.png',
+ storage_url: '',
+ preview_url: 'https://example.com/a.jpg',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
+ const mediaChevron = mediaHeader.get('i')
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('true')
+ expect(mediaChevron.classes()).toContain('rotate-90')
+
+ await mediaHeader.trigger('click')
+ await nextTick()
+
+ expect(mediaHeader.attributes('aria-expanded')).toBe('false')
+ expect(mediaChevron.classes()).not.toContain('rotate-90')
+ })
+
+ it('emits acknowledged update when checkbox is toggled', async () => {
+ const wrapper = createWrapper()
+
+ const checkbox = wrapper.find('input[type="checkbox"]')
+ await checkbox.setValue(true)
+ await nextTick()
+
+ expect(wrapper.emitted('update:acknowledged')).toBeTruthy()
+ expect(wrapper.emitted('update:acknowledged')![0]).toEqual([true])
+ })
+
+ it('displays asset names in the assets section', () => {
+ const wrapper = createWrapper()
+
+ expect(wrapper.text()).toContain('image.png')
+ })
+
+ it('renders thumbnail previews for assets when URLs are available', () => {
+ const wrapper = createWrapper()
+
+ const images = wrapper.findAll('img')
+ expect(images).toHaveLength(1)
+ expect(images[0].attributes('src')).toBe('https://example.com/a.jpg')
+ expect(images[0].attributes('alt')).toBe('image.png')
+ })
+
+ it('renders fallback icon when thumbnail is missing', () => {
+ const wrapper = createWrapper({
+ items: [
+ {
+ id: 'asset-image',
+ name: 'image.png',
+ storage_url: '',
+ preview_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-default',
+ name: 'model.safetensors',
+ storage_url: '',
+ preview_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ const fallbackIcons = wrapper
+ .findAll('i')
+ .filter((icon) => icon.classes().includes('icon-[lucide--image]'))
+
+ expect(fallbackIcons).toHaveLength(1)
+ })
+
+ it('hides assets section when no assets provided', () => {
+ const wrapper = createWrapper({
+ items: [
+ {
+ id: 'model-default',
+ name: 'model.safetensors',
+ storage_url: '',
+ preview_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ expect(wrapper.text()).not.toContain('Media File')
+ })
+
+ it('hides models section when no models provided', () => {
+ const wrapper = createWrapper({
+ items: [
+ {
+ id: 'asset-image',
+ name: 'image.png',
+ storage_url: '',
+ preview_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ expect(wrapper.text()).not.toContain('Model')
+ })
+})
diff --git a/src/platform/workflow/sharing/components/ShareAssetWarningBox.vue b/src/platform/workflow/sharing/components/ShareAssetWarningBox.vue
new file mode 100644
index 0000000000..88a7765910
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareAssetWarningBox.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/ShareUrlCopyField.vue b/src/platform/workflow/sharing/components/ShareUrlCopyField.vue
new file mode 100644
index 0000000000..87b4d9a48f
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareUrlCopyField.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts b/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
new file mode 100644
index 0000000000..56d67f4c5d
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
@@ -0,0 +1,532 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, reactive } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
+
+const mockWorkflowStore = reactive<{
+ activeWorkflow: {
+ path: string
+ directory: string
+ filename: string
+ isTemporary: boolean
+ isModified: boolean
+ lastModified: number
+ } | null
+}>({
+ activeWorkflow: null
+})
+
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => mockWorkflowStore
+}))
+
+const mockToast = vi.hoisted(() => ({ add: vi.fn() }))
+
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => mockToast
+}))
+
+vi.mock('@formkit/auto-animate/vue', () => ({
+ vAutoAnimate: {}
+}))
+
+const mockFlags = vi.hoisted(() => ({
+ comfyHubUploadEnabled: false,
+ comfyHubProfileGateEnabled: true
+}))
+
+const mockShowPublishDialog = vi.hoisted(() => vi.fn())
+
+vi.mock('@/composables/useFeatureFlags', () => ({
+ useFeatureFlags: () => ({
+ flags: mockFlags
+ })
+}))
+
+vi.mock(
+ '@/platform/workflow/sharing/composables/useComfyHubPublishDialog',
+ () => ({
+ useComfyHubPublishDialog: () => ({
+ show: mockShowPublishDialog
+ })
+ })
+)
+
+vi.mock('@/platform/workflow/core/services/workflowService', () => ({
+ useWorkflowService: () => ({
+ saveWorkflow: vi.fn(),
+ renameWorkflow: vi.fn()
+ })
+}))
+
+const mockShareServiceData = vi.hoisted(() => ({
+ items: [
+ {
+ id: 'test.png',
+ name: 'test.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model.safetensors',
+ name: 'model.safetensors',
+ preview_url: '',
+ storage_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+}))
+
+const mockGetPublishStatus = vi.hoisted(() => vi.fn())
+const mockPublishWorkflow = vi.hoisted(() => vi.fn())
+const mockGetShareableAssets = vi.hoisted(() => vi.fn())
+
+vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
+ useWorkflowShareService: () => ({
+ getPublishStatus: mockGetPublishStatus,
+ publishWorkflow: mockPublishWorkflow,
+ getShareableAssets: mockGetShareableAssets
+ })
+}))
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: { close: 'Close', error: 'Error' },
+ shareWorkflow: {
+ unsavedDescription: 'You must save your workflow before sharing.',
+ shareLinkTab: 'Share',
+ publishToHubTab: 'Publish',
+ workflowNameLabel: 'Workflow name',
+ saving: 'Saving...',
+ saveButton: 'Save workflow',
+ createLinkButton: 'Create link',
+ creatingLink: 'Creating link...',
+ checkingAssets: 'Checking assets...',
+ successDescription: 'Anyone with this link...',
+ hasChangesDescription: 'You have made changes...',
+ updateLinkButton: 'Update link',
+ updatingLink: 'Updating link...',
+ publishedOn: 'Published on {date}',
+ mediaLabel: '{count} Media File | {count} Media Files',
+ modelsLabel: '{count} Model | {count} Models',
+ acknowledgeCheckbox: 'I understand these assets...',
+ loadFailed: 'Failed to load publish status'
+ },
+ comfyHubProfile: {
+ introTitle: 'Introducing ComfyHub',
+ createProfileButton: 'Create my profile',
+ startPublishingButton: 'Start publishing'
+ }
+ }
+ }
+})
+
+describe('ShareWorkflowDialogContent', () => {
+ const onClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPublishWorkflow.mockReset()
+ mockGetShareableAssets.mockReset()
+ mockWorkflowStore.activeWorkflow = {
+ path: 'workflows/test.json',
+ directory: 'workflows',
+ filename: 'test.json',
+ isTemporary: false,
+ isModified: false,
+ lastModified: 1000
+ }
+ mockGetPublishStatus.mockResolvedValue({
+ isPublished: false,
+ shareId: null,
+ shareUrl: null,
+ publishedAt: null
+ })
+ mockFlags.comfyHubUploadEnabled = false
+ mockShareServiceData.items = [
+ {
+ id: 'test.png',
+ name: 'test.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model.safetensors',
+ name: 'model.safetensors',
+ preview_url: '',
+ storage_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ mockPublishWorkflow.mockResolvedValue({
+ shareId: 'test-123',
+ shareUrl: 'https://comfy.org/shared/test-123',
+ publishedAt: new Date('2026-01-15')
+ })
+ mockGetShareableAssets.mockResolvedValue(mockShareServiceData.items)
+ })
+
+ function createWrapper() {
+ return mount(ShareWorkflowDialogContent, {
+ props: { onClose },
+ global: {
+ plugins: [i18n],
+ stubs: {
+ ComfyHubPublishIntroPanel: {
+ template:
+ '',
+ props: ['onCreateProfile']
+ },
+ 'comfy-hub-publish-intro-panel': {
+ template:
+ '',
+ props: ['onCreateProfile']
+ },
+ Input: {
+ template: '',
+ methods: { focus() {}, select() {} }
+ }
+ }
+ }
+ })
+ }
+
+ it('renders in unsaved state when workflow is modified', async () => {
+ mockWorkflowStore.activeWorkflow = {
+ path: 'workflows/test.json',
+ directory: 'workflows',
+ filename: 'test.json',
+ isTemporary: false,
+ isModified: true,
+ lastModified: 1000
+ }
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain(
+ 'You must save your workflow before sharing.'
+ )
+ expect(wrapper.text()).toContain('Save workflow')
+ })
+
+ it('renders share-link and publish tabs when comfy hub upload is enabled', async () => {
+ mockFlags.comfyHubUploadEnabled = true
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Share')
+ expect(wrapper.text()).toContain('Publish')
+ const publishTabPanel = wrapper.find('[data-testid="publish-tab-panel"]')
+ expect(publishTabPanel.exists()).toBe(true)
+ expect(publishTabPanel.attributes('style')).toContain('display: none')
+ })
+
+ it('hides the publish tab when comfy hub upload is disabled', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Share')
+ expect(wrapper.text()).not.toContain('Publish')
+ expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(false)
+ })
+
+ it('shows publish intro panel in the share dialog', async () => {
+ mockFlags.comfyHubUploadEnabled = true
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const publishTab = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Publish'))
+
+ expect(publishTab).toBeDefined()
+ await publishTab!.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(true)
+ })
+
+ it('shows start publishing CTA in the publish intro panel', async () => {
+ mockFlags.comfyHubUploadEnabled = true
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const publishTab = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Publish'))
+ expect(publishTab).toBeDefined()
+
+ await publishTab!.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="publish-intro-cta"]').text()).toBe(
+ 'Start publishing'
+ )
+ })
+
+ it('opens publish dialog from intro cta and closes share dialog', async () => {
+ mockFlags.comfyHubUploadEnabled = true
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const publishTab = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Publish'))
+
+ expect(publishTab).toBeDefined()
+ await publishTab!.trigger('click')
+ await flushPromises()
+
+ await wrapper.find('[data-testid="publish-intro-cta"]').trigger('click')
+ await nextTick()
+
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(mockShowPublishDialog).toHaveBeenCalledOnce()
+ })
+
+ it('disables publish button when acknowledgment is unchecked', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const publishButton = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Create link'))
+
+ expect(publishButton?.attributes('disabled')).toBeDefined()
+ })
+
+ it('enables publish button when acknowledgment is checked', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const checkbox = wrapper.find('input[type="checkbox"]')
+ await checkbox.setValue(true)
+ await nextTick()
+
+ const publishButton = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Create link'))
+
+ expect(publishButton?.attributes('disabled')).toBeUndefined()
+ })
+
+ it('calls onClose when close button is clicked', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const closeButton = wrapper.find('[aria-label="Close"]')
+ await closeButton.trigger('click')
+
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('publishes using acknowledged assets from initial load', async () => {
+ const initialShareableAssets = [
+ {
+ id: 'local-photo-id',
+ name: 'photo.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'local-model-id',
+ name: 'model.safetensors',
+ preview_url: '',
+ storage_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+
+ mockGetShareableAssets.mockResolvedValueOnce(initialShareableAssets)
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ const checkbox = wrapper.find('input[type="checkbox"]')
+ await checkbox.setValue(true)
+ await nextTick()
+
+ const publishButton = wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Create link'))
+ expect(publishButton).toBeDefined()
+
+ await publishButton!.trigger('click')
+ await flushPromises()
+
+ expect(mockGetShareableAssets).toHaveBeenCalledTimes(1)
+ expect(mockPublishWorkflow).toHaveBeenCalledWith(
+ 'workflows/test.json',
+ initialShareableAssets
+ )
+ })
+
+ it('shows update button when workflow was saved after last publish', async () => {
+ const publishedAt = new Date('2026-01-15T00:00:00Z')
+ const savedAfterPublishMs = publishedAt.getTime() + 60_000
+
+ mockWorkflowStore.activeWorkflow = {
+ path: 'workflows/test.json',
+ directory: 'workflows',
+ filename: 'test.json',
+ isTemporary: false,
+ isModified: false,
+ lastModified: savedAfterPublishMs
+ }
+ mockGetPublishStatus.mockResolvedValue({
+ isPublished: true,
+ shareId: 'abc-123',
+ shareUrl: 'https://comfy.org/shared/abc-123',
+ publishedAt
+ })
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('You have made changes...')
+ expect(wrapper.text()).toContain('Update link')
+ })
+
+ it('shows copy URL when workflow has not changed since publish', async () => {
+ const publishedAt = new Date('2026-01-15T00:00:00Z')
+ const savedBeforePublishMs = publishedAt.getTime() - 60_000
+
+ mockWorkflowStore.activeWorkflow = {
+ path: 'workflows/test.json',
+ directory: 'workflows',
+ filename: 'test.json',
+ isTemporary: false,
+ isModified: false,
+ lastModified: savedBeforePublishMs
+ }
+ mockGetPublishStatus.mockResolvedValue({
+ isPublished: true,
+ shareId: 'abc-123',
+ shareUrl: 'https://comfy.org/shared/abc-123',
+ publishedAt
+ })
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Anyone with this link...')
+ expect(wrapper.text()).not.toContain('Update link')
+ })
+
+ describe('error and edge cases', () => {
+ it('renders unsaved state when workflow is temporary', async () => {
+ mockWorkflowStore.activeWorkflow = {
+ path: 'workflows/Unsaved Workflow.json',
+ directory: 'workflows',
+ filename: 'Unsaved Workflow.json',
+ isTemporary: true,
+ isModified: false,
+ lastModified: 1000
+ }
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain(
+ 'You must save your workflow before sharing.'
+ )
+ expect(wrapper.text()).toContain('Workflow name')
+ })
+
+ it('shows error toast when getPublishStatus rejects', async () => {
+ mockGetPublishStatus.mockRejectedValue(new Error('Server down'))
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Create link')
+ expect(mockToast.add).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'Failed to load publish status'
+ })
+ })
+
+ it('shows error toast when publishWorkflow rejects', async () => {
+ mockGetShareableAssets.mockResolvedValue([])
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ mockPublishWorkflow.mockRejectedValue(new Error('Publish failed'))
+
+ const publishButton = wrapper
+ .findAll('button')
+ .find((btn) => btn.text().includes('Create link'))
+ expect(publishButton).toBeDefined()
+ await publishButton!.trigger('click')
+ await flushPromises()
+
+ expect(wrapper.text()).not.toContain('Anyone with this link...')
+ expect(mockToast.add).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'Error',
+ detail: 'Publish failed',
+ life: 5000
+ })
+ })
+
+ it('renders unsaved state when no active workflow exists', async () => {
+ mockWorkflowStore.activeWorkflow = null
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain(
+ 'You must save your workflow before sharing.'
+ )
+ })
+
+ it('does not call publishWorkflow when workflow is null during publish', async () => {
+ mockGetShareableAssets.mockResolvedValue([])
+
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ mockWorkflowStore.activeWorkflow = null
+
+ const publishButton = wrapper
+ .findAll('button')
+ .find((btn) => btn.text().includes('Create link'))
+ if (publishButton) {
+ await publishButton.trigger('click')
+ await flushPromises()
+ }
+
+ expect(mockPublishWorkflow).not.toHaveBeenCalled()
+ })
+
+ it('does not switch to publishToHub mode when flag is disabled', async () => {
+ mockFlags.comfyHubUploadEnabled = false
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="publish-tab-panel"]').exists()).toBe(
+ false
+ )
+ expect(wrapper.text()).not.toContain('Publish')
+ })
+ })
+})
diff --git a/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue b/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue
new file mode 100644
index 0000000000..76f25adc81
--- /dev/null
+++ b/src/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue
@@ -0,0 +1,400 @@
+
+
+
+
+
+
+
+
+ {{ $t('shareWorkflow.shareLinkTab') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('shareWorkflow.unsavedDescription') }}
+
+
+
+
+
+
+
+ {{ $t('shareWorkflow.hasChangesDescription') }}
+
+
+ {{ $t('shareWorkflow.checkingAssets') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('shareWorkflow.publishedOn', { date: formattedDate }) }}
+
+
+ {{ $t('shareWorkflow.successDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue b/src/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue
new file mode 100644
index 0000000000..8e4e697bb9
--- /dev/null
+++ b/src/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/profile/ComfyHubPublishIntroPanel.vue b/src/platform/workflow/sharing/components/profile/ComfyHubPublishIntroPanel.vue
new file mode 100644
index 0000000000..cd2b708bc8
--- /dev/null
+++ b/src/platform/workflow/sharing/components/profile/ComfyHubPublishIntroPanel.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('comfyHubProfile.introTitle') }}
+
+
+ {{ $t('comfyHubProfile.introDescription') }}
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue
new file mode 100644
index 0000000000..f6b898534b
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubExamplesStep.vue b/src/platform/workflow/sharing/components/publish/ComfyHubExamplesStep.vue
new file mode 100644
index 0000000000..53e7986015
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubExamplesStep.vue
@@ -0,0 +1,145 @@
+
+
+
+ {{
+ $t('comfyHubPublish.examplesDescription', {
+ selected: selectedExampleIds.length,
+ total: MAX_EXAMPLES
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue b/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue
new file mode 100644
index 0000000000..a818569c53
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue
@@ -0,0 +1,32 @@
+
+
+
+ {{ $t('comfyHubPublish.createProfileToPublish') }}
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.test.ts b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.test.ts
new file mode 100644
index 0000000000..a40253ae8b
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.test.ts
@@ -0,0 +1,139 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
+
+const mockFetchProfile = vi.hoisted(() => vi.fn())
+const mockGoToStep = vi.hoisted(() => vi.fn())
+const mockGoNext = vi.hoisted(() => vi.fn())
+const mockGoBack = vi.hoisted(() => vi.fn())
+const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
+const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
+
+vi.mock(
+ '@/platform/workflow/sharing/composables/useComfyHubProfileGate',
+ () => ({
+ useComfyHubProfileGate: () => ({
+ fetchProfile: mockFetchProfile
+ })
+ })
+)
+
+vi.mock(
+ '@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
+ () => ({
+ useComfyHubPublishWizard: () => ({
+ currentStep: ref('finish'),
+ formData: ref({
+ name: '',
+ description: '',
+ workflowType: '',
+ tags: [],
+ thumbnailType: 'image',
+ thumbnailFile: null,
+ comparisonBeforeFile: null,
+ comparisonAfterFile: null,
+ exampleImages: [],
+ selectedExampleIds: []
+ }),
+ isFirstStep: ref(false),
+ isLastStep: ref(true),
+ goToStep: mockGoToStep,
+ goNext: mockGoNext,
+ goBack: mockGoBack,
+ openProfileCreationStep: mockOpenProfileCreationStep,
+ closeProfileCreationStep: mockCloseProfileCreationStep
+ })
+ })
+)
+
+describe('ComfyHubPublishDialog', () => {
+ const onClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchProfile.mockResolvedValue(null)
+ })
+
+ function createWrapper() {
+ return mount(ComfyHubPublishDialog, {
+ props: { onClose },
+ global: {
+ mocks: {
+ $t: (key: string) => key
+ },
+ stubs: {
+ BaseModalLayout: {
+ template:
+ '
'
+ },
+ ComfyHubPublishNav: {
+ template: '',
+ props: ['currentStep']
+ },
+ 'comfy-hub-publish-nav': {
+ template: '',
+ props: ['currentStep']
+ },
+ ComfyHubPublishWizardContent: {
+ template:
+ '',
+ props: [
+ 'currentStep',
+ 'formData',
+ 'isFirstStep',
+ 'isLastStep',
+ 'onGoNext',
+ 'onGoBack',
+ 'onRequireProfile',
+ 'onGateComplete',
+ 'onGateClose'
+ ]
+ }
+ }
+ }
+ })
+ }
+
+ it('starts in publish wizard mode and prefetches profile asynchronously', async () => {
+ createWrapper()
+ await flushPromises()
+
+ expect(mockFetchProfile).toHaveBeenCalledWith()
+ })
+
+ it('switches to profile creation step when final-step publish requires profile', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ await wrapper.find('[data-testid="require-profile"]').trigger('click')
+
+ expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
+ })
+
+ it('returns to finish state after gate complete and does not auto-close', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ await wrapper.find('[data-testid="require-profile"]').trigger('click')
+ await wrapper.find('[data-testid="gate-complete"]').trigger('click')
+
+ expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
+ expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
+ expect(mockFetchProfile).toHaveBeenCalledWith({ force: true })
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('returns to finish state when profile gate is closed', async () => {
+ const wrapper = createWrapper()
+ await flushPromises()
+
+ await wrapper.find('[data-testid="require-profile"]').trigger('click')
+ await wrapper.find('[data-testid="gate-close"]').trigger('click')
+
+ expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
+ expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue
new file mode 100644
index 0000000000..776acf5251
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue
@@ -0,0 +1,94 @@
+
+
+
+
+ {{ $t('comfyHubPublish.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue
new file mode 100644
index 0000000000..71afe4947f
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue
new file mode 100644
index 0000000000..b318b46cc4
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.test.ts b/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.test.ts
new file mode 100644
index 0000000000..a80813e571
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.test.ts
@@ -0,0 +1,263 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import ComfyHubPublishWizardContent from './ComfyHubPublishWizardContent.vue'
+import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
+
+const mockCheckProfile = vi.hoisted(() => vi.fn())
+const mockToastErrorHandler = vi.hoisted(() => vi.fn())
+const mockHasProfile = ref(true)
+
+vi.mock(
+ '@/platform/workflow/sharing/composables/useComfyHubProfileGate',
+ () => ({
+ useComfyHubProfileGate: () => ({
+ checkProfile: mockCheckProfile,
+ hasProfile: mockHasProfile
+ })
+ })
+)
+
+vi.mock('@/composables/useErrorHandling', () => ({
+ useErrorHandling: () => ({
+ toastErrorHandler: mockToastErrorHandler
+ })
+}))
+
+const mockFlags = vi.hoisted(() => ({
+ comfyHubProfileGateEnabled: true
+}))
+
+vi.mock('@/composables/useFeatureFlags', () => ({
+ useFeatureFlags: () => ({
+ flags: mockFlags
+ })
+}))
+
+function createDefaultFormData(): ComfyHubPublishFormData {
+ return {
+ name: 'Test Workflow',
+ description: '',
+ workflowType: '',
+ tags: [],
+ thumbnailType: 'image',
+ thumbnailFile: null,
+ comparisonBeforeFile: null,
+ comparisonAfterFile: null,
+ exampleImages: [],
+ selectedExampleIds: []
+ }
+}
+
+describe('ComfyHubPublishWizardContent', () => {
+ const onPublish = vi.fn()
+ const onGoNext = vi.fn()
+ const onGoBack = vi.fn()
+ const onUpdateFormData = vi.fn()
+ const onRequireProfile = vi.fn()
+ const onGateComplete = vi.fn()
+ const onGateClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckProfile.mockResolvedValue(true)
+ mockHasProfile.value = true
+ mockFlags.comfyHubProfileGateEnabled = true
+ })
+
+ function createWrapper(
+ overrides: Partial<
+ InstanceType['$props']
+ > = {}
+ ) {
+ return mount(ComfyHubPublishWizardContent, {
+ props: {
+ currentStep: 'finish',
+ formData: createDefaultFormData(),
+ isFirstStep: false,
+ isLastStep: true,
+ onGoNext,
+ onGoBack,
+ onUpdateFormData,
+ onPublish,
+ onRequireProfile,
+ onGateComplete,
+ onGateClose,
+ ...overrides
+ },
+ global: {
+ mocks: {
+ $t: (key: string) => key
+ },
+ stubs: {
+ ComfyHubCreateProfileForm: {
+ template: '',
+ props: ['onProfileCreated', 'onClose', 'showCloseButton']
+ },
+ 'comfy-hub-create-profile-form': {
+ template: '',
+ props: ['onProfileCreated', 'onClose', 'showCloseButton']
+ },
+ ComfyHubDescribeStep: {
+ template: ''
+ },
+ ComfyHubExamplesStep: {
+ template: ''
+ },
+ ComfyHubThumbnailStep: {
+ template: ''
+ },
+ ComfyHubProfilePromptPanel: {
+ template:
+ '',
+ emits: ['request-profile']
+ },
+ ComfyHubPublishFooter: {
+ template:
+ '',
+ props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
+ emits: ['publish', 'next', 'back']
+ }
+ }
+ }
+ })
+ }
+
+ describe('handlePublish — double-click guard', () => {
+ it('prevents concurrent publish calls', async () => {
+ let resolveCheck!: (v: boolean) => void
+ mockCheckProfile.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCheck = resolve
+ })
+ )
+
+ const wrapper = createWrapper()
+
+ const publishBtn = wrapper.find('[data-testid="publish-btn"]')
+ await publishBtn.trigger('click')
+ await publishBtn.trigger('click')
+
+ resolveCheck(true)
+ await flushPromises()
+
+ expect(mockCheckProfile).toHaveBeenCalledTimes(1)
+ expect(onPublish).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('handlePublish — feature flag bypass', () => {
+ it('calls onPublish directly when profile gate is disabled', async () => {
+ mockFlags.comfyHubProfileGateEnabled = false
+
+ const wrapper = createWrapper()
+ await wrapper.find('[data-testid="publish-btn"]').trigger('click')
+ await flushPromises()
+
+ expect(mockCheckProfile).not.toHaveBeenCalled()
+ expect(onPublish).toHaveBeenCalledOnce()
+ })
+ })
+
+ describe('handlePublish — profile check routing', () => {
+ it('calls onPublish when profile exists', async () => {
+ mockCheckProfile.mockResolvedValue(true)
+
+ const wrapper = createWrapper()
+ await wrapper.find('[data-testid="publish-btn"]').trigger('click')
+ await flushPromises()
+
+ expect(mockCheckProfile).toHaveBeenCalledOnce()
+ expect(onPublish).toHaveBeenCalledOnce()
+ expect(onRequireProfile).not.toHaveBeenCalled()
+ })
+
+ it('calls onRequireProfile when no profile exists', async () => {
+ mockCheckProfile.mockResolvedValue(false)
+
+ const wrapper = createWrapper()
+ await wrapper.find('[data-testid="publish-btn"]').trigger('click')
+ await flushPromises()
+
+ expect(onRequireProfile).toHaveBeenCalledOnce()
+ expect(onPublish).not.toHaveBeenCalled()
+ })
+
+ it('shows toast and aborts when checkProfile throws', async () => {
+ const error = new Error('Network error')
+ mockCheckProfile.mockRejectedValue(error)
+
+ const wrapper = createWrapper()
+ await wrapper.find('[data-testid="publish-btn"]').trigger('click')
+ await flushPromises()
+
+ expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
+ expect(onPublish).not.toHaveBeenCalled()
+ expect(onRequireProfile).not.toHaveBeenCalled()
+ })
+
+ it('resets guard after checkProfile error so retry is possible', async () => {
+ mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
+
+ const wrapper = createWrapper()
+ const publishBtn = wrapper.find('[data-testid="publish-btn"]')
+
+ await publishBtn.trigger('click')
+ await flushPromises()
+ expect(onPublish).not.toHaveBeenCalled()
+
+ mockCheckProfile.mockResolvedValue(true)
+ await publishBtn.trigger('click')
+ await flushPromises()
+ expect(onPublish).toHaveBeenCalledOnce()
+ })
+ })
+
+ describe('isPublishDisabled', () => {
+ it('disables publish when gate enabled and hasProfile is not true', () => {
+ mockHasProfile.value = null
+ const wrapper = createWrapper()
+
+ const footer = wrapper.find('[data-testid="publish-footer"]')
+ expect(footer.attributes('data-publish-disabled')).toBe('true')
+ })
+
+ it('enables publish when gate enabled and hasProfile is true', () => {
+ mockHasProfile.value = true
+ const wrapper = createWrapper()
+
+ const footer = wrapper.find('[data-testid="publish-footer"]')
+ expect(footer.attributes('data-publish-disabled')).toBe('false')
+ })
+
+ it('enables publish when gate is disabled regardless of profile', () => {
+ mockFlags.comfyHubProfileGateEnabled = false
+ mockHasProfile.value = null
+ const wrapper = createWrapper()
+
+ const footer = wrapper.find('[data-testid="publish-footer"]')
+ expect(footer.attributes('data-publish-disabled')).toBe('false')
+ })
+ })
+
+ describe('profileCreation step rendering', () => {
+ it('shows profile creation form when on profileCreation step', () => {
+ const wrapper = createWrapper({ currentStep: 'profileCreation' })
+ expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
+ true
+ )
+ expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(
+ false
+ )
+ })
+
+ it('shows wizard content when not on profileCreation step', () => {
+ const wrapper = createWrapper({ currentStep: 'finish' })
+ expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
+ false
+ )
+ expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(true)
+ })
+ })
+})
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue
new file mode 100644
index 0000000000..ba28d3f36a
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubThumbnailStep.vue b/src/platform/workflow/sharing/components/publish/ComfyHubThumbnailStep.vue
new file mode 100644
index 0000000000..de741adb01
--- /dev/null
+++ b/src/platform/workflow/sharing/components/publish/ComfyHubThumbnailStep.vue
@@ -0,0 +1,391 @@
+
+
+
+
+
+
+
+ {{ uploadSectionLabel }}
+
+
+
+
+
+
+
+
![]()
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/workflow/sharing/composables/lazyShareDialog.ts b/src/platform/workflow/sharing/composables/lazyShareDialog.ts
new file mode 100644
index 0000000000..6e6f47ecfc
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/lazyShareDialog.ts
@@ -0,0 +1,14 @@
+export function prefetchShareDialog() {
+ importShareDialog().catch((error) => {
+ console.error(error)
+ })
+}
+
+export async function openShareDialog() {
+ const { useShareDialog } = await importShareDialog()
+ useShareDialog().show()
+}
+
+function importShareDialog() {
+ return import('@/platform/workflow/sharing/composables/useShareDialog')
+}
diff --git a/src/platform/workflow/sharing/composables/useAssetSections.ts b/src/platform/workflow/sharing/composables/useAssetSections.ts
new file mode 100644
index 0000000000..a0c55916e3
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useAssetSections.ts
@@ -0,0 +1,71 @@
+import { partition } from 'es-toolkit'
+import { computed, ref, watch } from 'vue'
+
+import type { AssetInfo } from '@/schemas/apiSchema'
+
+type SectionId = 'media' | 'models'
+
+interface AssetSection {
+ id: SectionId
+ labelKey: string
+ items: AssetInfo[]
+}
+
+export function useAssetSections(items: () => AssetInfo[]) {
+ const sections = computed(() => {
+ const [models, media] = partition(items(), (a) => a.model)
+ const allSections: AssetSection[] = [
+ {
+ id: 'media',
+ labelKey: 'shareWorkflow.mediaLabel',
+ items: media
+ },
+ {
+ id: 'models',
+ labelKey: 'shareWorkflow.modelsLabel',
+ items: models
+ }
+ ]
+ return allSections.filter((s) => s.items.length > 0)
+ })
+
+ const expandedSectionId = ref(null)
+
+ function getDefaultExpandedSection(
+ availableSections: AssetSection[]
+ ): SectionId | null {
+ if (availableSections.length === 0) return null
+ return (
+ availableSections.find((s) => s.id === 'media')?.id ??
+ availableSections[0].id
+ )
+ }
+
+ watch(
+ sections,
+ (availableSections) => {
+ const hasExpanded = availableSections.some(
+ (s) => s.id === expandedSectionId.value
+ )
+ if (hasExpanded) return
+ expandedSectionId.value = getDefaultExpandedSection(availableSections)
+ },
+ { immediate: true }
+ )
+
+ function onSectionOpenChange(sectionId: SectionId, open: boolean) {
+ if (open) {
+ expandedSectionId.value = sectionId
+ return
+ }
+ if (expandedSectionId.value === sectionId) {
+ expandedSectionId.value = null
+ }
+ }
+
+ return {
+ sections,
+ expandedSectionId,
+ onSectionOpenChange
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useComfyHubProfileGate.test.ts b/src/platform/workflow/sharing/composables/useComfyHubProfileGate.test.ts
new file mode 100644
index 0000000000..1ffc99c27d
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useComfyHubProfileGate.test.ts
@@ -0,0 +1,238 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ComfyHubProfile } from '@/schemas/apiSchema'
+
+const mockFetchApi = vi.hoisted(() => vi.fn())
+const mockToastErrorHandler = vi.hoisted(() => vi.fn())
+const mockResolvedUserInfo = vi.hoisted(() => ({
+ value: { id: 'user-a' }
+}))
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ fetchApi: mockFetchApi
+ }
+}))
+
+vi.mock('@/composables/auth/useCurrentUser', () => ({
+ useCurrentUser: () => ({
+ resolvedUserInfo: mockResolvedUserInfo
+ })
+}))
+
+vi.mock('@/composables/useErrorHandling', () => ({
+ useErrorHandling: () => ({
+ toastErrorHandler: mockToastErrorHandler
+ })
+}))
+
+// Must import after vi.mock declarations
+const { useComfyHubProfileGate } = await import('./useComfyHubProfileGate')
+
+const mockProfile: ComfyHubProfile = {
+ username: 'testuser',
+ name: 'Test User',
+ description: 'A test profile'
+}
+
+function mockSuccessResponse(data?: unknown) {
+ return {
+ ok: true,
+ json: async () => data ?? mockProfile
+ } as Response
+}
+
+function mockErrorResponse(status = 500, message = 'Server error') {
+ return {
+ ok: false,
+ status,
+ json: async () => ({ message })
+ } as Response
+}
+
+describe('useComfyHubProfileGate', () => {
+ let gate: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockResolvedUserInfo.value = { id: 'user-a' }
+
+ // Reset module-level singleton refs
+ gate = useComfyHubProfileGate()
+ gate.hasProfile.value = null
+ gate.profile.value = null
+ gate.isCheckingProfile.value = false
+ gate.isFetchingProfile.value = false
+ })
+
+ describe('fetchProfile', () => {
+ it('returns mapped profile when API responds ok', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ const profile = await gate.fetchProfile()
+
+ expect(profile).toEqual(mockProfile)
+ expect(gate.hasProfile.value).toBe(true)
+ expect(gate.profile.value).toEqual(mockProfile)
+ expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
+ })
+
+ it('returns cached profile when already fetched', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.fetchProfile()
+ const profile = await gate.fetchProfile()
+
+ expect(profile).toEqual(mockProfile)
+ expect(mockFetchApi).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-fetches profile when force option is enabled', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.fetchProfile()
+ await gate.fetchProfile({ force: true })
+
+ expect(mockFetchApi).toHaveBeenCalledTimes(2)
+ })
+
+ it('returns null when API responds with error', async () => {
+ mockFetchApi.mockResolvedValue(mockErrorResponse(404))
+
+ const profile = await gate.fetchProfile()
+
+ expect(profile).toBeNull()
+ expect(gate.hasProfile.value).toBe(false)
+ expect(gate.profile.value).toBeNull()
+ })
+
+ it('sets isFetchingProfile during fetch', async () => {
+ let resolvePromise: (v: Response) => void
+ mockFetchApi.mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ )
+
+ const promise = gate.fetchProfile()
+ expect(gate.isFetchingProfile.value).toBe(true)
+
+ resolvePromise!(mockSuccessResponse())
+ await promise
+
+ expect(gate.isFetchingProfile.value).toBe(false)
+ })
+ })
+
+ describe('checkProfile', () => {
+ it('returns true when API responds ok', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ const result = await gate.checkProfile()
+
+ expect(result).toBe(true)
+ expect(gate.hasProfile.value).toBe(true)
+ })
+
+ it('returns false when API responds with error', async () => {
+ mockFetchApi.mockResolvedValue(mockErrorResponse(404))
+
+ const result = await gate.checkProfile()
+
+ expect(result).toBe(false)
+ expect(gate.hasProfile.value).toBe(false)
+ })
+
+ it('returns cached value without re-fetching', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.checkProfile()
+ const result = await gate.checkProfile()
+
+ expect(result).toBe(true)
+ expect(mockFetchApi).toHaveBeenCalledTimes(1)
+ })
+
+ it('clears cached profile state when the authenticated user changes', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.checkProfile()
+ mockResolvedUserInfo.value = { id: 'user-b' }
+ await gate.checkProfile()
+
+ expect(mockFetchApi).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('createProfile', () => {
+ it('sends FormData with required username', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.createProfile({ username: 'testuser' })
+
+ const [url, options] = mockFetchApi.mock.calls[0]
+ expect(url).toBe('/hub/profile')
+ expect(options.method).toBe('POST')
+
+ const body = options.body as FormData
+ expect(body.get('username')).toBe('testuser')
+ })
+
+ it('includes optional fields when provided', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+ const coverImage = new File(['img'], 'cover.png')
+ const profilePicture = new File(['img'], 'avatar.png')
+
+ await gate.createProfile({
+ username: 'testuser',
+ name: 'Test User',
+ description: 'Hello',
+ coverImage,
+ profilePicture
+ })
+
+ const body = mockFetchApi.mock.calls[0][1].body as FormData
+ expect(body.get('name')).toBe('Test User')
+ expect(body.get('description')).toBe('Hello')
+ expect(body.get('cover_image')).toBe(coverImage)
+ expect(body.get('profile_picture')).toBe(profilePicture)
+ })
+
+ it('sets profile state on success', async () => {
+ mockFetchApi.mockResolvedValue(mockSuccessResponse())
+
+ await gate.createProfile({ username: 'testuser' })
+
+ expect(gate.hasProfile.value).toBe(true)
+ expect(gate.profile.value).toEqual(mockProfile)
+ })
+
+ it('returns the created profile', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockSuccessResponse({
+ username: 'testuser',
+ name: 'Test User',
+ description: 'A test profile',
+ cover_image_url: 'https://example.com/cover.png',
+ profile_picture_url: 'https://example.com/profile.png'
+ })
+ )
+
+ const profile = await gate.createProfile({ username: 'testuser' })
+
+ expect(profile).toEqual({
+ ...mockProfile,
+ coverImageUrl: 'https://example.com/cover.png',
+ profilePictureUrl: 'https://example.com/profile.png'
+ })
+ })
+
+ it('throws with error message from API response', async () => {
+ mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
+
+ await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
+ 'Username taken'
+ )
+ })
+ })
+})
diff --git a/src/platform/workflow/sharing/composables/useComfyHubProfileGate.ts b/src/platform/workflow/sharing/composables/useComfyHubProfileGate.ts
new file mode 100644
index 0000000000..e321b4759f
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useComfyHubProfileGate.ts
@@ -0,0 +1,143 @@
+import { ref } from 'vue'
+
+import { useCurrentUser } from '@/composables/auth/useCurrentUser'
+import { useErrorHandling } from '@/composables/useErrorHandling'
+import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
+import type { ComfyHubProfile } from '@/schemas/apiSchema'
+import { api } from '@/scripts/api'
+
+// TODO: Migrate to a Pinia store for proper singleton state management
+// User-scoped, session-cached profile state (module-level singleton)
+const hasProfile = ref(null)
+const isCheckingProfile = ref(false)
+const isFetchingProfile = ref(false)
+const profile = ref(null)
+const cachedUserId = ref(null)
+let inflightFetch: Promise | null = null
+
+function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
+ const result = zHubProfileResponse.safeParse(payload)
+ return result.success ? result.data : null
+}
+
+export function useComfyHubProfileGate() {
+ const { resolvedUserInfo } = useCurrentUser()
+ const { toastErrorHandler } = useErrorHandling()
+
+ function syncCachedProfileWithCurrentUser(): void {
+ const currentUserId = resolvedUserInfo.value?.id ?? null
+ if (cachedUserId.value === currentUserId) {
+ return
+ }
+
+ hasProfile.value = null
+ profile.value = null
+ cachedUserId.value = currentUserId
+ }
+
+ async function performFetch(): Promise {
+ isFetchingProfile.value = true
+ try {
+ const response = await api.fetchApi('/hub/profile')
+ if (!response.ok) {
+ hasProfile.value = false
+ profile.value = null
+ return null
+ }
+
+ const nextProfile = mapHubProfileResponse(await response.json())
+ if (!nextProfile) {
+ hasProfile.value = false
+ profile.value = null
+ return null
+ }
+ hasProfile.value = true
+ profile.value = nextProfile
+ return nextProfile
+ } catch (error) {
+ toastErrorHandler(error)
+ return null
+ } finally {
+ isFetchingProfile.value = false
+ inflightFetch = null
+ }
+ }
+
+ function fetchProfile(options?: {
+ force?: boolean
+ }): Promise {
+ syncCachedProfileWithCurrentUser()
+
+ if (!options?.force && profile.value) {
+ return Promise.resolve(profile.value)
+ }
+
+ if (!options?.force && inflightFetch) return inflightFetch
+
+ inflightFetch = performFetch()
+ return inflightFetch
+ }
+
+ async function checkProfile(): Promise {
+ syncCachedProfileWithCurrentUser()
+
+ if (hasProfile.value !== null) return hasProfile.value
+ isCheckingProfile.value = true
+ try {
+ const fetchedProfile = await fetchProfile()
+ return fetchedProfile !== null
+ } finally {
+ isCheckingProfile.value = false
+ }
+ }
+
+ async function createProfile(data: {
+ username: string
+ name?: string
+ description?: string
+ coverImage?: File
+ profilePicture?: File
+ }): Promise {
+ syncCachedProfileWithCurrentUser()
+
+ const formData = new FormData()
+ formData.append('username', data.username)
+ if (data.name) formData.append('name', data.name)
+ if (data.description) formData.append('description', data.description)
+ if (data.coverImage) formData.append('cover_image', data.coverImage)
+ if (data.profilePicture)
+ formData.append('profile_picture', data.profilePicture)
+
+ const response = await api.fetchApi('/hub/profile', {
+ method: 'POST',
+ body: formData
+ })
+
+ if (!response.ok) {
+ const body: unknown = await response.json().catch(() => ({}))
+ const message =
+ body && typeof body === 'object' && 'message' in body
+ ? String((body as Record).message)
+ : 'Failed to create profile'
+ throw new Error(message)
+ }
+
+ const createdProfile = mapHubProfileResponse(await response.json())
+ if (!createdProfile) {
+ throw new Error('Invalid profile response from server')
+ }
+ hasProfile.value = true
+ profile.value = createdProfile
+ return createdProfile
+ }
+
+ return {
+ hasProfile,
+ profile,
+ isCheckingProfile,
+ isFetchingProfile,
+ checkProfile,
+ fetchProfile,
+ createProfile
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useComfyHubPublishDialog.ts b/src/platform/workflow/sharing/composables/useComfyHubPublishDialog.ts
new file mode 100644
index 0000000000..6c80943fe8
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useComfyHubPublishDialog.ts
@@ -0,0 +1,29 @@
+import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
+import { useDialogService } from '@/services/dialogService'
+import { useDialogStore } from '@/stores/dialogStore'
+
+const DIALOG_KEY = 'global-comfyhub-publish'
+
+export function useComfyHubPublishDialog() {
+ const dialogService = useDialogService()
+ const dialogStore = useDialogStore()
+
+ function hide() {
+ dialogStore.closeDialog({ key: DIALOG_KEY })
+ }
+
+ function show() {
+ dialogService.showLayoutDialog({
+ key: DIALOG_KEY,
+ component: ComfyHubPublishDialog,
+ props: {
+ onClose: hide
+ }
+ })
+ }
+
+ return {
+ show,
+ hide
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.test.ts b/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.test.ts
new file mode 100644
index 0000000000..6acaa164a4
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.test.ts
@@ -0,0 +1,147 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockActiveWorkflow = vi.hoisted(() => ({
+ value: { filename: 'my-workflow.json' } as { filename: string } | null
+}))
+
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({
+ get activeWorkflow() {
+ return mockActiveWorkflow.value
+ }
+ })
+}))
+
+const { useComfyHubPublishWizard } = await import('./useComfyHubPublishWizard')
+
+describe('useComfyHubPublishWizard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockActiveWorkflow.value = { filename: 'my-workflow.json' }
+ })
+
+ describe('createDefaultFormData', () => {
+ it('initialises name from active workflow filename', () => {
+ const { formData } = useComfyHubPublishWizard()
+ expect(formData.value.name).toBe('my-workflow.json')
+ })
+
+ it('defaults name to empty string when no active workflow', () => {
+ mockActiveWorkflow.value = null
+ const { formData } = useComfyHubPublishWizard()
+ expect(formData.value.name).toBe('')
+ })
+
+ it('initialises all other form fields to defaults', () => {
+ const { formData } = useComfyHubPublishWizard()
+ expect(formData.value.description).toBe('')
+ expect(formData.value.workflowType).toBe('')
+ expect(formData.value.tags).toEqual([])
+ expect(formData.value.thumbnailType).toBe('image')
+ expect(formData.value.thumbnailFile).toBeNull()
+ expect(formData.value.comparisonBeforeFile).toBeNull()
+ expect(formData.value.comparisonAfterFile).toBeNull()
+ expect(formData.value.exampleImages).toEqual([])
+ expect(formData.value.selectedExampleIds).toEqual([])
+ })
+ })
+
+ describe('canGoNext', () => {
+ it('returns false on describe step when name is empty', () => {
+ const { canGoNext, formData } = useComfyHubPublishWizard()
+ formData.value.name = ''
+ expect(canGoNext.value).toBe(false)
+ })
+
+ it('returns false on describe step when name is whitespace only', () => {
+ const { canGoNext, formData } = useComfyHubPublishWizard()
+ formData.value.name = ' '
+ expect(canGoNext.value).toBe(false)
+ })
+
+ it('returns true on describe step when name has content', () => {
+ const { canGoNext, formData } = useComfyHubPublishWizard()
+ formData.value.name = 'Valid Name'
+ expect(canGoNext.value).toBe(true)
+ })
+
+ it('returns true on non-describe steps regardless of name', () => {
+ const { canGoNext, goNext, formData } = useComfyHubPublishWizard()
+ formData.value.name = 'something'
+ goNext()
+ formData.value.name = ''
+ expect(canGoNext.value).toBe(true)
+ })
+ })
+
+ describe('step navigation', () => {
+ it('starts on the describe step', () => {
+ const { currentStep, isFirstStep } = useComfyHubPublishWizard()
+ expect(currentStep.value).toBe('describe')
+ expect(isFirstStep.value).toBe(true)
+ })
+
+ it('navigates forward through steps', () => {
+ const { currentStep, goNext } = useComfyHubPublishWizard()
+ expect(currentStep.value).toBe('describe')
+
+ goNext()
+ expect(currentStep.value).toBe('examples')
+
+ goNext()
+ expect(currentStep.value).toBe('finish')
+ })
+
+ it('navigates backward through steps', () => {
+ const { currentStep, goNext, goBack } = useComfyHubPublishWizard()
+ goNext()
+ goNext()
+ expect(currentStep.value).toBe('finish')
+
+ goBack()
+ expect(currentStep.value).toBe('examples')
+
+ goBack()
+ expect(currentStep.value).toBe('describe')
+ })
+
+ it('reports isLastStep correctly on finish step', () => {
+ const { isLastStep, goNext } = useComfyHubPublishWizard()
+ expect(isLastStep.value).toBe(false)
+
+ goNext()
+ expect(isLastStep.value).toBe(false)
+
+ goNext()
+ expect(isLastStep.value).toBe(true)
+ })
+ })
+
+ describe('profile creation step', () => {
+ it('navigates to profileCreation step', () => {
+ const { currentStep, openProfileCreationStep } =
+ useComfyHubPublishWizard()
+ openProfileCreationStep()
+ expect(currentStep.value).toBe('profileCreation')
+ })
+
+ it('reports isProfileCreationStep correctly', () => {
+ const { isProfileCreationStep, openProfileCreationStep } =
+ useComfyHubPublishWizard()
+ expect(isProfileCreationStep.value).toBe(false)
+
+ openProfileCreationStep()
+ expect(isProfileCreationStep.value).toBe(true)
+ })
+
+ it('returns to finish step from profileCreation', () => {
+ const { currentStep, openProfileCreationStep, closeProfileCreationStep } =
+ useComfyHubPublishWizard()
+ openProfileCreationStep()
+ expect(currentStep.value).toBe('profileCreation')
+
+ closeProfileCreationStep()
+ expect(currentStep.value).toBe('finish')
+ })
+ })
+})
diff --git a/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.ts b/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.ts
new file mode 100644
index 0000000000..8602942701
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useComfyHubPublishWizard.ts
@@ -0,0 +1,69 @@
+import { useStepper } from '@vueuse/core'
+import { computed, ref } from 'vue'
+
+import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
+import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
+
+const PUBLISH_STEPS = [
+ 'describe',
+ 'examples',
+ 'finish',
+ 'profileCreation'
+] as const
+
+export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
+
+function createDefaultFormData(): ComfyHubPublishFormData {
+ const { activeWorkflow } = useWorkflowStore()
+ return {
+ name: activeWorkflow?.filename ?? '',
+ description: '',
+ workflowType: '',
+ tags: [],
+ thumbnailType: 'image',
+ thumbnailFile: null,
+ comparisonBeforeFile: null,
+ comparisonAfterFile: null,
+ exampleImages: [],
+ selectedExampleIds: []
+ }
+}
+
+export function useComfyHubPublishWizard() {
+ const stepper = useStepper([...PUBLISH_STEPS])
+ const formData = ref(createDefaultFormData())
+
+ const canGoNext = computed(() => {
+ if (stepper.isCurrent('describe')) {
+ return formData.value.name.trim().length > 0
+ }
+ return true
+ })
+
+ const isLastStep = computed(() => stepper.isCurrent('finish'))
+ const isProfileCreationStep = computed(() =>
+ stepper.isCurrent('profileCreation')
+ )
+
+ function openProfileCreationStep() {
+ stepper.goTo('profileCreation')
+ }
+
+ function closeProfileCreationStep() {
+ stepper.goTo('finish')
+ }
+
+ return {
+ currentStep: stepper.current,
+ formData,
+ canGoNext,
+ isFirstStep: stepper.isFirst,
+ isLastStep,
+ isProfileCreationStep,
+ goToStep: stepper.goTo,
+ goNext: stepper.goToNext,
+ goBack: stepper.goToPrevious,
+ openProfileCreationStep,
+ closeProfileCreationStep
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useShareDialog.ts b/src/platform/workflow/sharing/composables/useShareDialog.ts
new file mode 100644
index 0000000000..8cc90a5401
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useShareDialog.ts
@@ -0,0 +1,36 @@
+import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
+import { useDialogService } from '@/services/dialogService'
+import { useDialogStore } from '@/stores/dialogStore'
+
+const DIALOG_KEY = 'global-share-workflow'
+
+export function useShareDialog() {
+ const dialogService = useDialogService()
+ const dialogStore = useDialogStore()
+
+ function hide() {
+ dialogStore.closeDialog({ key: DIALOG_KEY })
+ }
+
+ function show() {
+ dialogService.showLayoutDialog({
+ key: DIALOG_KEY,
+ component: ShareWorkflowDialogContent,
+ props: {
+ onClose: hide
+ },
+ dialogComponentProps: {
+ pt: {
+ root: {
+ class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
+ }
+ }
+ }
+ })
+ }
+
+ return {
+ show,
+ hide
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
new file mode 100644
index 0000000000..3b10cf520c
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
@@ -0,0 +1,367 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
+import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
+
+const preservedQueryMocks = vi.hoisted(() => ({
+ clearPreservedQuery: vi.fn(),
+ hydratePreservedQuery: vi.fn(),
+ mergePreservedQueryIntoQuery: vi.fn()
+}))
+
+vi.mock(
+ '@/platform/navigation/preservedQueryManager',
+ () => preservedQueryMocks
+)
+
+let mockQueryParams: Record = {}
+const mockRouterReplace = vi.fn()
+
+vi.mock('vue-router', () => ({
+ useRoute: vi.fn(() => ({
+ query: mockQueryParams
+ })),
+ useRouter: vi.fn(() => ({
+ replace: mockRouterReplace
+ }))
+}))
+
+const mockImportPublishedAssets = vi.fn()
+
+vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
+ SharedWorkflowLoadError: class extends Error {
+ readonly isRetryable: boolean
+ constructor(message: string, isRetryable: boolean) {
+ super(message)
+ this.name = 'SharedWorkflowLoadError'
+ this.isRetryable = isRetryable
+ }
+ },
+ useWorkflowShareService: () => ({
+ getSharedWorkflow: vi.fn(),
+ importPublishedAssets: mockImportPublishedAssets
+ })
+}))
+
+const mockLoadGraphData = vi.hoisted(() => vi.fn())
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ loadGraphData: mockLoadGraphData
+ }
+}))
+
+const mockToastAdd = vi.fn()
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => ({
+ add: mockToastAdd
+ })
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: vi.fn((key: string) => {
+ if (key === 'g.error') return 'Error'
+ if (key === 'shareWorkflow.loadFailed') {
+ return 'Failed to load shared workflow'
+ }
+ if (key === 'openSharedWorkflow.dialogTitle') {
+ return 'Open shared workflow'
+ }
+ if (key === 'openSharedWorkflow.importFailed') {
+ return 'Failed to import workflow assets'
+ }
+ return key
+ })
+ })
+}))
+
+const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
+const mockCloseDialog = vi.hoisted(() => vi.fn())
+
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: () => ({
+ showLayoutDialog: mockShowLayoutDialog
+ })
+}))
+
+vi.mock('@/stores/dialogStore', () => ({
+ useDialogStore: () => ({
+ closeDialog: mockCloseDialog
+ })
+}))
+
+function makePayload(
+ overrides: Partial = {}
+): SharedWorkflowPayload {
+ return {
+ shareId: 'share-id-1',
+ workflowId: 'workflow-id-1',
+ name: 'Test Workflow',
+ listed: true,
+ publishedAt: new Date('2026-02-20T00:00:00Z'),
+ workflowJson: {
+ nodes: []
+ } as unknown as SharedWorkflowPayload['workflowJson'],
+ assets: [],
+ ...overrides
+ }
+}
+
+function resolveDialogWithConfirm(payload: SharedWorkflowPayload) {
+ const call = mockShowLayoutDialog.mock.calls.at(-1)
+ if (!call) throw new Error('showLayoutDialog was not called')
+ const options = call[0]
+ options.props.onConfirm(payload)
+}
+
+function resolveDialogWithOpenOnly(payload: SharedWorkflowPayload) {
+ const call = mockShowLayoutDialog.mock.calls.at(-1)
+ if (!call) throw new Error('showLayoutDialog was not called')
+ const options = call[0]
+ options.props.onOpenWithoutImporting(payload)
+}
+
+function resolveDialogWithCancel() {
+ const call = mockShowLayoutDialog.mock.calls.at(-1)
+ if (!call) throw new Error('showLayoutDialog was not called')
+ const options = call[0]
+ options.props.onCancel()
+}
+
+describe('useSharedWorkflowUrlLoader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockQueryParams = {}
+ preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
+ })
+
+ it('does nothing when no share query param is present', async () => {
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+
+ const loaded = await loadSharedWorkflowFromUrl()
+
+ expect(loaded).toBe('not-present')
+ expect(mockShowLayoutDialog).not.toHaveBeenCalled()
+ expect(mockLoadGraphData).not.toHaveBeenCalled()
+ })
+
+ it('opens dialog immediately with shareId and loads graph on confirm', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload()
+ mockShowLayoutDialog.mockImplementation(() => {
+ expect(mockLoadGraphData).not.toHaveBeenCalled()
+ resolveDialogWithConfirm(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ const loaded = await loadSharedWorkflowFromUrl()
+
+ expect(loaded).toBe('loaded')
+ const dialogCall = mockShowLayoutDialog.mock.calls[0][0]
+ expect(dialogCall.props.shareId).toBe('share-id-1')
+ expect(mockLoadGraphData).toHaveBeenCalledWith(
+ { nodes: [] },
+ true,
+ true,
+ 'Test Workflow'
+ )
+ expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
+ expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
+ 'share'
+ )
+ })
+
+ it('does not load graph when user cancels dialog', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithCancel()
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ const loaded = await loadSharedWorkflowFromUrl()
+
+ expect(loaded).toBe('cancelled')
+ expect(mockLoadGraphData).not.toHaveBeenCalled()
+ expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
+ expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
+ 'share'
+ )
+ })
+
+ it('calls import when non-owned assets exist and user confirms', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload({
+ assets: [
+ {
+ id: 'a1',
+ name: 'img.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithConfirm(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ await loadSharedWorkflowFromUrl()
+
+ expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
+ })
+
+ it('does not call import when user chooses open-only', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload({
+ assets: [
+ {
+ id: 'a1',
+ name: 'img.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithOpenOnly(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ await loadSharedWorkflowFromUrl()
+
+ expect(mockLoadGraphData).toHaveBeenCalled()
+ expect(mockImportPublishedAssets).not.toHaveBeenCalled()
+ })
+
+ it('shows toast on import failure and returns loaded-without-assets', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload({
+ assets: [
+ {
+ id: 'm1',
+ name: 'model.safetensors',
+ preview_url: '',
+ storage_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+ mockImportPublishedAssets.mockRejectedValue(new Error('Import failed'))
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithConfirm(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ const loaded = await loadSharedWorkflowFromUrl()
+
+ expect(loaded).toBe('loaded-without-assets')
+ expect(mockToastAdd).toHaveBeenCalledWith(
+ expect.objectContaining({
+ severity: 'error',
+ detail: 'Failed to import workflow assets'
+ })
+ )
+ })
+
+ it('filters out in_library assets before importing', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload({
+ assets: [
+ {
+ id: 'a1',
+ name: 'needed.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'a2',
+ name: 'already-have.png',
+ preview_url: '',
+ storage_url: '',
+ model: false,
+ public: false,
+ in_library: true
+ }
+ ]
+ })
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithConfirm(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ await loadSharedWorkflowFromUrl()
+
+ expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
+ })
+
+ it('restores preserved share query before loading', async () => {
+ preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
+ share: 'preserved-share-id'
+ })
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithConfirm(makePayload({ shareId: 'preserved-share-id' }))
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ await loadSharedWorkflowFromUrl()
+
+ expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
+ 'share'
+ )
+ expect(mockRouterReplace).toHaveBeenCalledWith({
+ query: { share: 'preserved-share-id' }
+ })
+ const dialogCall = mockShowLayoutDialog.mock.calls[0][0]
+ expect(dialogCall.props.shareId).toBe('preserved-share-id')
+ })
+
+ it('rejects invalid share parameter values', async () => {
+ mockQueryParams = { share: '../../../etc/passwd' }
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ const loaded = await loadSharedWorkflowFromUrl()
+
+ expect(loaded).toBe('failed')
+ expect(mockShowLayoutDialog).not.toHaveBeenCalled()
+ expect(mockToastAdd).toHaveBeenCalledWith({
+ severity: 'error',
+ summary: 'Error',
+ detail: 'Failed to load shared workflow',
+ life: 3000
+ })
+ expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
+ expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
+ 'share'
+ )
+ })
+
+ it('uses fallback name when payload name is empty', async () => {
+ mockQueryParams = { share: 'share-id-1' }
+ const payload = makePayload({ name: '' })
+ mockShowLayoutDialog.mockImplementation(() => {
+ resolveDialogWithConfirm(payload)
+ })
+
+ const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
+ await loadSharedWorkflowFromUrl()
+
+ expect(mockLoadGraphData).toHaveBeenCalledWith(
+ expect.anything(),
+ true,
+ true,
+ 'Open shared workflow'
+ )
+ })
+})
diff --git a/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.ts b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.ts
new file mode 100644
index 0000000000..9013947693
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.ts
@@ -0,0 +1,186 @@
+import { useToast } from 'primevue/usetoast'
+import { useI18n } from 'vue-i18n'
+import { useRoute, useRouter } from 'vue-router'
+
+import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
+import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
+import {
+ clearPreservedQuery,
+ hydratePreservedQuery,
+ mergePreservedQueryIntoQuery
+} from '@/platform/navigation/preservedQueryManager'
+import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
+import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
+import { app } from '@/scripts/app'
+import { useDialogService } from '@/services/dialogService'
+import { useDialogStore } from '@/stores/dialogStore'
+
+type SharedWorkflowUrlLoadStatus =
+ | 'not-present'
+ | 'loaded'
+ | 'loaded-without-assets'
+ | 'cancelled'
+ | 'failed'
+
+type DialogResult =
+ | { action: 'copy-and-open'; payload: SharedWorkflowPayload }
+ | { action: 'open-only'; payload: SharedWorkflowPayload }
+ | { action: 'cancel' }
+
+export function useSharedWorkflowUrlLoader() {
+ const route = useRoute()
+ const router = useRouter()
+ const toast = useToast()
+ const { t } = useI18n()
+ const workflowShareService = useWorkflowShareService()
+ const dialogService = useDialogService()
+ const dialogStore = useDialogStore()
+ const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
+
+ function isValidParameter(param: string): boolean {
+ return /^[a-zA-Z0-9_.-]+$/.test(param)
+ }
+
+ async function ensureShareQueryFromIntent() {
+ hydratePreservedQuery(SHARE_NAMESPACE)
+ const mergedQuery = mergePreservedQueryIntoQuery(
+ SHARE_NAMESPACE,
+ route.query
+ )
+
+ if (mergedQuery) {
+ await router.replace({ query: mergedQuery })
+ }
+
+ return mergedQuery ?? route.query
+ }
+
+ function cleanupUrlParams() {
+ const newQuery = { ...route.query }
+ delete newQuery.share
+ void router.replace({ query: newQuery })
+ }
+
+ function showOpenSharedWorkflowDialog(
+ shareId: string
+ ): Promise {
+ const dialogKey = 'open-shared-workflow'
+
+ return new Promise((resolve) => {
+ dialogService.showLayoutDialog({
+ key: dialogKey,
+ component: OpenSharedWorkflowDialogContent,
+ props: {
+ shareId,
+ onConfirm: (payload: SharedWorkflowPayload) => {
+ resolve({ action: 'copy-and-open', payload })
+ dialogStore.closeDialog({ key: dialogKey })
+ },
+ onOpenWithoutImporting: (payload: SharedWorkflowPayload) => {
+ resolve({ action: 'open-only', payload })
+ dialogStore.closeDialog({ key: dialogKey })
+ },
+ onCancel: () => {
+ resolve({ action: 'cancel' })
+ dialogStore.closeDialog({ key: dialogKey })
+ }
+ },
+ dialogComponentProps: {
+ onClose: () => resolve({ action: 'cancel' }),
+ pt: {
+ root: {
+ class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
+ }
+ }
+ }
+ })
+ })
+ }
+
+ async function loadSharedWorkflowFromUrl(): Promise {
+ const query = await ensureShareQueryFromIntent()
+ const shareParam = query.share
+
+ if (shareParam == null) {
+ return 'not-present'
+ }
+
+ if (typeof shareParam !== 'string') {
+ cleanupUrlParams()
+ clearPreservedQuery(SHARE_NAMESPACE)
+ return 'not-present'
+ }
+
+ if (!isValidParameter(shareParam)) {
+ console.warn(
+ `[useSharedWorkflowUrlLoader] Invalid share parameter format: ${shareParam}`
+ )
+ toast.add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('shareWorkflow.loadFailed'),
+ life: 3000
+ })
+ cleanupUrlParams()
+ clearPreservedQuery(SHARE_NAMESPACE)
+ return 'failed'
+ }
+
+ const result = await showOpenSharedWorkflowDialog(shareParam)
+
+ if (result.action === 'cancel') {
+ cleanupUrlParams()
+ clearPreservedQuery(SHARE_NAMESPACE)
+ return 'cancelled'
+ }
+
+ const { payload } = result
+ const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
+ const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
+
+ try {
+ await app.loadGraphData(payload.workflowJson, true, true, workflowName)
+ } catch (error) {
+ console.error(
+ '[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
+ error
+ )
+ toast.add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('shareWorkflow.loadFailed'),
+ life: 5000
+ })
+ return 'failed'
+ }
+
+ if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
+ try {
+ await workflowShareService.importPublishedAssets(
+ nonOwnedAssets.map((a) => a.id)
+ )
+ } catch (importError) {
+ console.error(
+ '[useSharedWorkflowUrlLoader] Failed to import assets:',
+ importError
+ )
+ toast.add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('openSharedWorkflow.importFailed')
+ })
+ cleanupUrlParams()
+ clearPreservedQuery(SHARE_NAMESPACE)
+ return 'loaded-without-assets'
+ }
+ }
+
+ cleanupUrlParams()
+ clearPreservedQuery(SHARE_NAMESPACE)
+ return 'loaded'
+ }
+
+ return {
+ loadSharedWorkflowFromUrl
+ }
+}
diff --git a/src/platform/workflow/sharing/composables/useSliderFromMouse.ts b/src/platform/workflow/sharing/composables/useSliderFromMouse.ts
new file mode 100644
index 0000000000..619ed389bd
--- /dev/null
+++ b/src/platform/workflow/sharing/composables/useSliderFromMouse.ts
@@ -0,0 +1,17 @@
+import type { Ref } from 'vue'
+
+import { useMouseInElement } from '@vueuse/core'
+import { ref, watch } from 'vue'
+
+export function useSliderFromMouse(target: Ref) {
+ const position = ref(50)
+ const { elementX, elementWidth, isOutside } = useMouseInElement(target)
+
+ watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
+ if (!outside && width > 0) {
+ position.value = (x / width) * 100
+ }
+ })
+
+ return position
+}
diff --git a/src/platform/workflow/sharing/constants/comfyHubTags.ts b/src/platform/workflow/sharing/constants/comfyHubTags.ts
new file mode 100644
index 0000000000..445abc446b
--- /dev/null
+++ b/src/platform/workflow/sharing/constants/comfyHubTags.ts
@@ -0,0 +1,81 @@
+import { orderBy } from 'es-toolkit/array'
+
+/**
+ * Curated tag options for ComfyHub workflow publishing.
+ * Sourced from https://github.com/Comfy-Org/workflow_templates/blob/main/templates/index.json
+ *
+ * To regenerate: pnpm dlx tsx temp/scripts/extract-comfyhub-tags.ts
+ */
+const COMFY_HUB_TAG_FREQUENCIES = [
+ { tag: 'API', count: 143 },
+ { tag: 'Video', count: 102 },
+ { tag: 'Image', count: 98 },
+ { tag: 'Text to Image', count: 61 },
+ { tag: 'Image to Video', count: 48 },
+ { tag: 'Image Edit', count: 46 },
+ { tag: 'Text to Video', count: 34 },
+ { tag: 'Audio', count: 20 },
+ { tag: '3D', count: 19 },
+ { tag: 'FLF2V', count: 18 },
+ { tag: 'ControlNet', count: 16 },
+ { tag: 'Image Upscale', count: 16 },
+ { tag: 'Product', count: 11 },
+ { tag: 'Text to Audio', count: 10 },
+ { tag: 'Image to 3D', count: 9 },
+ { tag: 'Inpainting', count: 8 },
+ { tag: 'Character Reference', count: 6 },
+ { tag: 'Video to Video', count: 6 },
+ { tag: 'Video Upscale', count: 6 },
+ { tag: 'Mockup', count: 5 },
+ { tag: 'Outpainting', count: 5 },
+ { tag: 'Preprocessor', count: 5 },
+ { tag: 'Relight', count: 5 },
+ { tag: 'Voice Cloning', count: 5 },
+ { tag: 'Brand Design', count: 4 },
+ { tag: 'Fashion', count: 4 },
+ { tag: 'Image to Model', count: 4 },
+ { tag: 'Portrait', count: 4 },
+ { tag: 'Text to Model', count: 4 },
+ { tag: 'Video Edit', count: 4 },
+ { tag: 'Anime', count: 3 },
+ { tag: 'Audio to Audio', count: 3 },
+ { tag: 'Audio to Video', count: 3 },
+ { tag: 'LLM', count: 3 },
+ { tag: 'Motion Control', count: 3 },
+ { tag: 'Music', count: 3 },
+ { tag: 'Style Reference', count: 3 },
+ { tag: 'Style Transfer', count: 3 },
+ { tag: 'Text to Speech', count: 3 },
+ { tag: '3D Model', count: 2 },
+ { tag: 'Audio Editing', count: 2 },
+ { tag: 'Character', count: 2 },
+ { tag: 'Layer Decompose', count: 2 },
+ { tag: 'Lip Sync', count: 2 },
+ { tag: 'Multiple Angles', count: 2 },
+ { tag: 'Remove Background', count: 2 },
+ { tag: 'Text-to-Image', count: 2 },
+ { tag: 'Vector', count: 2 },
+ { tag: 'Brand', count: 1 },
+ { tag: 'Canny', count: 1 },
+ { tag: 'Depth Map', count: 1 },
+ { tag: 'Frame Interpolation', count: 1 },
+ { tag: 'icon', count: 1 },
+ { tag: 'Image Enhancement', count: 1 },
+ { tag: 'Layout Design', count: 1 },
+ { tag: 'Normal Map', count: 1 },
+ { tag: 'OpenPose', count: 1 },
+ { tag: 'Pose transfer', count: 1 },
+ { tag: 'Replacement', count: 1 },
+ { tag: 'Sound Effects', count: 1 },
+ { tag: 'Speech to Text', count: 1 },
+ { tag: 'Text Generation', count: 1 },
+ { tag: 'Turbo', count: 1 },
+ { tag: 'Video Extension', count: 1 },
+ { tag: 'Voice Isolation', count: 1 }
+] as const
+
+export const COMFY_HUB_TAG_OPTIONS = orderBy(
+ COMFY_HUB_TAG_FREQUENCIES,
+ ['count', 'tag'],
+ ['desc', 'asc']
+).map(({ tag }) => tag)
diff --git a/src/platform/workflow/sharing/schemas/shareSchemas.test.ts b/src/platform/workflow/sharing/schemas/shareSchemas.test.ts
new file mode 100644
index 0000000000..39b5269c45
--- /dev/null
+++ b/src/platform/workflow/sharing/schemas/shareSchemas.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+
+import { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
+
+function makePayload(name: string) {
+ return {
+ share_id: 'share-1',
+ workflow_id: 'wf-1',
+ name,
+ listed: false,
+ publish_time: null,
+ workflow_json: {},
+ assets: []
+ }
+}
+
+describe('zSharedWorkflowResponse name sanitization', () => {
+ it('strips forward slashes from name', () => {
+ const result = zSharedWorkflowResponse.parse(
+ makePayload('../../malicious/path')
+ )
+ expect(result.name).toBe('.._.._malicious_path')
+ })
+
+ it('strips backslashes from name', () => {
+ const result = zSharedWorkflowResponse.parse(
+ makePayload('..\\..\\malicious\\path')
+ )
+ expect(result.name).toBe('.._.._malicious_path')
+ })
+
+ it('strips colons from name', () => {
+ const result = zSharedWorkflowResponse.parse(makePayload('C:\\evil'))
+ expect(result.name).toBe('C__evil')
+ })
+
+ it('truncates names exceeding 200 characters', () => {
+ const longName = 'a'.repeat(300)
+ const result = zSharedWorkflowResponse.parse(makePayload(longName))
+ expect(result.name).toHaveLength(200)
+ })
+
+ it('preserves safe names unchanged', () => {
+ const result = zSharedWorkflowResponse.parse(
+ makePayload('My Cool Workflow (v2)')
+ )
+ expect(result.name).toBe('My Cool Workflow (v2)')
+ })
+
+ it('trims whitespace from sanitized names', () => {
+ const result = zSharedWorkflowResponse.parse(makePayload(' spaced name '))
+ expect(result.name).toBe('spaced name')
+ })
+})
diff --git a/src/platform/workflow/sharing/schemas/shareSchemas.ts b/src/platform/workflow/sharing/schemas/shareSchemas.ts
new file mode 100644
index 0000000000..516ef401a3
--- /dev/null
+++ b/src/platform/workflow/sharing/schemas/shareSchemas.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod'
+
+import { zAssetInfo, zComfyHubProfile } from '@/schemas/apiSchema'
+
+export const zPublishRecordResponse = z.object({
+ workflow_id: z.string(),
+ share_id: z.string().nullable(),
+ listed: z.boolean(),
+ publish_time: z.string().nullable(),
+ assets: z.array(zAssetInfo).optional()
+})
+
+/**
+ * Strips path separators and control characters from a workflow name to prevent
+ * path traversal when the name is later used as part of a file path.
+ */
+function sanitizeWorkflowName(name: string): string {
+ return name
+ .replaceAll(/[/\\:]/g, '_')
+ .slice(0, 200)
+ .trim()
+}
+
+export const zSharedWorkflowResponse = z.object({
+ share_id: z.string(),
+ workflow_id: z.string(),
+ name: z.string().transform(sanitizeWorkflowName),
+ listed: z.boolean(),
+ publish_time: z.string().nullable(),
+ workflow_json: z.record(z.string(), z.unknown()),
+ assets: z.array(zAssetInfo)
+})
+
+export const zHubProfileResponse = z.preprocess((data) => {
+ if (!data || typeof data !== 'object') return data
+ const d = data as Record
+ return {
+ username: d.username,
+ name: d.name,
+ description: d.description,
+ coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
+ profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
+ }
+}, zComfyHubProfile)
diff --git a/src/platform/workflow/sharing/services/workflowShareService.test.ts b/src/platform/workflow/sharing/services/workflowShareService.test.ts
new file mode 100644
index 0000000000..49dfc03a73
--- /dev/null
+++ b/src/platform/workflow/sharing/services/workflowShareService.test.ts
@@ -0,0 +1,437 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AssetInfo } from '@/schemas/apiSchema'
+import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
+
+const mockApp = vi.hoisted(() => ({
+ rootGraph: {} as object | null,
+ graphToPrompt: vi.fn()
+}))
+
+vi.mock('@/scripts/app', () => ({
+ app: mockApp
+}))
+
+const mockGetShareableAssets = vi.fn()
+const mockFetchApi = vi.fn()
+
+vi.mock(
+ '@/platform/workflow/validation/schemas/workflowSchema',
+ async (importOriginal) => ({
+ ...(await importOriginal()),
+ validateComfyWorkflow: vi.fn(async (json: unknown) => json)
+ })
+)
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ getShareableAssets: (...args: unknown[]) => mockGetShareableAssets(...args),
+ fetchApi: (...args: unknown[]) => mockFetchApi(...args),
+ apiURL: (route: string) => `/api${route}`,
+ fileURL: (route: string) => route
+ }
+}))
+
+describe(useWorkflowShareService, () => {
+ const mockShareableAssets: AssetInfo[] = [
+ {
+ id: 'asset-1',
+ name: 'asset.png',
+ storage_url: '',
+ preview_url: '',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-1',
+ name: 'model.safetensors',
+ storage_url: '',
+ preview_url: '',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+
+ function mockJsonResponse(payload: unknown, ok = true, status = 200) {
+ return {
+ ok,
+ status,
+ json: async () => payload
+ } as Response
+ }
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ mockApp.rootGraph = {}
+ window.history.replaceState({}, '', '/')
+ })
+
+ it('returns unpublished status for unknown workflow', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'wf-0',
+ publish_time: null,
+ share_id: null,
+ listed: false
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const status = await service.getPublishStatus('unknown-id')
+
+ expect(status.isPublished).toBe(false)
+ expect(status.shareId).toBeNull()
+ expect(status.shareUrl).toBeNull()
+ expect(status.publishedAt).toBeNull()
+ })
+
+ it('publishes a workflow and returns a share URL', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'test-workflow',
+ share_id: 'abc123',
+ publish_time: '2026-02-23T00:00:00Z',
+ listed: false,
+ assets: []
+ })
+ )
+
+ const service = useWorkflowShareService()
+
+ const result = await service.publishWorkflow(
+ 'test-workflow',
+ mockShareableAssets
+ )
+
+ expect(result.shareId).toBe('abc123')
+ expect(result.shareUrl).toBe(`${window.location.origin}/?share=abc123`)
+ expect(result.publishedAt).toBeInstanceOf(Date)
+ expect(mockFetchApi).toHaveBeenCalledWith(
+ '/userdata/test-workflow/publish',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ asset_ids: ['asset-1', 'model-1']
+ })
+ }
+ )
+ })
+
+ it('preserves app subpath when normalizing published share URLs', async () => {
+ window.history.replaceState({}, '', '/comfy/subpath/?foo=bar#section')
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'test-workflow',
+ share_id: 'subpath-id',
+ publish_time: '2026-02-23T00:00:00Z',
+ listed: false,
+ assets: []
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const result = await service.publishWorkflow(
+ 'test-workflow',
+ mockShareableAssets
+ )
+
+ expect(result.shareUrl).toBe(
+ `${window.location.origin}/comfy/subpath/?share=subpath-id`
+ )
+ })
+
+ it('reports published status after publishing', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'wf-1',
+ share_id: 'wf-1',
+ publish_time: '2026-02-23T00:00:00Z',
+ listed: false,
+ assets: []
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const status = await service.getPublishStatus('wf-1')
+
+ expect(status.isPublished).toBe(true)
+ expect(status.shareId).toBe('wf-1')
+ expect(status.shareUrl).toBe(`${window.location.origin}/?share=wf-1`)
+ expect(status.publishedAt).toBeInstanceOf(Date)
+ })
+
+ it('preserves app subpath when normalizing publish status share URLs', async () => {
+ window.history.replaceState({}, '', '/comfy/subpath/')
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'wf-subpath',
+ share_id: 'wf-subpath',
+ publish_time: '2026-02-23T00:00:00Z',
+ listed: false,
+ assets: []
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const status = await service.getPublishStatus('wf-subpath')
+
+ expect(status.shareUrl).toBe(
+ `${window.location.origin}/comfy/subpath/?share=wf-subpath`
+ )
+ })
+
+ it('returns unpublished when publish record has no share id', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ workflow_id: 'wf-2',
+ share_id: null,
+ publish_time: '2026-02-23T00:00:00Z',
+ listed: false
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const status = await service.getPublishStatus('wf-2')
+
+ expect(status.isPublished).toBe(false)
+ expect(status.shareId).toBeNull()
+ })
+
+ it('fetches and maps shared workflow payload', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({
+ share_id: 'share-123',
+ workflow_id: 'wf-123',
+ name: 'Test Workflow',
+ listed: true,
+ publish_time: '2026-02-23T00:00:00Z',
+ workflow_json: { nodes: [] },
+ assets: [
+ {
+ id: 'asset-1',
+ name: 'asset.png',
+ preview_url: 'https://example.com/a.jpg',
+ storage_url: 'storage/a',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+ )
+
+ const service = useWorkflowShareService()
+ const shared = await service.getSharedWorkflow('share-123')
+
+ expect(mockFetchApi).toHaveBeenCalledWith('/workflows/published/share-123')
+ expect(shared).toEqual({
+ shareId: 'share-123',
+ workflowId: 'wf-123',
+ name: 'Test Workflow',
+ listed: true,
+ publishedAt: new Date('2026-02-23T00:00:00Z'),
+ workflowJson: { nodes: [] },
+ assets: [
+ {
+ id: 'asset-1',
+ name: 'asset.png',
+ preview_url: 'https://example.com/a.jpg',
+ storage_url: 'storage/a',
+ model: false,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+ })
+
+ it('throws when shared workflow request fails', async () => {
+ mockFetchApi.mockResolvedValue(mockJsonResponse({}, false, 404))
+
+ const service = useWorkflowShareService()
+
+ await expect(service.getSharedWorkflow('missing')).rejects.toThrow(
+ 'Failed to load shared workflow: 404'
+ )
+ })
+
+ it('imports published assets via POST /assets/import', async () => {
+ mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
+
+ const service = useWorkflowShareService()
+ await service.importPublishedAssets(['pa-1', 'pa-2'])
+
+ expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ published_asset_ids: ['pa-1', 'pa-2'] })
+ })
+ })
+
+ it('throws when import request fails', async () => {
+ mockFetchApi.mockResolvedValue(mockJsonResponse({}, false, 400))
+
+ const service = useWorkflowShareService()
+
+ await expect(service.importPublishedAssets(['bad-id'])).rejects.toThrow(
+ 'Failed to import assets: 400'
+ )
+ })
+
+ it('throws when shared workflow payload is invalid', async () => {
+ mockFetchApi.mockResolvedValue(
+ mockJsonResponse({ name: 'Invalid', version: 1 })
+ )
+
+ const service = useWorkflowShareService()
+
+ await expect(service.getSharedWorkflow('invalid')).rejects.toThrow(
+ 'Failed to load shared workflow: invalid response'
+ )
+ })
+
+ it('treats malformed publish-status payload as unpublished', async () => {
+ mockFetchApi.mockResolvedValue(mockJsonResponse({ is_published: true }))
+
+ const service = useWorkflowShareService()
+ const status = await service.getPublishStatus('wf-4')
+
+ expect(status).toEqual({
+ isPublished: false,
+ shareId: null,
+ shareUrl: null,
+ publishedAt: null
+ })
+ })
+
+ it('returns empty results when no graph exists', async () => {
+ mockApp.rootGraph = null
+
+ const service = useWorkflowShareService()
+ const result = await service.getShareableAssets()
+
+ expect(result).toEqual([])
+ expect(mockApp.graphToPrompt).not.toHaveBeenCalled()
+ })
+
+ it('calls backend API with graph prompt output', async () => {
+ mockApp.graphToPrompt.mockResolvedValue({ output: { '1': {} } })
+ mockGetShareableAssets.mockResolvedValue({ assets: [] })
+
+ const service = useWorkflowShareService()
+ await service.getShareableAssets()
+
+ expect(mockGetShareableAssets).toHaveBeenCalledWith({ '1': {} })
+ })
+
+ it('propagates error when graphToPrompt fails', async () => {
+ mockApp.graphToPrompt.mockRejectedValue(new Error('prompt failed'))
+
+ const service = useWorkflowShareService()
+
+ await expect(service.getShareableAssets()).rejects.toThrow('prompt failed')
+ })
+
+ it('normalizes backend thumbnail field names', async () => {
+ mockApp.graphToPrompt.mockResolvedValue({ output: {} })
+ mockGetShareableAssets.mockResolvedValue({
+ assets: [
+ {
+ id: 'asset-server-1',
+ name: 'server-asset.png',
+ preview_url: 'https://example.com/a.jpg',
+ storage_url: 'storage/a',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-server-1',
+ name: 'server-model.safetensors',
+ preview_url: 'https://example.com/m.jpg',
+ storage_url: 'storage/m',
+ model: true,
+ public: false,
+ in_library: true
+ }
+ ]
+ })
+
+ const service = useWorkflowShareService()
+ const result = await service.getShareableAssets()
+
+ expect(result).toEqual([
+ {
+ id: 'asset-server-1',
+ name: 'server-asset.png',
+ preview_url: 'https://example.com/a.jpg',
+ storage_url: 'storage/a',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-server-1',
+ name: 'server-model.safetensors',
+ preview_url: 'https://example.com/m.jpg',
+ storage_url: 'storage/m',
+ model: true,
+ public: false,
+ in_library: true
+ }
+ ])
+ })
+
+ it('returns assets with preview_url intact', async () => {
+ mockApp.graphToPrompt.mockResolvedValue({ output: {} })
+ mockGetShareableAssets.mockResolvedValue({
+ assets: [
+ {
+ id: 'asset-1',
+ name: 'asset.png',
+ preview_url: '/view?filename=asset.png',
+ storage_url: 'storage/asset',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-1',
+ name: 'model.safetensors',
+ preview_url: '/api/assets/model-thumb',
+ storage_url: 'storage/model',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ]
+ })
+
+ const service = useWorkflowShareService()
+ const result = await service.getShareableAssets()
+
+ expect(result).toEqual([
+ {
+ id: 'asset-1',
+ name: 'asset.png',
+ preview_url: '/view?filename=asset.png',
+ storage_url: 'storage/asset',
+ model: false,
+ public: false,
+ in_library: false
+ },
+ {
+ id: 'model-1',
+ name: 'model.safetensors',
+ preview_url: '/api/assets/model-thumb',
+ storage_url: 'storage/model',
+ model: true,
+ public: false,
+ in_library: false
+ }
+ ])
+ })
+})
diff --git a/src/platform/workflow/sharing/services/workflowShareService.ts b/src/platform/workflow/sharing/services/workflowShareService.ts
new file mode 100644
index 0000000000..f604d0e790
--- /dev/null
+++ b/src/platform/workflow/sharing/services/workflowShareService.ts
@@ -0,0 +1,207 @@
+import type {
+ SharedWorkflowPayload,
+ WorkflowPublishResult,
+ WorkflowPublishStatus
+} from '@/platform/workflow/sharing/types/shareTypes'
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
+import type { AssetInfo } from '@/schemas/apiSchema'
+import {
+ zPublishRecordResponse,
+ zSharedWorkflowResponse
+} from '@/platform/workflow/sharing/schemas/shareSchemas'
+import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
+
+class SharedWorkflowLoadError extends Error {
+ readonly status: number | null
+
+ constructor(status: number | null, message?: string) {
+ super(message ?? `Failed to load shared workflow: ${status ?? 'unknown'}`)
+ this.name = 'SharedWorkflowLoadError'
+ this.status = status
+ }
+
+ get isRetryable(): boolean {
+ if (this.status === null) return true
+ return this.status >= 500 || this.status === 408 || this.status === 429
+ }
+}
+
+function decodePublishRecord(payload: unknown) {
+ const result = zPublishRecordResponse.safeParse(payload)
+ if (!result.success) return null
+ const r = result.data
+ return {
+ workflowId: r.workflow_id,
+ shareId: r.share_id ?? undefined,
+ listed: r.listed,
+ publishedAt: parsePublishedAt(r.publish_time),
+ shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
+ }
+}
+
+function parsePublishedAt(value: string | null | undefined): Date | null {
+ if (!value) return null
+
+ const parsed = new Date(value)
+ return Number.isNaN(parsed.getTime()) ? null : parsed
+}
+
+function normalizeShareUrl(shareId: string): string {
+ const queryString = `share=${encodeURIComponent(shareId)}`
+ if (typeof window === 'undefined' || !window.location?.origin) {
+ return `/?${queryString}`
+ }
+
+ const normalizedUrl = new URL(window.location.href)
+ normalizedUrl.search = queryString
+ normalizedUrl.hash = ''
+ return normalizedUrl.toString()
+}
+
+function decodeSharedWorkflowPayload(
+ payload: unknown
+): SharedWorkflowPayload | null {
+ const result = zSharedWorkflowResponse.safeParse(payload)
+ if (!result.success) return null
+ const r = result.data
+ return {
+ shareId: r.share_id,
+ workflowId: r.workflow_id,
+ name: r.name,
+ listed: r.listed,
+ publishedAt: r.publish_time ? parsePublishedAt(r.publish_time) : null,
+ workflowJson: r.workflow_json as ComfyWorkflowJSON,
+ assets: r.assets
+ }
+}
+
+const UNPUBLISHED = {
+ isPublished: false,
+ shareId: null,
+ shareUrl: null,
+ publishedAt: null
+} as const satisfies WorkflowPublishStatus
+
+export function useWorkflowShareService() {
+ async function publishWorkflow(
+ workflowPath: string,
+ shareableAssets: AssetInfo[]
+ ): Promise {
+ const assetIds = shareableAssets.map((a) => a.id)
+ const response = await api.fetchApi(
+ `/userdata/${encodeURIComponent(workflowPath)}/publish`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ asset_ids: assetIds })
+ }
+ )
+
+ if (!response.ok) {
+ throw new Error(`Failed to publish workflow: ${response.status}`)
+ }
+
+ const record = decodePublishRecord(await response.json())
+ if (!record?.shareId || !record.publishedAt) {
+ throw new Error('Failed to publish workflow: invalid response')
+ }
+
+ return {
+ shareId: record.shareId,
+ shareUrl: normalizeShareUrl(record.shareId),
+ publishedAt: record.publishedAt
+ }
+ }
+
+ async function getPublishStatus(
+ workflowPath: string
+ ): Promise {
+ const response = await api.fetchApi(
+ `/userdata/${encodeURIComponent(workflowPath)}/publish`
+ )
+ if (!response.ok) {
+ if (response.status === 404) return UNPUBLISHED
+ throw new Error(
+ `Failed to fetch publish status: ${response.status} ${response.statusText}`
+ )
+ }
+
+ const json = await response.json()
+ const record = decodePublishRecord(json)
+ if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
+
+ return {
+ isPublished: true,
+ shareId: record.shareId,
+ shareUrl: normalizeShareUrl(record.shareId),
+ publishedAt: record.publishedAt
+ }
+ }
+
+ async function getShareableAssets(
+ includingPublic = false
+ ): Promise {
+ const graph = app.rootGraph
+ if (!graph) return []
+
+ const { output } = await app.graphToPrompt(graph)
+ const { assets } = await api.getShareableAssets(output)
+
+ return includingPublic ? assets : assets.filter((asset) => !asset.public)
+ }
+
+ async function getSharedWorkflow(
+ shareId: string
+ ): Promise {
+ let response: Response
+ try {
+ response = await api.fetchApi(
+ `/workflows/published/${encodeURIComponent(shareId)}`
+ )
+ } catch {
+ throw new SharedWorkflowLoadError(
+ null,
+ 'Failed to load shared workflow: network error'
+ )
+ }
+
+ if (!response.ok) {
+ throw new SharedWorkflowLoadError(response.status)
+ }
+
+ const workflow = decodeSharedWorkflowPayload(await response.json())
+ if (!workflow) {
+ throw new Error('Failed to load shared workflow: invalid response')
+ }
+
+ const validated = await validateComfyWorkflow(workflow.workflowJson)
+ if (!validated) {
+ throw new Error('Failed to load shared workflow: invalid workflow data')
+ }
+ workflow.workflowJson = validated
+
+ return workflow
+ }
+
+ async function importPublishedAssets(assetIds: string[]): Promise {
+ const response = await api.fetchApi('/assets/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ published_asset_ids: assetIds })
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to import assets: ${response.status}`)
+ }
+ }
+
+ return {
+ publishWorkflow,
+ getPublishStatus,
+ getShareableAssets,
+ getSharedWorkflow,
+ importPublishedAssets
+ }
+}
diff --git a/src/platform/workflow/sharing/types/comfyHubTypes.ts b/src/platform/workflow/sharing/types/comfyHubTypes.ts
new file mode 100644
index 0000000000..f102324565
--- /dev/null
+++ b/src/platform/workflow/sharing/types/comfyHubTypes.ts
@@ -0,0 +1,26 @@
+export type ThumbnailType = 'image' | 'video' | 'imageComparison'
+
+export type ComfyHubWorkflowType =
+ | 'imageGeneration'
+ | 'videoGeneration'
+ | 'upscaling'
+ | 'editing'
+
+export interface ExampleImage {
+ id: string
+ url: string
+ file?: File
+}
+
+export interface ComfyHubPublishFormData {
+ name: string
+ description: string
+ workflowType: ComfyHubWorkflowType | ''
+ tags: string[]
+ thumbnailType: ThumbnailType
+ thumbnailFile: File | null
+ comparisonBeforeFile: File | null
+ comparisonAfterFile: File | null
+ exampleImages: ExampleImage[]
+ selectedExampleIds: string[]
+}
diff --git a/src/platform/workflow/sharing/types/shareTypes.ts b/src/platform/workflow/sharing/types/shareTypes.ts
new file mode 100644
index 0000000000..5dc0fb1781
--- /dev/null
+++ b/src/platform/workflow/sharing/types/shareTypes.ts
@@ -0,0 +1,27 @@
+import type { AssetInfo } from '@/schemas/apiSchema'
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+
+export interface WorkflowPublishResult {
+ publishedAt: Date
+ shareId: string
+ shareUrl: string
+}
+
+export type WorkflowPublishStatus =
+ | { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
+ | {
+ isPublished: true
+ publishedAt: Date
+ shareId: string
+ shareUrl: string
+ }
+
+export interface SharedWorkflowPayload {
+ assets: AssetInfo[]
+ listed: boolean
+ name: string
+ publishedAt: Date | null
+ shareId: string
+ workflowId: string
+ workflowJson: ComfyWorkflowJSON
+}
diff --git a/src/platform/workflow/sharing/utils/validateFileSize.ts b/src/platform/workflow/sharing/utils/validateFileSize.ts
new file mode 100644
index 0000000000..14c4694e40
--- /dev/null
+++ b/src/platform/workflow/sharing/utils/validateFileSize.ts
@@ -0,0 +1,20 @@
+import { t } from '@/i18n'
+import { useToastStore } from '@/platform/updates/common/toastStore'
+
+export const MAX_IMAGE_SIZE_MB = 10
+export const MAX_VIDEO_SIZE_MB = 50
+
+export function isFileTooLarge(file: File, maxSizeMB: number): boolean {
+ const fileSizeMB = file.size / 1024 / 1024
+ if (fileSizeMB <= maxSizeMB) return false
+
+ useToastStore().add({
+ severity: 'error',
+ summary: t('g.error'),
+ detail: t('toastMessages.fileTooLarge', {
+ size: fileSizeMB.toFixed(1),
+ maxSize: maxSizeMB
+ })
+ })
+ return true
+}
diff --git a/src/router.ts b/src/router.ts
index 22378d813a..538364c740 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -99,6 +99,10 @@ installPreservedQueryTracker(router, [
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source', 'mode']
},
+ {
+ namespace: PRESERVED_QUERY_NAMESPACES.SHARE,
+ keys: ['share']
+ },
{
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
keys: ['invite']
diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts
index 0c581512f9..20856b9b01 100644
--- a/src/schemas/apiSchema.ts
+++ b/src/schemas/apiSchema.ts
@@ -480,3 +480,31 @@ export type UserDataFullInfo = z.infer
export type TerminalSize = z.infer
export type LogEntry = z.infer
export type LogsRawResponse = z.infer
+
+export const zComfyHubProfile = z.object({
+ username: z.string(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ coverImageUrl: z.string().nullish(),
+ profilePictureUrl: z.string().nullish()
+})
+
+export type ComfyHubProfile = z.infer
+
+export const zAssetInfo = z.object({
+ id: z.string(),
+ name: z.string(),
+ preview_url: z.string(),
+ storage_url: z.string(),
+ model: z.boolean(),
+ public: z.boolean(),
+ in_library: z.boolean()
+})
+
+export type AssetInfo = z.infer
+
+export const zShareableAssetsResponse = z.object({
+ assets: z.array(zAssetInfo)
+})
+
+export type ShareableAssetsResponse = z.infer
diff --git a/src/scripts/api.ts b/src/scripts/api.ts
index 0ffaa96ac3..ebe23e4264 100644
--- a/src/scripts/api.ts
+++ b/src/scripts/api.ts
@@ -12,6 +12,8 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
+import type { ShareableAssetsResponse } from '@/schemas/apiSchema'
+import { zShareableAssetsResponse } from '@/schemas/apiSchema'
import type { IFuseOptions } from 'fuse.js'
import type {
TemplateIncludeOnDistributionEnum,
@@ -873,6 +875,30 @@ export class ComfyApi extends EventTarget {
return await res.json()
}
+ /**
+ * Gets the list of assets and models referenced by a prompt that would
+ * need user consent before sharing.
+ */
+ async getShareableAssets(
+ prompt: ComfyApiWorkflow,
+ options?: { owned?: boolean }
+ ): Promise {
+ const body: Record = { workflow_api_json: prompt }
+ if (options?.owned !== undefined) {
+ body.owned = options.owned
+ }
+ const res = await this.fetchApi('/assets/from-workflow', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+ if (res.status !== 200) {
+ throw new Error(`Failed to fetch shareable assets: ${res.status}`)
+ }
+ const data = await res.json()
+ return zShareableAssetsResponse.parse(data)
+ }
+
/**
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
* @returns The list of model folder keys
diff --git a/src/stores/userFileStore.ts b/src/stores/userFileStore.ts
index f2742f3e3a..92c2d14a24 100644
--- a/src/stores/userFileStore.ts
+++ b/src/stores/userFileStore.ts
@@ -8,6 +8,19 @@ import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { buildTree } from '@/utils/treeUtil'
+/**
+ * Normalizes a timestamp value that may be either a number (milliseconds)
+ * or an ISO 8601 string (from Go's time.Time JSON serialization) into
+ * a consistent millisecond timestamp.
+ */
+function normalizeTimestamp(value: number | string): number {
+ if (typeof value === 'string') {
+ const ms = new Date(value).getTime()
+ return Number.isNaN(ms) ? Date.now() : ms
+ }
+ return value
+}
+
/**
* Represents a file in the user's data directory.
*/
@@ -140,7 +153,7 @@ export class UserFile {
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
- this.lastModified = updatedFile.modified
+ this.lastModified = normalizeTimestamp(updatedFile.modified)
this.size = updatedFile.size
}
this.originalContent = this.content
@@ -175,7 +188,7 @@ export class UserFile {
// https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
- this.lastModified = updatedFile.modified
+ this.lastModified = normalizeTimestamp(updatedFile.modified)
this.size = updatedFile.size
}
return this