mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-11 10:30:10 +00:00
Workflow Management Reworked (#1406)
* Merge temp userfile Basic migration Remove deprecated isFavourite Rename nit nit Rework open/load Refactor save Refactor delete Remove workflow dep on manager WIP Change map to record Fix directory nit isActive Move nit Add unload Add close workflow Remove workflowManager.closeWorkflow nit Remove workflowManager.storePrompt move from commandStore move more from commandStore nit Use workflowservice nit nit implement setWorkflow nit Remove workflows.ts Fix strict errors nit nit Resolves circular dep nit nit Fix workflow switching Add openworkflowPaths Fix store Fix key Serialize by default Fix proxy nit Update path Proper sync Fix tabs WIP nit Resolve merge conflict Fix userfile store tests Update jest test Update tabs patch tests Fix changeTracker init Move insert to service nit Fix insert nit Handle bookmark rename Refactor tests Add delete workflow Add test on deleting workflow Add closeWorkflow tests nit * Fix path * Move load next/previous * Move logic from store to service * nit * nit * nit * nit * nit * Add ChangeTracker.initialState * ChangeTracker load/unload * Remove app.changeWorkflow * Hook to app.ts * Changetracker restore * nit * nit * nit * Add debug logs * Remove unnecessary checkState on graphLoad * nit * Fix strict * Fix temp workflow name * Track ismodified * Fix reactivity * nit * Fix graph equal * nit * update test * nit * nit * Fix modified state * nit * Fix modified state * Sidebar force close * tabs force close * Fix save * Add load remote workflow test * Force save * Add save test * nit * Correctly handle delete last opened workflow * nit * Fix workflow rename * Fix save * Fix tests * Fix strict * Update playwright tests * Fix filename conflict handling * nit * Merge temporary and persisted ref * Update playwright expectations * nit * nit * Fix saveAs * Add playwright test * nit
This commit is contained in:
@@ -2,7 +2,6 @@ import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { globalTracker } from '@/scripts/changeTracker'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import {
|
||||
@@ -15,7 +14,7 @@ import { ComfyExtension } from '@/types/comfy'
|
||||
import { LGraphGroup } from '@comfyorg/litegraph'
|
||||
import { useTitleEditorStore } from './graphStore'
|
||||
import { useErrorHandling } from '@/hooks/errorHooks'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
|
||||
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
@@ -76,8 +75,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const getTracker = () =>
|
||||
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
|
||||
const getTracker = () => useWorkflowStore()?.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
@@ -120,12 +118,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
function: () => {
|
||||
app.workflowManager.setWorkflow(null)
|
||||
app.clean()
|
||||
app.graph.clear()
|
||||
app.workflowManager.activeWorkflow?.track()
|
||||
}
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.OpenWorkflow',
|
||||
@@ -140,17 +133,18 @@ export const useCommandStore = defineStore('command', () => {
|
||||
id: 'Comfy.LoadDefaultWorkflow',
|
||||
icon: 'pi pi-code',
|
||||
label: 'Load Default Workflow',
|
||||
function: async () => {
|
||||
await app.loadGraphData()
|
||||
}
|
||||
function: () => workflowService.loadDefaultWorkflow()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.SaveWorkflow',
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save()
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -158,8 +152,11 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save(true)
|
||||
function: async () => {
|
||||
const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -185,7 +182,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-undo',
|
||||
label: 'Undo',
|
||||
function: async () => {
|
||||
await getTracker().undo()
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -193,7 +190,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
function: async () => {
|
||||
await getTracker().redo()
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -387,7 +384,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Next Opened Workflow',
|
||||
versionAdded: '1.3.9',
|
||||
function: () => {
|
||||
useWorkflowStore().loadNextOpenedWorkflow()
|
||||
workflowService.loadNextOpenedWorkflow()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -396,7 +393,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Previous Opened Workflow',
|
||||
versionAdded: '1.3.9',
|
||||
function: () => {
|
||||
useWorkflowStore().loadPreviousOpenedWorkflow()
|
||||
workflowService.loadPreviousOpenedWorkflow()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { ComfyWorkflow } from './workflowStore'
|
||||
import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import type {
|
||||
ExecutedWsMessage,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { buildTree } from '@/utils/treeUtil'
|
||||
import { computed, ref } from 'vue'
|
||||
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { UserDataFullInfo } from '@/types/apiTypes'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { getPathDetails } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Represents a file in the user's data directory.
|
||||
@@ -37,25 +39,60 @@ export class UserFile {
|
||||
*/
|
||||
public lastModified: number,
|
||||
/**
|
||||
* File size in bytes.
|
||||
* File size in bytes. -1 for temporary files.
|
||||
*/
|
||||
public size: number
|
||||
) {
|
||||
this.directory = path.split('/').slice(0, -1).join('/')
|
||||
this.fullFilename = path.split('/').pop() ?? path
|
||||
this.filename = this.fullFilename.split('.').slice(0, -1).join('.')
|
||||
this.suffix = this.fullFilename.split('.').pop() ?? null
|
||||
const details = getPathDetails(path)
|
||||
this.path = path
|
||||
this.directory = details.directory
|
||||
this.fullFilename = details.fullFilename
|
||||
this.filename = details.filename
|
||||
this.suffix = details.suffix
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return !!this.content
|
||||
updatePath(newPath: string) {
|
||||
const details = getPathDetails(newPath)
|
||||
this.path = newPath
|
||||
this.directory = details.directory
|
||||
this.fullFilename = details.fullFilename
|
||||
this.filename = details.filename
|
||||
this.suffix = details.suffix
|
||||
}
|
||||
|
||||
static createTemporary(path: string): UserFile {
|
||||
return new UserFile(path, Date.now(), -1)
|
||||
}
|
||||
|
||||
get isTemporary() {
|
||||
return this.size === 0
|
||||
}
|
||||
|
||||
get isPersisted() {
|
||||
return !this.isTemporary
|
||||
}
|
||||
|
||||
get key(): string {
|
||||
return this.path
|
||||
}
|
||||
|
||||
get isLoaded() {
|
||||
return this.content !== null
|
||||
}
|
||||
|
||||
get isModified() {
|
||||
return this.content !== this.originalContent
|
||||
}
|
||||
|
||||
async load(): Promise<UserFile> {
|
||||
/**
|
||||
* Loads the file content from the remote storage.
|
||||
*/
|
||||
async load({
|
||||
force = false
|
||||
}: { force?: boolean } = {}): Promise<LoadedUserFile> {
|
||||
if (this.isTemporary || (!force && this.isLoaded))
|
||||
return this as LoadedUserFile
|
||||
|
||||
this.isLoading = true
|
||||
const resp = await api.getUserData(this.path)
|
||||
if (resp.status !== 200) {
|
||||
@@ -66,19 +103,34 @@ export class UserFile {
|
||||
this.content = await resp.text()
|
||||
this.originalContent = this.content
|
||||
this.isLoading = false
|
||||
return this
|
||||
return this as LoadedUserFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads the file content from memory
|
||||
*/
|
||||
unload(): void {
|
||||
this.content = null
|
||||
this.originalContent = null
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
async saveAs(newPath: string): Promise<UserFile> {
|
||||
const tempFile = new TempUserFile(newPath, this.content ?? undefined)
|
||||
const tempFile = this.isTemporary ? this : UserFile.createTemporary(newPath)
|
||||
tempFile.content = this.content
|
||||
await tempFile.save()
|
||||
return tempFile
|
||||
}
|
||||
|
||||
async save(): Promise<UserFile> {
|
||||
if (!this.isModified) return this
|
||||
/**
|
||||
* 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 }: { force?: boolean } = {}): Promise<UserFile> {
|
||||
if (this.isPersisted && !this.isModified && !force) return this
|
||||
|
||||
const resp = await api.storeUserData(this.path, this.content, {
|
||||
overwrite: this.isPersisted,
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
@@ -95,6 +147,8 @@ export class UserFile {
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
if (this.isTemporary) return
|
||||
|
||||
const resp = await api.deleteUserData(this.path)
|
||||
if (resp.status !== 204) {
|
||||
throw new Error(
|
||||
@@ -104,13 +158,18 @@ export class UserFile {
|
||||
}
|
||||
|
||||
async rename(newPath: string): Promise<UserFile> {
|
||||
if (this.isTemporary) {
|
||||
this.updatePath(newPath)
|
||||
return this
|
||||
}
|
||||
|
||||
const resp = await api.moveUserData(this.path, newPath)
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to rename file '${this.path}': ${resp.status} ${resp.statusText}`
|
||||
)
|
||||
}
|
||||
this.path = newPath
|
||||
this.updatePath(newPath)
|
||||
// Note: Backend supports full_info=true feature after
|
||||
// https://github.com/comfyanonymous/ComfyUI/pull/5446
|
||||
const updatedFile = (await resp.json()) as string | UserDataFullInfo
|
||||
@@ -122,56 +181,21 @@ export class UserFile {
|
||||
}
|
||||
}
|
||||
|
||||
export class TempUserFile extends UserFile {
|
||||
constructor(path: string, content: string = '') {
|
||||
// Initialize with current timestamp and 0 size since it's temporary
|
||||
super(path, Date.now(), 0)
|
||||
this.content = content
|
||||
this.originalContent = content
|
||||
}
|
||||
|
||||
// Override methods that interact with backend
|
||||
async load(): Promise<TempUserFile> {
|
||||
// No need to load as it's a temporary file
|
||||
return this
|
||||
}
|
||||
|
||||
async save(): Promise<TempUserFile> {
|
||||
// First save should create the actual file on the backend
|
||||
const resp = await api.storeUserData(this.path, this.content, {
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
|
||||
const updatedFile = (await resp.json()) as string | UserDataFullInfo
|
||||
if (typeof updatedFile === 'object') {
|
||||
this.lastModified = updatedFile.modified
|
||||
this.size = updatedFile.size
|
||||
}
|
||||
this.originalContent = this.content
|
||||
return this
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
// No need to delete from backend as it doesn't exist there
|
||||
}
|
||||
|
||||
async rename(newPath: string): Promise<TempUserFile> {
|
||||
// Just update the path locally since it's not on backend yet
|
||||
this.path = newPath
|
||||
return this
|
||||
}
|
||||
export interface LoadedUserFile extends UserFile {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const useUserFileStore = defineStore('userFile', () => {
|
||||
const userFilesByPath = ref(new Map<string, UserFile>())
|
||||
const userFilesByPath = ref<Record<string, UserFile>>({})
|
||||
|
||||
const userFiles = computed(() => Array.from(userFilesByPath.value.values()))
|
||||
const userFiles = computed(() => Object.values(userFilesByPath.value))
|
||||
const modifiedFiles = computed(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isModified)
|
||||
)
|
||||
const openedFiles = computed(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isOpen)
|
||||
const loadedFiles = computed(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isLoaded)
|
||||
)
|
||||
|
||||
const fileTree = computed<TreeExplorerNode<UserFile>>(
|
||||
@@ -186,39 +210,22 @@ export const useUserFileStore = defineStore('userFile', () => {
|
||||
* @param dir The directory to sync.
|
||||
*/
|
||||
const syncFiles = async (dir: string = '') => {
|
||||
const files = await api.listUserDataFullInfo(dir)
|
||||
|
||||
for (const file of files) {
|
||||
const existingFile = userFilesByPath.value.get(file.path)
|
||||
|
||||
if (!existingFile) {
|
||||
// New file, add it to the map
|
||||
userFilesByPath.value.set(
|
||||
file.path,
|
||||
new UserFile(file.path, file.modified, file.size)
|
||||
)
|
||||
} else if (existingFile.lastModified !== file.modified) {
|
||||
// File has been modified, update its properties
|
||||
await syncEntities(
|
||||
dir,
|
||||
userFilesByPath.value,
|
||||
(file) => new UserFile(file.path, file.modified, file.size),
|
||||
(existingFile, file) => {
|
||||
existingFile.lastModified = file.modified
|
||||
existingFile.size = file.size
|
||||
existingFile.originalContent = null
|
||||
existingFile.content = null
|
||||
existingFile.isLoading = false
|
||||
existingFile.unload()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files that no longer exist
|
||||
for (const [path, _] of userFilesByPath.value) {
|
||||
if (!files.some((file) => file.path === path)) {
|
||||
userFilesByPath.value.delete(path)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
userFiles,
|
||||
modifiedFiles,
|
||||
openedFiles,
|
||||
loadedFiles,
|
||||
fileTree,
|
||||
syncFiles
|
||||
}
|
||||
|
||||
@@ -1,24 +1,292 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { computed, markRaw, ref } from 'vue'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { UserFile } from './userFileStore'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import { appendJsonExt, getPathDetails } from '@/utils/formatUtil'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
private _isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
get key() {
|
||||
return this.path.substring('workflows/'.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
async load({
|
||||
force = false
|
||||
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
// Note: originalContent is populated by super.load()
|
||||
console.debug('load and start tracking of workflow', this.path)
|
||||
this.changeTracker = markRaw(
|
||||
new ChangeTracker(
|
||||
this,
|
||||
/* initialState= */ JSON.parse(this.originalContent)
|
||||
)
|
||||
)
|
||||
return this as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
unload(): void {
|
||||
console.debug('unload workflow', this.path)
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
async save() {
|
||||
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 })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
async saveAs(path: string) {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
return await super.saveAs(path)
|
||||
}
|
||||
|
||||
async rename(newName: string) {
|
||||
const newPath = this.directory + '/' + appendJsonExt(newName)
|
||||
await super.rename(newPath)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const activeWorkflow = ref<ComfyWorkflow | null>(null)
|
||||
/**
|
||||
* Detach the workflow from the store. lightweight helper function.
|
||||
* @param workflow The workflow to detach.
|
||||
* @returns The index of the workflow in the openWorkflowPaths array, or -1 if the workflow was not open.
|
||||
*/
|
||||
const detachWorkflow = (workflow: ComfyWorkflow) => {
|
||||
delete workflowLookup.value[workflow.path]
|
||||
const index = openWorkflowPaths.value.indexOf(workflow.path)
|
||||
if (index !== -1) {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the workflow to the store. lightweight helper function.
|
||||
* @param workflow The workflow to attach.
|
||||
* @param openIndex The index to open the workflow at.
|
||||
*/
|
||||
const attachWorkflow = (workflow: ComfyWorkflow, openIndex: number = -1) => {
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
|
||||
if (openIndex !== -1) {
|
||||
openWorkflowPaths.value.splice(openIndex, 0, workflow.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active workflow currently being edited.
|
||||
*/
|
||||
const activeWorkflow = ref<LoadedComfyWorkflow | null>(null)
|
||||
const isActive = (workflow: ComfyWorkflow) =>
|
||||
activeWorkflow.value?.path === workflow.path
|
||||
/**
|
||||
* All workflows.
|
||||
*/
|
||||
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
|
||||
const workflows = computed(() => Object.values(workflowLookup.value))
|
||||
const persistedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isPersisted)
|
||||
const workflows = computed<ComfyWorkflow[]>(() =>
|
||||
Object.values(workflowLookup.value)
|
||||
)
|
||||
const getWorkflowByPath = (path: string): ComfyWorkflow | null =>
|
||||
workflowLookup.value[path] ?? null
|
||||
|
||||
/**
|
||||
* The paths of the open workflows. It is setup as a ref to allow user
|
||||
* to reorder the workflows opened.
|
||||
*/
|
||||
const openWorkflowPaths = ref<string[]>([])
|
||||
const openWorkflowPathSet = computed(() => new Set(openWorkflowPaths.value))
|
||||
const openWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isOpen)
|
||||
openWorkflowPaths.value.map((path) => workflowLookup.value[path])
|
||||
)
|
||||
const isOpen = (workflow: ComfyWorkflow) =>
|
||||
openWorkflowPathSet.value.has(workflow.path)
|
||||
|
||||
/**
|
||||
* Set the workflow as the active workflow.
|
||||
* @param workflow The workflow to open.
|
||||
*/
|
||||
const openWorkflow = async (
|
||||
workflow: ComfyWorkflow
|
||||
): Promise<LoadedComfyWorkflow> => {
|
||||
if (isActive(workflow)) return workflow as LoadedComfyWorkflow
|
||||
|
||||
if (!openWorkflowPaths.value.includes(workflow.path)) {
|
||||
openWorkflowPaths.value.push(workflow.path)
|
||||
}
|
||||
const loadedWorkflow = await workflow.load()
|
||||
activeWorkflow.value = loadedWorkflow
|
||||
console.debug('[workflowStore] open workflow', workflow.path)
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
const getUnconflictedPath = (basePath: string): string => {
|
||||
const { directory, filename, suffix } = getPathDetails(basePath)
|
||||
let counter = 2
|
||||
let newPath = basePath
|
||||
while (workflowLookup.value[newPath]) {
|
||||
newPath = `${directory}/${filename} (${counter}).${suffix}`
|
||||
counter++
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
|
||||
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
|
||||
const fullPath = getUnconflictedPath(
|
||||
'workflows/' + (path ?? 'Unsaved Workflow.json')
|
||||
)
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: fullPath,
|
||||
modified: Date.now(),
|
||||
size: 0
|
||||
})
|
||||
|
||||
workflow.originalContent = workflow.content = workflowData
|
||||
? JSON.stringify(workflowData)
|
||||
: defaultGraphJSON
|
||||
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const closeWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
if (workflow.isTemporary) {
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
}
|
||||
console.debug('[workflowStore] close workflow', workflow.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workflow at the given index shift from the active workflow.
|
||||
* @param shift The shift to the next workflow. Positive for next, negative for previous.
|
||||
* @returns The next workflow or null if the shift is out of bounds.
|
||||
*/
|
||||
const openedWorkflowIndexShift = (shift: number): ComfyWorkflow | null => {
|
||||
const index = openWorkflowPaths.value.indexOf(
|
||||
activeWorkflow.value?.path ?? ''
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const length = openWorkflows.value.length
|
||||
const nextIndex = (index + shift + length) % length
|
||||
const nextWorkflow = openWorkflows.value[nextIndex]
|
||||
return nextWorkflow ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const persistedWorkflows = computed(() =>
|
||||
Array.from(workflows.value).filter((workflow) => workflow.isPersisted)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
existingWorkflow.lastModified = file.modified
|
||||
existingWorkflow.size = file.size
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
}
|
||||
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
const bookmarkedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isBookmarked)
|
||||
workflows.value.filter((workflow) =>
|
||||
bookmarkStore.isBookmarked(workflow.path)
|
||||
)
|
||||
)
|
||||
const modifiedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.unsaved)
|
||||
workflows.value.filter((workflow) => workflow.isModified)
|
||||
)
|
||||
|
||||
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
|
||||
@@ -31,50 +299,78 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
)
|
||||
// Bookmarked workflows tree is flat.
|
||||
const bookmarkedWorkflowsTree = computed(() =>
|
||||
buildTree(bookmarkedWorkflows.value, (workflow: ComfyWorkflow) => [
|
||||
workflow.path ?? 'temporary_workflow'
|
||||
])
|
||||
buildTree(bookmarkedWorkflows.value, (workflow) => [workflow.key])
|
||||
)
|
||||
// Open workflows tree is flat.
|
||||
const openWorkflowsTree = computed(() =>
|
||||
buildTree(openWorkflows.value, (workflow: ComfyWorkflow) => [workflow.key])
|
||||
buildTree(openWorkflows.value, (workflow) => [workflow.key])
|
||||
)
|
||||
|
||||
const loadOpenedWorkflowIndexShift = async (shift: number) => {
|
||||
const index = openWorkflows.value.indexOf(
|
||||
activeWorkflow.value as ComfyWorkflow
|
||||
)
|
||||
if (index !== -1) {
|
||||
const length = openWorkflows.value.length
|
||||
const nextIndex = (index + shift + length) % length
|
||||
const nextWorkflow = openWorkflows.value[nextIndex]
|
||||
if (nextWorkflow) {
|
||||
await nextWorkflow.load()
|
||||
}
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newName: string) => {
|
||||
// Capture all needed values upfront
|
||||
const oldPath = workflow.path
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newName)
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
// Perform the actual rename operation first
|
||||
try {
|
||||
await workflow.rename(newName)
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
// Update bookmarks
|
||||
if (wasBookmarked) {
|
||||
bookmarkStore.setBookmarked(oldPath, false)
|
||||
bookmarkStore.setBookmarked(newPath, true)
|
||||
}
|
||||
}
|
||||
|
||||
const loadNextOpenedWorkflow = async () => {
|
||||
await loadOpenedWorkflowIndexShift(1)
|
||||
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
await workflow.delete()
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
bookmarkStore.setBookmarked(workflow.path, false)
|
||||
}
|
||||
delete workflowLookup.value[workflow.path]
|
||||
}
|
||||
|
||||
const loadPreviousOpenedWorkflow = async () => {
|
||||
await loadOpenedWorkflowIndexShift(-1)
|
||||
/**
|
||||
* Save a workflow.
|
||||
* @param workflow The workflow to save.
|
||||
*/
|
||||
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
// Detach the workflow and re-attach to force refresh the tree objects.
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
try {
|
||||
await workflow.save()
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
workflows,
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openWorkflowsTree,
|
||||
openedWorkflowIndexShift,
|
||||
openWorkflow,
|
||||
isOpen,
|
||||
closeWorkflow,
|
||||
createTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
saveWorkflow,
|
||||
|
||||
workflows,
|
||||
bookmarkedWorkflows,
|
||||
modifiedWorkflows,
|
||||
workflowLookup,
|
||||
getWorkflowByPath,
|
||||
workflowsTree,
|
||||
bookmarkedWorkflowsTree,
|
||||
openWorkflowsTree,
|
||||
buildWorkflowTree,
|
||||
loadNextOpenedWorkflow,
|
||||
loadPreviousOpenedWorkflow
|
||||
syncWorkflows
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +394,7 @@ export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
}
|
||||
|
||||
const setBookmarked = (path: string, value: boolean) => {
|
||||
if (bookmarks.value.has(path) === value) return
|
||||
if (value) {
|
||||
bookmarks.value.add(path)
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQueueSettingsStore } from './queueStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
import { useSettingStore } from './settingStore'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const spinner = ref(false)
|
||||
@@ -26,6 +27,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
get: useSettingStore().get,
|
||||
set: useSettingStore().set
|
||||
}))
|
||||
const workflow = computed(() => useWorkflowStore())
|
||||
|
||||
/**
|
||||
* Registers a sidebar tab.
|
||||
@@ -66,6 +68,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
command,
|
||||
sidebarTab,
|
||||
setting,
|
||||
workflow,
|
||||
|
||||
registerSidebarTab,
|
||||
unregisterSidebarTab,
|
||||
|
||||
Reference in New Issue
Block a user