Compare commits

...

1 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
66fa344367 feat: add workflow folder operations (rename, delete, create, drag-drop move)
Add moveUserDataDir() and deleteUserDataDir() API methods. Add
moveWorkflowToFolder, renameFolder, deleteFolder, createFolder store
actions. Wire folder rename/delete/create/drop to Browse tree nodes
and add New Folder toolbar button.
2026-03-05 15:04:23 -05:00
4 changed files with 157 additions and 4 deletions

View File

@@ -9,6 +9,15 @@
<slot name="alt-title" />
</template>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.newFolder')"
@click="browseTreeRef?.addFolderCommand('root')"
>
<i class="icon-[lucide--folder-plus] size-4" />
</Button>
<Button
v-tooltip.bottom="$t('g.refresh')"
variant="muted-textonly"
@@ -107,7 +116,7 @@
class="ml-2"
/>
<TreeExplorer
v-if="filteredPersistedWorkflows.length > 0"
ref="browseTreeRef"
v-model:expanded-keys="expandedKeys"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
:selection-keys="selectionKeys"
@@ -116,7 +125,10 @@
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
<slot v-else name="empty-state">
<slot
v-if="filteredPersistedWorkflows.length === 0"
name="empty-state"
>
<NoResultsPlaceholder
icon="pi pi-folder"
:title="$t('g.empty')"
@@ -162,7 +174,11 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import type {
TreeExplorerDragAndDropData,
TreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
@@ -193,6 +209,9 @@ const workflowTabsPosition = computed(() =>
)
const searchBoxRef = ref()
const browseTreeRef = ref<{ addFolderCommand: (key: string) => void } | null>(
null
)
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.length > 0)
@@ -324,7 +343,53 @@ const renderTreeNode = (
},
draggable: true
}
: { handleClick }
: type === WorkflowTreeType.Browse
? {
handleClick,
...(node.key !== 'root'
? {
handleRename: async (newName: string) => {
const folderKey = node.key.replace(/^root\//, '')
const parentKey = folderKey.substring(
0,
folderKey.lastIndexOf('/')
)
const newFolderKey = parentKey
? parentKey + '/' + newName
: newName
await workflowStore.renameFolder(
'workflows/' + folderKey,
'workflows/' + newFolderKey
)
},
handleDelete: async () => {
const folderKey = node.key.replace(/^root\//, '')
await workflowStore.deleteFolder('workflows/' + folderKey)
}
}
: {}),
handleAddFolder: async (name: string) => {
const folderPath =
node.key === 'root'
? 'workflows/' + name
: 'workflows/' + node.key.replace(/^root\//, '') + '/' + name
await workflowStore.createFolder(folderPath)
},
droppable: true,
handleDrop: async (
data: TreeExplorerDragAndDropData<ComfyWorkflow>
) => {
const droppedWorkflow = data.data.data
if (droppedWorkflow) {
const destDir =
node.key === 'root'
? 'workflows'
: 'workflows/' + node.key.replace(/^root\//, '')
await workflowStore.moveWorkflowToFolder(droppedWorkflow, destDir)
}
}
}
: { handleClick }
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label

View File

@@ -899,6 +899,8 @@
"dirtyCloseHint": "Hold Shift to close without prompt",
"confirmOverwriteTitle": "Overwrite existing file?",
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
"confirmDeleteFolderTitle": "Delete Folder",
"confirmDeleteFolderMessage": "Delete this folder and all workflows inside it?",
"workflowTreeType": {
"browse": "Browse",
"bookmarks": "Bookmarks",

View File

@@ -62,8 +62,15 @@ interface WorkflowStore {
workflowData?: ComfyWorkflowJSON
) => ComfyWorkflow
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
moveWorkflowToFolder: (
workflow: ComfyWorkflow,
destDir: string
) => Promise<void>
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
renameFolder: (oldPath: string, newPath: string) => Promise<void>
deleteFolder: (path: string) => Promise<void>
createFolder: (folderPath: string) => Promise<void>
workflows: ComfyWorkflow[]
bookmarkedWorkflows: ComfyWorkflow[]
@@ -521,6 +528,67 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
const moveWorkflowToFolder = async (
workflow: ComfyWorkflow,
destDir: string
) => {
const newPath = destDir + '/' + workflow.fullFilename
await renameWorkflow(workflow, newPath)
}
const renameFolder = async (oldPath: string, newPath: string) => {
isBusy.value = true
try {
const resp = await api.moveUserDataDir(oldPath, newPath)
if (resp.status !== 200) {
throw new Error(
`Failed to rename folder: ${resp.status} ${resp.statusText}`
)
}
await syncWorkflows()
} finally {
isBusy.value = false
}
}
const deleteFolder = async (path: string) => {
isBusy.value = true
try {
const prefix = path.endsWith('/') ? path : path + '/'
for (const w of [...openWorkflows.value]) {
if (w.path.startsWith(prefix)) {
await closeWorkflow(w)
}
}
const resp = await api.deleteUserDataDir(path)
if (resp.status !== 200) {
throw new Error(
`Failed to delete folder: ${resp.status} ${resp.statusText}`
)
}
await syncWorkflows()
} finally {
isBusy.value = false
}
}
const createFolder = async (folderPath: string) => {
isBusy.value = true
try {
const normalizedPath = folderPath.endsWith('/')
? folderPath
: folderPath + '/'
await api.storeUserData(normalizedPath, '', {
overwrite: false,
stringify: false,
throwOnError: true
})
await syncWorkflows()
} finally {
isBusy.value = false
}
}
/**
* Save a workflow.
* @param workflow The workflow to save.
@@ -755,9 +823,13 @@ export const useWorkflowStore = defineStore('workflow', () => {
createTemporary,
createNewTemporary,
renameWorkflow,
moveWorkflowToFolder,
deleteWorkflow,
saveAs,
saveWorkflow,
renameFolder,
deleteFolder,
createFolder,
reorderWorkflows,
workflows,

View File

@@ -1183,6 +1183,20 @@ export class ComfyApi extends EventTarget {
return resp
}
async moveUserDataDir(source: string, dest: string) {
return this.fetchApi('/userdata/dir/move', {
method: 'POST',
body: JSON.stringify({ source, dest }),
headers: { 'Content-Type': 'application/json' }
})
}
async deleteUserDataDir(path: string) {
return this.fetchApi(`/userdata/dir?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
})
}
async listUserDataFullInfo(dir: string): Promise<UserDataFullInfo[]> {
const trimmedDir = trimEnd(dir, '/')
const resp = await this.fetchApi(