mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 19:51:05 +00:00
Compare commits
1 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e26b4da6c5 |
@@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
mockFetchApi: vi.fn(),
|
||||
mockAddAlert: vi.fn(),
|
||||
mockUpdateInputs: vi.fn()
|
||||
}))
|
||||
const { mockFetchApi, mockAddAlert, mockUpdateInputs, mockSetNodeUploading } =
|
||||
vi.hoisted(() => ({
|
||||
mockFetchApi: vi.fn(),
|
||||
mockAddAlert: vi.fn(),
|
||||
mockUpdateInputs: vi.fn(),
|
||||
mockSetNodeUploading: vi.fn()
|
||||
}))
|
||||
|
||||
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
|
||||
|
||||
@@ -43,6 +45,10 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ setNodeUploading: mockSetNodeUploading })
|
||||
}))
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
isUploading: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
@@ -92,12 +93,15 @@ export const useNodeImageUpload = (
|
||||
}
|
||||
}
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const handleUploadBatch = async (files: File[]) => {
|
||||
if (node.isUploading) {
|
||||
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
|
||||
return []
|
||||
}
|
||||
node.isUploading = true
|
||||
nodeOutputStore.setNodeUploading(node.id, true)
|
||||
|
||||
try {
|
||||
node.imgs = undefined
|
||||
@@ -114,6 +118,7 @@ export const useNodeImageUpload = (
|
||||
return validPaths
|
||||
} finally {
|
||||
node.isUploading = false
|
||||
nodeOutputStore.setNodeUploading(node.id, false)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"control_before_generate": "control before generate",
|
||||
"choose_file_to_upload": "choose file to upload",
|
||||
"uploadAlreadyInProgress": "Upload already in progress",
|
||||
"uploading": "Uploading",
|
||||
"capture": "capture",
|
||||
"nodes": "Nodes",
|
||||
"nodesCount": "{count} node | {count} nodes",
|
||||
|
||||
@@ -99,9 +99,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
storeThumbnail: vi.fn(),
|
||||
getThumbnail: vi.fn(),
|
||||
clearThumbnail: vi.fn(),
|
||||
moveWorkflowThumbnail: vi.fn()
|
||||
getThumbnail: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -855,13 +853,12 @@ describe('useWorkflowService', () => {
|
||||
|
||||
const existing = createSaveableWorkflow('workflows/test.app.json')
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(existing)
|
||||
vi.spyOn(workflowStore, 'removeWorkflowEntry').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'deleteWorkflow').mockResolvedValue()
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflow(workflow)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled()
|
||||
expect(workflowStore.removeWorkflowEntry).toHaveBeenCalledWith(existing)
|
||||
expect(workflowStore.renameWorkflow).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
'workflows/test.app.json'
|
||||
|
||||
@@ -130,20 +130,11 @@ export const useWorkflowService = () => {
|
||||
|
||||
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
||||
if ((await confirmOverwrite(newPath)) !== true) return false
|
||||
}
|
||||
|
||||
const needsOverwrite =
|
||||
!!existingWorkflow && !existingWorkflow.isTemporary && !isSelfOverwrite
|
||||
|
||||
// Close and remove the old workflow entry before saving the new content.
|
||||
// The file on disk is intentionally kept so that a save failure does not
|
||||
// cause data loss. The subsequent save with overwrite: true will
|
||||
// atomically replace it.
|
||||
if (needsOverwrite) {
|
||||
if (workflowStore.isOpen(existingWorkflow)) {
|
||||
await closeWorkflow(existingWorkflow, { warnIfUnsaved: false })
|
||||
if (!isSelfOverwrite) {
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
if (!deleted) return false
|
||||
}
|
||||
await workflowStore.removeWorkflowEntry(existingWorkflow)
|
||||
}
|
||||
|
||||
workflow.changeTracker?.checkState()
|
||||
@@ -152,19 +143,11 @@ export const useWorkflowService = () => {
|
||||
await saveWorkflow(workflow)
|
||||
} else if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
if (needsOverwrite) {
|
||||
await workflowStore.saveWorkflow(workflow, { overwrite: true })
|
||||
} else {
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
}
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(tempWorkflow)
|
||||
if (needsOverwrite) {
|
||||
await workflowStore.saveWorkflow(tempWorkflow, { overwrite: true })
|
||||
} else {
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -191,12 +174,7 @@ export const useWorkflowService = () => {
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
return
|
||||
}
|
||||
// Remove the old entry without deleting the file; the rename
|
||||
// will atomically replace it, preventing data loss on failure.
|
||||
if (workflowStore.isOpen(existing)) {
|
||||
await closeWorkflow(existing, { warnIfUnsaved: false })
|
||||
}
|
||||
await workflowStore.removeWorkflowEntry(existing)
|
||||
await deleteWorkflow(existing, true)
|
||||
}
|
||||
await renameWorkflow(workflow, expectedPath)
|
||||
toastStore.add({
|
||||
|
||||
@@ -151,16 +151,14 @@ export class ComfyWorkflow extends UserFile {
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save({
|
||||
overwrite
|
||||
}: { force?: boolean; overwrite?: boolean } = {}) {
|
||||
override async save() {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true, overwrite })
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
draftStore.removeDraft(this.path)
|
||||
|
||||
@@ -63,11 +63,7 @@ interface WorkflowStore {
|
||||
) => ComfyWorkflow
|
||||
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
|
||||
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
removeWorkflowEntry: (workflow: ComfyWorkflow) => Promise<void>
|
||||
saveWorkflow: (
|
||||
workflow: ComfyWorkflow,
|
||||
options?: { overwrite?: boolean }
|
||||
) => Promise<void>
|
||||
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
|
||||
workflows: ComfyWorkflow[]
|
||||
bookmarkedWorkflows: ComfyWorkflow[]
|
||||
@@ -543,32 +539,14 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a workflow entry from the store without deleting the file on disk.
|
||||
* Used during atomic overwrite to clear the old entry before saving the new
|
||||
* content, so that the save can use overwrite: true to replace the file.
|
||||
*/
|
||||
const removeWorkflowEntry = async (workflow: ComfyWorkflow) => {
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||
}
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a workflow.
|
||||
* @param workflow The workflow to save.
|
||||
* @param options.overwrite Force overwrite of existing file at the path.
|
||||
*/
|
||||
const saveWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options?: { overwrite?: boolean }
|
||||
) => {
|
||||
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
await workflow.save({ overwrite: options?.overwrite })
|
||||
await workflow.save()
|
||||
// Synchronously detach and re-attach to force refresh the tree objects
|
||||
// without an async gap that would cause the tab to disappear.
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
@@ -796,7 +774,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
createNewTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
removeWorkflowEntry,
|
||||
saveAs,
|
||||
saveWorkflow,
|
||||
reorderWorkflows,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
v-if="!imageError && !isUploading"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@@ -45,6 +45,16 @@
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Upload Spinner Overlay -->
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-8 animate-spin text-base-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
@@ -85,7 +95,10 @@
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="pt-2 text-center text-xs text-base-foreground">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
<span v-if="isUploading" class="text-base-foreground">
|
||||
{{ $t('g.uploading') }}...
|
||||
</span>
|
||||
<span v-else-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="showLoader" class="text-base-foreground">
|
||||
@@ -134,6 +147,8 @@ interface ImagePreviewProps {
|
||||
readonly imageUrls: readonly string[]
|
||||
/** Optional node ID for context-aware actions */
|
||||
readonly nodeId?: string
|
||||
/** Whether a file is being uploaded to this node */
|
||||
readonly isUploading?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<ImagePreviewProps>()
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
v-if="nodeMedia"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
:is-uploading="isNodeUploading"
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
@@ -744,6 +745,8 @@ const nodeMedia = computed(() => {
|
||||
return { type, urls } as const
|
||||
})
|
||||
|
||||
const isNodeUploading = computed(() => nodeOutputs.isNodeUploading(nodeData.id))
|
||||
|
||||
const nodeContainerRef = ref<HTMLDivElement>()
|
||||
|
||||
// Drag and drop support
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
v-else-if="hasMedia && media?.type === 'image'"
|
||||
:image-urls="media.urls"
|
||||
:node-id="nodeId"
|
||||
:is-uploading="isUploading"
|
||||
class="mt-2 flex-auto"
|
||||
/>
|
||||
</slot>
|
||||
@@ -43,14 +44,15 @@ interface NodeContentProps {
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
isUploading?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeContentProps>()
|
||||
const { nodeData, media, isUploading = false } = defineProps<NodeContentProps>()
|
||||
|
||||
const hasMedia = computed(() => props.media && props.media.urls.length > 0)
|
||||
const hasMedia = computed(() => media && media.urls.length > 0)
|
||||
|
||||
// Get node ID from nodeData
|
||||
const nodeId = computed(() => props.nodeData?.id?.toString())
|
||||
const nodeId = computed(() => nodeData?.id?.toString())
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -48,6 +48,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
||||
const latestPreview = ref<string[]>([])
|
||||
|
||||
const uploadingNodeIds = ref(new Set<string>())
|
||||
|
||||
function setNodeUploading(nodeId: string | number, uploading: boolean) {
|
||||
const id = String(nodeId)
|
||||
if (uploading) {
|
||||
uploadingNodeIds.value.add(id)
|
||||
} else {
|
||||
uploadingNodeIds.value.delete(id)
|
||||
}
|
||||
uploadingNodeIds.value = new Set(uploadingNodeIds.value)
|
||||
}
|
||||
|
||||
function isNodeUploading(nodeId: string | number): boolean {
|
||||
return uploadingNodeIds.value.has(String(nodeId))
|
||||
}
|
||||
|
||||
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
||||
scheduledRevoke[locator]?.stop()
|
||||
|
||||
@@ -463,6 +479,10 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
// Upload state
|
||||
setNodeUploading,
|
||||
isNodeUploading,
|
||||
|
||||
// State
|
||||
nodeOutputs,
|
||||
nodePreviewImages,
|
||||
|
||||
@@ -140,14 +140,11 @@ export class UserFile {
|
||||
* Saves the file to the remote storage.
|
||||
* @param force Whether to force the save even if the file is not modified.
|
||||
*/
|
||||
async save({
|
||||
force = false,
|
||||
overwrite
|
||||
}: { force?: boolean; overwrite?: boolean } = {}): Promise<UserFile> {
|
||||
async save({ force = false }: { force?: boolean } = {}): Promise<UserFile> {
|
||||
if (this.isPersisted && !this.isModified && !force) return this
|
||||
|
||||
const resp = await api.storeUserData(this.path, this.content, {
|
||||
overwrite: overwrite ?? this.isPersisted,
|
||||
overwrite: this.isPersisted,
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user