Compare commits

...

22 Commits

Author SHA1 Message Date
Chenlei Hu
5e6e34cfd3 Fix regex handling of folderName 2025-03-17 15:05:24 -04:00
Chenlei Hu
2a445f3f94 workaround placeholder filename 2025-03-17 14:49:21 -04:00
Chenlei Hu
f8dcb915aa nit 2025-03-17 14:12:49 -04:00
Chenlei Hu
11925ce345 Create folder support 2025-03-17 14:09:07 -04:00
Chenlei Hu
90053058ba [Refactor] Support handleAddFolder in TreeExplorer (#3101) 2025-03-17 14:08:23 -04:00
Chenlei Hu
b36f748a78 [nit] Remove unused provide in TreeExplorer (#3100) 2025-03-17 12:26:35 -04:00
Chenlei Hu
d57d12b426 [Refactor] Handle rename in TreeExplorer (#3099) 2025-03-17 12:26:26 -04:00
Christian Byrne
bd1be28478 [Manager] Add progress queue dialog (#3091)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 12:16:36 -04:00
Christian Byrne
891e18af8e [Manager] Improve node pack card header style (#3098) 2025-03-17 12:15:34 -04:00
Chenlei Hu
1610d06cd1 [Refactor] Accept single root node in TreeExplorer (#3088) 2025-03-17 10:52:06 -04:00
Comfy Org PR Bot
e3c7bbf966 1.14.0 (#3097)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-17 10:41:08 -04:00
Christian Byrne
0bfbbe838f [Manager] Remove shadows on version selector list (#3094) 2025-03-17 10:39:44 -04:00
Christian Byrne
c82fe80716 Allow rectangular virtual grid items (#3093) 2025-03-17 10:39:02 -04:00
Christian Byrne
ad98bcb87c [Manager] Fix registry search results flashing screen (#3092) 2025-03-17 10:38:08 -04:00
Christian Byrne
652ea15e8b Allow footer component and position props in dialogs (#3090) 2025-03-17 10:37:19 -04:00
Christian Byrne
a7a8cc633b [Manager] Improve node pack card design (#3089) 2025-03-17 10:36:35 -04:00
Comfy Org PR Bot
d23aec4ceb [chore] Update litegraph to 0.10.9 (#3095)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-18 00:21:02 +11:00
Chenlei Hu
8db088b27a Remove duplicated toolbuttons for workflow sidebar (#3087) 2025-03-16 20:18:24 -04:00
samnyan
7ef6e52f38 [nit] add refresh button to workflow sidebar tab (#3062) 2025-03-16 19:46:13 -04:00
Comfy Org PR Bot
edeefe0883 [chore] Update litegraph to 0.10.8 (#3086)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-16 19:18:49 -04:00
MohammadAboulEla
c6046e47d2 Allowing control over domWidgets margin (#3085) 2025-03-16 19:16:11 -04:00
filtered
e8bcccc276 [Test] Update expectation - graph ID (#3083) 2025-03-17 05:29:16 +11:00
51 changed files with 918 additions and 293 deletions

View File

@@ -95,18 +95,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
return this.page.locator('.workflows-sidebar-tab')
}
get browseGalleryButton() {
return this.root.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.root.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.root.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.root
.locator('.comfyui-workflows-open .node-label')

View File

@@ -673,7 +673,7 @@ test.describe('Load duplicate workflow', () => {
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.loadWorkflow('single_ksampler')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})

View File

@@ -119,7 +119,10 @@ test.describe('Menu', () => {
test('Can add new bookmark folder', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTab
await tab.newFolderButton.click()
await comfyPage.page.keyboard.press('Enter')
const textInput = comfyPage.page.locator('.editable-text input')
await textInput.waitFor({ state: 'visible' })
await textInput.fill('New Folder')
await textInput.press('Enter')
expect(await tab.getFolder('New Folder').count()).toBe(1)
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
@@ -132,8 +135,10 @@ test.describe('Menu', () => {
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('New Folder').click()
await comfyPage.page.keyboard.type('bar')
await comfyPage.page.keyboard.press('Enter')
const textInput = comfyPage.page.locator('.editable-text input')
await textInput.waitFor({ state: 'visible' })
await textInput.fill('bar')
await textInput.press('Enter')
expect(await tab.getFolder('bar').count()).toBe(1)
expect(
@@ -397,7 +402,7 @@ test.describe('Menu', () => {
'*Unsaved Workflow.json'
])
await tab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
@@ -496,7 +501,7 @@ test.describe('Menu', () => {
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
@@ -554,6 +559,8 @@ test.describe('Menu', () => {
})
// Compare the exported workflow with the original
delete downloadedContent.id
delete downloadedContentZh.id
expect(downloadedContent).toBeDefined()
expect(downloadedContent).toEqual(downloadedContentZh)
})
@@ -618,7 +625,7 @@ test.describe('Menu', () => {
// Load blank workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')

View File

@@ -57,13 +57,13 @@ test.describe('Templates', () => {
test('Can load template workflows', async ({ comfyPage }) => {
// Clear the workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
// Load a template
await comfyPage.menu.workflowsTab.browseGalleryButton.click()
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.7",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.7",
"version": "1.14.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.7",
"@comfyorg/litegraph": "^0.10.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -466,9 +466,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.7.tgz",
"integrity": "sha512-Cts4FBAk+e5RWPfmXUiBkjYzHkpy1eiW+DRCAYjGBGkobRhPZIBBHkk85vAnhMva704jSS9nsHG8c7r1/LUGyQ==",
"version": "0.10.9",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.9.tgz",
"integrity": "sha512-ubGozxdDIVNL/MYvfCAXgiaqBfIODtp0jZeN9uzWrdHwqUy9ZkLt/7/q7G4nGpNcEoShbMu7EK4VPH3WRmNQ7A==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.13.7",
"version": "1.14.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -73,7 +73,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.7",
"@comfyorg/litegraph": "^0.10.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -4,7 +4,7 @@
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
:value="renderedRoots"
:value="renderedRoot.children"
selectionMode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
@@ -43,21 +43,24 @@ import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperations'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode
import {
InjectKeyExpandedKeys,
InjectKeyHandleEditLabelFunction,
type RenderedTreeExplorerNode,
type TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
provide('expandedKeys', expandedKeys)
provide(InjectKeyExpandedKeys, expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
provide('selectionKeys', selectionKeys)
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
roots: TreeExplorerNode[]
root: TreeExplorerNode
class?: string
}>()
const emit = defineEmits<{
@@ -65,8 +68,23 @@ const emit = defineEmits<{
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
}>()
const renderedRoots = computed<RenderedTreeExplorerNode[]>(() => {
return props.roots.map(fillNodeInfo)
const {
newFolderNode,
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.getIcon) {
@@ -81,7 +99,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.leaf) {
return 'pi pi-file'
}
const isExpanded = expandedKeys.value[node.key]
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
@@ -95,7 +113,8 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : null
badgeText: node.getBadgeText ? node.getBadgeText() : null,
isEditingLabel: node.key === renameEditingNode.value?.key
}
}
const onNodeContentClick = async (
@@ -112,7 +131,6 @@ const onNodeContentClick = async (
}
const menu = ref(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
provide('menuTargetNode', menuTargetNode)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
@@ -121,7 +139,26 @@ const extraMenuItems = computed(() => {
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
provide('renameEditingNode', renameEditingNode)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
newName: string
) => {
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
await handleFolderCreation(newName)
} else {
await node.handleRename(newName)
}
},
node.handleError,
() => {
renameEditingNode.value = null
}
)()
}
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
@@ -133,6 +170,7 @@ const deleteCommand = async (node: RenderedTreeExplorerNode) => {
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
@@ -163,7 +201,6 @@ const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
}
}
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
@@ -181,7 +218,14 @@ const wrapCommandWithErrorHandler = (
defineExpose({
renameCommand,
deleteCommand
deleteCommand,
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey))
}
})
</script>

View File

@@ -38,18 +38,17 @@
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { Ref, computed, inject, ref } from 'vue'
import { computed, inject, ref } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import type {
RenderedTreeExplorerNode,
TreeExplorerDragAndDropData,
TreeExplorerNode
import {
InjectKeyHandleEditLabelFunction,
type RenderedTreeExplorerNode,
type TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const props = defineProps<{
@@ -77,22 +76,12 @@ const nodeBadgeText = computed<string>(() => {
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const labelEditable = computed<boolean>(() => !!props.node.handleRename)
const renameEditingNode =
inject<Ref<TreeExplorerNode | null>>('renameEditingNode')
const isEditing = computed(
() => labelEditable.value && renameEditingNode.value?.key === props.node.key
)
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(newName)
},
props.node.handleError,
() => {
renameEditingNode.value = null
}
)
const isEditing = computed<boolean>(() => props.node.isEditingLabel)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel(props.node, newName)
}
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)

View File

@@ -1,6 +1,6 @@
<template>
<div ref="container" class="scroll-container">
<div :style="{ height: `${(state.start / cols) * itemSize}px` }" />
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<slot name="item" :item="item"> </slot>
@@ -8,7 +8,7 @@
</div>
<div
:style="{
height: `${((props.items.length - state.end) / cols) * itemSize}px`
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
</div>
@@ -19,22 +19,25 @@ import { useElementSize, useScroll } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{
const {
items,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200
} = defineProps<{
items: (T & { key: string })[]
gridStyle: Partial<CSSProperties>
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemSize?: number
defaultItemHeight?: number
defaultItemWidth?: number
}>()
const {
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemSize = 200
} = props
const itemSize = ref(defaultItemSize)
const itemHeight = ref(defaultItemHeight)
const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
@@ -42,12 +45,10 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() => Math.floor(width.value / itemSize.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemSize.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
const isValidGrid = computed(
() => height.value && width.value && props.items?.length
)
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
const state = computed<{ start: number; end: number }>(() => {
const fromRow = offsetRows.value - bufferRows
@@ -57,18 +58,19 @@ const state = computed<{ start: number; end: number }>(() => {
const toCol = toRow * cols.value
return {
start: clamp(fromCol, 0, props.items?.length),
end: clamp(toCol, fromCol, props.items?.length)
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length)
}
})
const renderedItems = computed(() =>
isValidGrid.value ? props.items.slice(state.value.start, state.value.end) : []
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
itemSize.value = firstItem?.clientHeight || defaultItemSize
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
}
}
const onResize = debounce(updateItemSize, resizeDebounce)

View File

@@ -10,8 +10,10 @@ import { createI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useToastStore } from '@/stores/toastStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import {
InjectKeyHandleEditLabelFunction,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({
@@ -47,7 +49,6 @@ describe('TreeExplorerTreeNode', () => {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
provide: { renameEditingNode: { value: null } },
plugins: [createTestingPinia(), i18n]
}
})
@@ -63,10 +64,14 @@ describe('TreeExplorerTreeNode', () => {
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
props: {
node: {
...mockNode,
isEditingLabel: true
}
},
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
@@ -75,62 +80,25 @@ describe('TreeExplorerTreeNode', () => {
expect(editableText.props('isEditing')).toBe(true)
})
it('triggers handleRename callback when editing is finished', async () => {
const handleRenameMock = vi.fn()
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
it('triggers handleEditLabel callback when editing is finished', async () => {
const handleEditLabelMock = vi.fn()
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
props: {
node: {
...mockNode,
isEditingLabel: true
}
},
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
provide: { [InjectKeyHandleEditLabelFunction]: handleEditLabelMock },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
expect(handleRenameMock).toHaveBeenCalledOnce()
})
it('shows error toast when handleRename promise rejects', async () => {
const handleRenameMock = vi
.fn()
.mockRejectedValue(new Error('Rename failed'))
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const toastStore = useToastStore()
const addToastSpy = vi.spyOn(toastStore, 'add')
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Wait for the promise to reject and the toast to be added
vi.runAllTimers()
// Wait for any pending promises to resolve
await new Promise(process.nextTick)
expect(handleRenameMock).toHaveBeenCalledOnce()
expect(addToastSpy).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Rename failed'
})
expect(handleEditLabelMock).toHaveBeenCalledOnce()
})
})

View File

@@ -25,6 +25,10 @@
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template #footer v-if="item.footerComponent">
<component :is="item.footerComponent" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,114 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'max-h-0 p-0 m-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="px-6 py-4 overflow-y-auto max-h-[450px] scroll-container"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
:class="{
'max-h-[450px]': isExpanded,
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<Panel
:expanded="expandedPanels[index] || false"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span v-show="expandedPanels[index]" class="text-muted">
{{
index === taskPanels.length - 1
? 'In progress'
: 'Completed '
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
:icon="
expandedPanels[index]
? 'pi pi-chevron-down'
: 'pi pi-chevron-right'
"
text
class="text-neutral-300"
@click="togglePanel(index)"
/>
</template>
<div
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const expandedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
expandedPanels.value[index] = !expandedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef)
const scrollToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
whenever(() => isExpanded.value, scrollToBottom)
onMounted(() => {
expandedPanels.value = {}
scrollToBottom()
})
onBeforeUnmount(() => {
progressDialogContent.collapse()
})
</script>

View File

@@ -124,7 +124,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const DEFAULT_CARD_SIZE = 349
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -25,7 +25,7 @@
option-value="value"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">

View File

@@ -5,62 +5,65 @@
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
body: { class: 'p-0 flex flex-col h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
title: {
class:
'self-stretch px-4 py-3 inline-flex justify-start items-center gap-6'
},
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
/>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isPackInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
<PackCardHeader :node-pack="nodePack" />
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
</div>
</template>
@@ -76,21 +79,35 @@ import Card from 'primevue/card'
import { computed } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const managerStore = useComfyManagerStore()
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isPackInstalled = computed(() =>
managerStore.isPackInstalled(nodePack?.id)
)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isUpdateAvailable = computed(() => {
if (!isInstalled.value) return false
const latestVersion = nodePack.latest_version?.version
if (!latestVersion) return false
const installedVersion = getInstalledPackVersion(nodePack.id)
// Don't attempt to show update available for nightly GitHub packs
if (installedVersion && !isSemVer(installedVersion)) return false
return compareVersions(latestVersion, installedVersion) > 0
})
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
</script>

View File

@@ -1,10 +1,11 @@
<template>
<div class="flex justify-between p-5 text-xs text-muted">
<div
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
>
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
<span v-if="publisherName" class="max-w-40 truncate">
{{ publisherName }}
</span>
<PackVersionBadge v-if="isInstalled" :node-pack="nodePack" />
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
@@ -26,14 +27,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="w-[100%] flex justify-between items-center">
<div class="flex justify-start items-center">
<div class="w-1 h-6 rounded-md" />
<div class="w-6 h-6 relative overflow-hidden">
<i class="pi pi-box text-xl text-muted" style="opacity: 0.6" />
</div>
<div class="px-3 py-2 rounded-md flex justify-start items-start gap-2.5">
<div class="text-right justify-start text-sm font-bold leading-none">
{{ $t('manager.nodePack') }}
</div>
</div>
</div>
<div class="inline-flex justify-start items-center gap-3">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} of
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress"
rounded
outlined
class="px-4 py-2 rounded-md mr-4"
@click="handleRestart"
>
{{ $t('g.restart') }}
</Button>
<Button
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-right'
"
text
rounded
size="small"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
/>
<Button
icon="pi pi-times"
text
rounded
size="small"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('g.installing')
const currentTaskName = computed(() => {
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
await useComfyManagerService().rebootComfyUI()
closeDialog()
const onReconnect = () => {
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="px-4 py-2 flex items-center"
>
<TabMenu
v-model:activeIndex="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
</template>
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const tabs = [{ label: 'Installation Queue' }, { label: 'Failed (0)' }]
</script>

View File

@@ -30,7 +30,7 @@ const widgets = computed(() =>
)
)
const MARGIN = 10
const DEFAULT_MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
@@ -49,10 +49,11 @@ const updateWidgets = () => {
widgetState.visible = visible
if (visible) {
widgetState.pos = [node.pos[0] + MARGIN, node.pos[1] + MARGIN + widget.y]
const margin = widget.options.margin ?? DEFAULT_MARGIN
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [
(widget.width ?? node.width) - MARGIN * 2,
(widget.computedHeight ?? 50) - MARGIN * 2
(widget.width ?? node.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)

View File

@@ -32,7 +32,7 @@
<TreeExplorer
class="model-lib-tree-explorer"
:roots="renderedRoot.children"
:root="renderedRoot"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">

View File

@@ -49,7 +49,7 @@
/>
<TreeExplorer
class="node-lib-tree-explorer"
:roots="renderedRoot.children"
:root="renderedRoot"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">

View File

@@ -5,28 +5,18 @@
>
<template #tool-buttons>
<Button
class="browse-templates-button"
icon="pi pi-th-large"
icon="pi pi-folder-plus"
@click="addNewFolder"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.browseTemplates')"
text
@click="() => commandStore.execute('Comfy.BrowseTemplates')"
v-tooltip.bottom="$t('g.newFolder')"
/>
<Button
class="open-workflow-button"
icon="pi pi-folder-open"
icon="pi pi-refresh"
@click="workflowStore.syncWorkflows()"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.openWorkflow')"
text
@click="() => commandStore.execute('Comfy.OpenWorkflow')"
/>
<Button
class="new-blank-workflow-button"
icon="pi pi-plus"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
text
v-tooltip.bottom="$t('g.refresh')"
/>
</template>
<template #header>
@@ -49,9 +39,7 @@
class="ml-2"
/>
<TreeExplorer
:roots="
renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open).children
"
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selectionKeys="selectionKeys"
>
<template #node="{ node }">
@@ -87,11 +75,11 @@
class="ml-2"
/>
<TreeExplorer
:roots="
:root="
renderTreeNode(
bookmarkedWorkflowsTree,
WorkflowTreeType.Bookmarks
).children
)
"
:selectionKeys="selectionKeys"
>
@@ -107,11 +95,10 @@
class="ml-2"
/>
<TreeExplorer
:roots="
renderTreeNode(workflowsTree, WorkflowTreeType.Browse).children
"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
v-model:expandedKeys="expandedKeys"
:selectionKeys="selectionKeys"
ref="persistedWorkflowsTreeExplorerRef"
v-if="workflowStore.persistedWorkflows.length > 0"
>
<template #node="{ node }">
@@ -128,9 +115,7 @@
</div>
<div class="comfyui-workflows-search-panel" v-else>
<TreeExplorer
:roots="
renderTreeNode(filteredRoot, WorkflowTreeType.Browse).children
"
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
@@ -159,7 +144,6 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import {
useWorkflowBookmarkStore,
@@ -197,7 +181,6 @@ const handleSearch = (query: string) => {
})
}
const commandStore = useCommandStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workspaceStore = useWorkspaceStore()
@@ -244,7 +227,6 @@ const renderTreeNode = (
type: WorkflowTreeType
): TreeExplorerNode<ComfyWorkflow> => {
const children = node.children?.map((child) => renderTreeNode(child, type))
const workflow: ComfyWorkflow = node.data
function handleClick(this: TreeExplorerNode<ComfyWorkflow>, e: MouseEvent) {
@@ -255,7 +237,7 @@ const renderTreeNode = (
}
}
const actions = node.leaf
const actions: Partial<TreeExplorerNode<ComfyWorkflow>> = node.leaf
? {
handleClick,
async handleRename(newName: string) {
@@ -285,7 +267,16 @@ const renderTreeNode = (
},
draggable: true
}
: { handleClick }
: {
handleClick,
async handleAddFolder(folderName: string) {
if (folderName === '') return
const parentPath = this.key.replace(/^root\/?/, '')
const folderPath = parentPath + '/' + folderName
await workflowStore.createFolder(folderPath)
}
}
return {
key: node.key,
@@ -305,4 +296,11 @@ const workflowBookmarkStore = useWorkflowBookmarkStore()
onMounted(async () => {
await workflowBookmarkStore.loadBookmarks()
})
const persistedWorkflowsTreeExplorerRef = ref<InstanceType<
typeof TreeExplorer
> | null>(null)
const addNewFolder = () => {
persistedWorkflowsTreeExplorerRef.value?.addFolderCommand?.('root')
}
</script>

View File

@@ -2,7 +2,7 @@
<TreeExplorer
class="node-lib-bookmark-tree-explorer"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:root="renderedBookmarkedRoot"
:expandedKeys="expandedKeys"
>
<template #folder="{ node }">
@@ -40,7 +40,6 @@ import type {
TreeExplorerDragAndDropData,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { findNodeByKey } from '@/utils/treeUtil'
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
@@ -94,14 +93,6 @@ const { t } = useI18n()
const extraMenuItems = (
menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) => [
{
label: t('g.newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
addNewBookmarkFolder(menuTargetNode)
},
visible: !menuTargetNode?.leaf
},
{
label: t('g.customize'),
icon: 'pi pi-palette',
@@ -152,6 +143,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
},
children: sortedChildren,
draggable: node.leaf,
handleAddFolder(newName: string) {
if (newName !== '') {
nodeBookmarkStore.addNewBookmarkFolder(this.data, newName)
}
},
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
render(vnode, container)
@@ -197,25 +193,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
)
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
const addNewBookmarkFolder = (
parent?: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) => {
const newFolderKey =
'root/' + nodeBookmarkStore.addNewBookmarkFolder(parent?.data).slice(0, -1)
nextTick(() => {
treeExplorerRef.value?.renameCommand(
findNodeByKey(
renderedBookmarkedRoot.value,
newFolderKey
) as RenderedTreeExplorerNode
)
if (parent) {
expandedKeys.value[parent.key] = true
}
})
}
defineExpose({
addNewBookmarkFolder
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
})
const showCustomizationDialog = ref(false)

View File

@@ -8,21 +8,27 @@
</template>
<script setup lang="ts">
import { Ref, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { BookmarkCustomization } from '@/schemas/apiSchema'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import {
InjectKeyExpandedKeys,
type RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
const props = defineProps<{
const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const nodeBookmarkStore = useNodeBookmarkStore()
const customization = computed<BookmarkCustomization | undefined>(() => {
return nodeBookmarkStore.bookmarksCustomization[props.node.data.nodePath]
const nodeDef = node.data
return nodeDef
? nodeBookmarkStore.bookmarksCustomization[nodeDef.nodePath]
: undefined
})
const treeNodeElement = ref<HTMLElement | null>(null)
@@ -56,7 +62,7 @@ onUnmounted(() => {
}
})
const expandedKeys = inject<Ref<Record<string, boolean>>>('expandedKeys')
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
expandedKeys.value[node.key] = true
}

View File

@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
/**
* Use this to handle folder operations in a tree.
* @param expandNode - The function to expand a node.
*/
export function useTreeFolderOperations(
expandNode: (node: RenderedTreeExplorerNode) => void
) {
const { t } = useI18n()
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
// Generate a unique temporary key for the new folder
const generateTempKey = (parentKey: string) => {
return `${parentKey}/new_folder_${Date.now()}`
}
// Handle folder creation after name is confirmed
const handleFolderCreation = async (newName: string) => {
if (!newFolderNode.value || !addFolderTargetNode.value) return
try {
// Call the handleAddFolder method with the new folder name
await addFolderTargetNode.value?.handleAddFolder?.(newName)
} finally {
newFolderNode.value = null
addFolderTargetNode.value = null
}
}
/**
* The command to add a folder to a node via the context menu
* @param targetNode - The node where the folder will be added under
*/
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
expandNode(targetNode)
newFolderNode.value = {
key: generateTempKey(targetNode.key),
label: '',
leaf: false,
children: [],
icon: 'pi pi-folder',
type: 'folder',
totalLeaves: 0,
badgeText: '',
isEditingLabel: true
}
addFolderTargetNode.value = targetNode
}
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
) => {
return {
label: t('g.newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
if (targetNode) addFolderCommand(targetNode)
},
visible: targetNode && !targetNode.leaf && !!targetNode.handleAddFolder,
isAsync: false
}
}
return {
newFolderNode,
addFolderCommand,
getAddFolderMenuItem,
handleFolderCreation
}
}

View File

@@ -590,6 +590,15 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle Progress Dialog',
versionAdded: '1.13.9',
function: () => {
dialogService.showManagerProgressDialog()
}
}
]
}

View File

@@ -10,7 +10,7 @@ import {
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { PackField } from '@/types/comfyManagerTypes'
const SEARCH_DEBOUNCE_TIME = 16
const SEARCH_DEBOUNCE_TIME = 256
const DEFAULT_PAGE_SIZE = 64
/**

View File

@@ -23,30 +23,20 @@ export function useTreeExpansion(expandedKeys: Ref<Record<string, boolean>>) {
}
const expandNode = (node: TreeNode) => {
if (
node.key &&
typeof node.key === 'string' &&
node.children &&
node.children.length
) {
if (node.key && typeof node.key === 'string' && !node.leaf) {
expandedKeys.value[node.key] = true
for (const child of node.children) {
for (const child of node.children ?? []) {
expandNode(child)
}
}
}
const collapseNode = (node: TreeNode) => {
if (
node.key &&
typeof node.key === 'string' &&
node.children &&
node.children.length
) {
if (node.key && typeof node.key === 'string' && !node.leaf) {
delete expandedKeys.value[node.key]
for (const child of node.children) {
for (const child of node.children ?? []) {
collapseNode(child)
}
}

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle Progress Dialog"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -94,10 +94,12 @@
"filter": "Filter",
"apply": "Apply",
"enabled": "Enabled",
"installed": "Installed"
"installed": "Installed",
"restart": "Restart"
},
"manager": {
"title": "Custom Nodes Manager",
"restartToApplyChanges": "To apply changes, please restart ComfyUI",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
@@ -615,6 +617,7 @@
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -188,6 +188,7 @@
"reportSent": "Rapport soumis",
"reset": "Réinitialiser",
"resetKeybindingsTooltip": "Réinitialiser les raccourcis clavier par défaut",
"restart": "Redémarrer",
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"saving": "Enregistrement",
@@ -397,6 +398,7 @@
"nodePack": "Pack de Nœuds",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"restartToApplyChanges": "Pour appliquer les modifications, veuillez redémarrer ComfyUI",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"sort": {
@@ -530,6 +532,7 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -188,6 +188,7 @@
"reportSent": "レポートが送信されました",
"reset": "リセット",
"resetKeybindingsTooltip": "キーバインディングをデフォルトにリセット",
"restart": "再起動",
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"saving": "保存中",
@@ -397,6 +398,7 @@
"nodePack": "ノードパック",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"restartToApplyChanges": "変更を適用するには、ComfyUIを再起動してください",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"sort": {
@@ -530,6 +532,7 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "프로그레스 대화 상자 전환"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -188,6 +188,7 @@
"reportSent": "보고서 제출됨",
"reset": "재설정",
"resetKeybindingsTooltip": "키 바인딩을 기본값으로 재설정",
"restart": "재시작",
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"saving": "저장 중",
@@ -397,6 +398,7 @@
"nodePack": "노드 팩",
"packsSelected": "선택한 팩",
"repository": "저장소",
"restartToApplyChanges": "변경 사항을 적용하려면 ComfyUI를 재시작해 주세요",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"sort": {
@@ -530,6 +532,7 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 큐 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -188,6 +188,7 @@
"reportSent": "Отчёт отправлен",
"reset": "Сбросить",
"resetKeybindingsTooltip": "Сбросить сочетания клавиш по умолчанию",
"restart": "Перезапустить",
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"saving": "Сохранение",
@@ -397,6 +398,7 @@
"nodePack": "Пакет Узлов",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"restartToApplyChanges": "Чтобы применить изменения, пожалуйста, перезапустите ComfyUI",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"sort": {
@@ -530,6 +532,7 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "自定义节点管理器"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -188,6 +188,7 @@
"reportSent": "报告已提交",
"reset": "重置",
"resetKeybindingsTooltip": "将快捷键重置为默认",
"restart": "重新启动",
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"saving": "正在保存",
@@ -397,6 +398,7 @@
"nodePack": "节点包",
"packsSelected": "选定的包",
"repository": "仓库",
"restartToApplyChanges": "要应用更改请重新启动ComfyUI",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"sort": {
@@ -530,6 +532,7 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",

View File

@@ -63,6 +63,7 @@ export interface DOMWidgetOptions<V extends object | string>
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: BaseDOMWidget<V>) => void
margin?: number
/**
* @deprecated Use `afterResize` instead. This callback is a legacy API
* that fires before resize happens, but it is no longer supported. Now it

View File

@@ -131,7 +131,9 @@ export const useAlgoliaSearchService = () => {
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode)
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
comfy_nodes: algoliaNode.comfy_nodes
}
}

View File

@@ -2,11 +2,14 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import ExecutionErrorDialogContent from '@/components/dialog/content/ExecutionErrorDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
@@ -121,6 +124,29 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: '!p-0' },
header: { class: '!p-0 border-none' },
footer: { class: '!p-0 border-none' }
}
}
})
}
async function prompt({
title,
message,
@@ -203,6 +229,7 @@ export const useDialogService = () => {
showTemplateWorkflowsDialog,
showIssueReportDialog,
showManagerDialog,
showManagerProgressDialog,
prompt,
confirm
}

View File

@@ -6,6 +6,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { useServerLogs } from '@/composables/useServerLogs'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import {
InstallPackParams,
InstalledPacksResponse,
@@ -20,6 +21,8 @@ import {
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set())
const disabledPacksIds = ref<Set<string>>(new Set())
@@ -110,6 +113,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
whenever(isStale, refreshInstalledList, { immediate: true })
whenever(uncompletedCount, () => showManagerProgressDialog())
const withLogs = (task: () => Promise<null>, taskName: string) => {
const { startListening, stopListening, logs } = useServerLogs()
@@ -178,6 +182,11 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enqueueTask(withLogs(task, `Disabling ${params.id}`))
}
const getInstalledPackVersion = (packId: string) => {
const pack = installedPacks.value[packId]
return pack?.ver
}
const clearLogs = () => {
taskLogs.value = []
}
@@ -197,6 +206,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
installedPacksIds,
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
// Pack actions
installPack,
@@ -207,3 +217,33 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enablePack: installPack // Enable is done via install endpoint with a disabled pack
}
})
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand
}
}
)

View File

@@ -7,11 +7,24 @@ import { type Component, markRaw, ref } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
type DialogPosition =
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'topleft'
| 'topright'
| 'bottomleft'
| 'bottomright'
interface CustomDialogComponentProps {
maximizable?: boolean
maximized?: boolean
onClose?: () => void
closable?: boolean
modal?: boolean
position?: DialogPosition
pt?: DialogPassThroughOptions
}
@@ -25,6 +38,7 @@ interface DialogInstance {
headerComponent?: Component
component: Component
contentProps: Record<string, any>
footerComponent?: Component
dialogComponentProps: DialogComponentProps
}
@@ -32,6 +46,7 @@ export interface ShowDialogOptions {
key?: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
@@ -66,6 +81,7 @@ export const useDialogStore = defineStore('dialog', () => {
key: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
@@ -81,6 +97,9 @@ export const useDialogStore = defineStore('dialog', () => {
headerComponent: options.headerComponent
? markRaw(options.headerComponent)
: undefined,
footerComponent: options.footerComponent
? markRaw(options.footerComponent)
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
dialogComponentProps: {

View File

@@ -73,14 +73,12 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
)
}
const addNewBookmarkFolder = (parent?: ComfyNodeDefImpl) => {
const addNewBookmarkFolder = (
parent: ComfyNodeDefImpl | undefined,
folderName: string
) => {
const parentPath = parent ? parent.nodePath : ''
let newFolderPath = parentPath + 'New Folder/'
let suffix = 1
while (bookmarks.value.some((b: string) => b.startsWith(newFolderPath))) {
newFolderPath = parentPath + `New Folder ${suffix}/`
suffix++
}
const newFolderPath = parentPath + folderName + '/'
addBookmark(newFolderPath)
return newFolderPath
}

View File

@@ -13,6 +13,7 @@ import { UserFile } from './userFileStore'
export class ComfyWorkflow extends UserFile {
static readonly basePath = 'workflows/'
static readonly folderPlaceholderFilename = 'folder.index'
/**
* The change tracker for the workflow. Non-reactive raw object.
@@ -32,7 +33,9 @@ export class ComfyWorkflow extends UserFile {
}
get key() {
return this.path.substring(ComfyWorkflow.basePath.length)
const key = this.isFolderPlaceholder ? this.directory + '/' : this.path
return key.substring(ComfyWorkflow.basePath.length)
}
get activeState(): ComfyWorkflowJSON | null {
@@ -55,6 +58,13 @@ export class ComfyWorkflow extends UserFile {
this._isModified = value
}
/**
* Whether the workflow is a folder placeholder.
*/
get isFolderPlaceholder(): boolean {
return this.fullFilename === ComfyWorkflow.folderPlaceholderFilename
}
/**
* Load the workflow content from remote storage. Directly returns the loaded
* workflow if the content is already loaded.
@@ -153,6 +163,8 @@ export interface WorkflowStore {
getWorkflowByPath: (path: string) => ComfyWorkflow | null
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
createFolder: (folderPath: string) => Promise<void>
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -418,6 +430,40 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/**
* Creates a new folder in the workflows directory
* @param folderPath The path of the folder to create (relative to workflows/)
* @returns Promise that resolves when the folder is created
*/
const createFolder = async (folderPath: string): Promise<void> => {
isBusy.value = true
try {
// Ensure the path is properly formatted
const normalizedPath = folderPath.endsWith('/')
? folderPath.slice(0, -1)
: folderPath
// Create the full path including the reserved index file
const indexFilePath = `${ComfyWorkflow.basePath}${normalizedPath}/${ComfyWorkflow.folderPlaceholderFilename}`
// Create an empty file to represent the folder
const resp = await api.storeUserData(indexFilePath, '', {
overwrite: false,
throwOnError: true,
full_info: true
})
if (resp.status !== 200) {
throw new Error('Failed to create folder')
}
// Sync workflows to update the file tree
await syncWorkflows()
} finally {
isBusy.value = false
}
}
return {
activeWorkflow,
isActive,
@@ -439,7 +485,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
persistedWorkflows,
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows
syncWorkflows,
createFolder
}
}) as unknown as () => WorkflowStore

View File

@@ -1,4 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
import type { InjectionKey, Ref } from 'vue'
export interface TreeExplorerNode<T = any> {
key: string
@@ -18,10 +19,10 @@ export interface TreeExplorerNode<T = any> {
) => void | Promise<void>
/** Function to handle deleting the node */
handleDelete?: (this: TreeExplorerNode<T>) => void | Promise<void>
/** Function to handle adding a child node */
handleAddChild?: (
/** Function to handle adding a folder */
handleAddFolder?: (
this: TreeExplorerNode<T>,
child: TreeExplorerNode<T>
folderName: string
) => void | Promise<void>
/** Whether the node is draggable */
draggable?: boolean
@@ -58,9 +59,18 @@ export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
totalLeaves: number
/** Text to display on the leaf-count badge. Empty string means no badge. */
badgeText?: string
/** Whether the node label is currently being edited */
isEditingLabel?: boolean
}
export type TreeExplorerDragAndDropData<T = any> = {
type: 'tree-explorer-node'
data: RenderedTreeExplorerNode<T>
}
export const InjectKeyHandleEditLabelFunction: InjectionKey<
(node: RenderedTreeExplorerNode, newName: string) => void
> = Symbol()
export const InjectKeyExpandedKeys: InjectionKey<Ref<Record<string, boolean>>> =
Symbol()

View File

@@ -384,3 +384,31 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
return url
}
}
export const isSemVer = (version: string) => {
const regex = /^(\d+)\.(\d+)\.(\d+)$/
return regex.test(version)
}
const normalizeVersion = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part))
export function compareVersions(versionA: string, versionB: string): number {
versionA ??= '0.0.0'
versionB ??= '0.0.0'
const aParts = normalizeVersion(versionA)
const bParts = normalizeVersion(versionB)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}

View File

@@ -105,7 +105,10 @@ export function sortedTree(
return newNode
}
export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => {
export const findNodeByKey = <T extends TreeNode>(
root: T,
key: string
): T | null => {
if (root.key === key) {
return root
}
@@ -113,10 +116,46 @@ export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => {
return null
}
for (const child of root.children) {
const result = findNodeByKey(child, key)
const result = findNodeByKey(child as T, key)
if (result) {
return result
}
}
return null
}
/**
* Deep clone a tree node and its children.
* @param node - The node to clone.
* @returns A deep clone of the node.
*/
export function cloneTree<T extends TreeNode>(node: T): T {
const clone: T = { ...node } as T
// Clone children recursively
if (node.children && node.children.length > 0) {
clone.children = node.children.map((child) => cloneTree(child as T))
}
return clone
}
/**
* Merge a subtree into the tree.
* @param root - The root of the tree.
* @param subtree - The subtree to merge.
* @returns A new tree with the subtree merged.
*/
export const combineTrees = <T extends TreeNode>(root: T, subtree: T): T => {
const newRoot = cloneTree(root)
const parentKey = subtree.key.slice(0, subtree.key.lastIndexOf('/'))
const parent = findNodeByKey(newRoot, parentKey)
if (parent) {
parent.children ??= []
parent.children.push(cloneTree(subtree))
}
return newRoot
}